@namzu/cli 29.0.1 → 29.1.0
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 +115 -0
- package/README.md +20 -0
- package/dist/browser/commands.d.ts +29 -0
- package/dist/browser/commands.d.ts.map +1 -0
- package/dist/browser/commands.js +361 -0
- package/dist/browser/commands.js.map +1 -0
- package/dist/browser/control.d.ts +79 -0
- package/dist/browser/control.d.ts.map +1 -0
- package/dist/browser/control.js +140 -0
- package/dist/browser/control.js.map +1 -0
- package/dist/browser/describe.d.ts +12 -0
- package/dist/browser/describe.d.ts.map +1 -0
- package/dist/browser/describe.js +27 -0
- package/dist/browser/describe.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +29 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/acp.d.ts.map +1 -1
- package/dist/commands/acp.js +1 -0
- package/dist/commands/acp.js.map +1 -1
- package/dist/commands/browser.d.ts +7 -0
- package/dist/commands/browser.d.ts.map +1 -0
- package/dist/commands/browser.js +16 -0
- package/dist/commands/browser.js.map +1 -0
- package/dist/commands/drain.d.ts +2 -1
- package/dist/commands/drain.d.ts.map +1 -1
- package/dist/commands/drain.js +6 -3
- package/dist/commands/drain.js.map +1 -1
- package/dist/commands/exec-flags.d.ts +2 -1
- package/dist/commands/exec-flags.d.ts.map +1 -1
- package/dist/commands/exec-flags.js +6 -3
- package/dist/commands/exec-flags.js.map +1 -1
- package/dist/commands/exec-json.d.ts.map +1 -1
- package/dist/commands/exec-json.js +2 -1
- package/dist/commands/exec-json.js.map +1 -1
- package/dist/commands/exec.d.ts.map +1 -1
- package/dist/commands/exec.js +2 -1
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/host-queries.d.ts.map +1 -1
- package/dist/commands/host-queries.js +10 -3
- package/dist/commands/host-queries.js.map +1 -1
- package/dist/commands/schedule.d.ts +9 -0
- package/dist/commands/schedule.d.ts.map +1 -0
- package/dist/commands/schedule.js +126 -0
- package/dist/commands/schedule.js.map +1 -0
- package/dist/commands/serve.d.ts +3 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +5 -3
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/skills.d.ts +10 -0
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +23 -7
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/upgrade.d.ts +5 -0
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +11 -0
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/config/load.d.ts +39 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +232 -1
- package/dist/config/load.js.map +1 -1
- package/dist/config/schema.d.ts +78 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/config/user-config.d.ts +23 -0
- package/dist/config/user-config.d.ts.map +1 -0
- package/dist/config/user-config.js +48 -0
- package/dist/config/user-config.js.map +1 -0
- package/dist/context/capabilities.d.ts +1 -1
- package/dist/context/capabilities.d.ts.map +1 -1
- package/dist/context/capabilities.js +1 -0
- package/dist/context/capabilities.js.map +1 -1
- package/dist/doctor/checks/browser.d.ts +15 -0
- package/dist/doctor/checks/browser.d.ts.map +1 -0
- package/dist/doctor/checks/browser.js +63 -0
- package/dist/doctor/checks/browser.js.map +1 -0
- package/dist/doctor/checks/index.d.ts +4 -1
- package/dist/doctor/checks/index.d.ts.map +1 -1
- package/dist/doctor/checks/index.js +8 -1
- package/dist/doctor/checks/index.js.map +1 -1
- package/dist/doctor/checks/scheduler.d.ts +10 -0
- package/dist/doctor/checks/scheduler.d.ts.map +1 -0
- package/dist/doctor/checks/scheduler.js +74 -0
- package/dist/doctor/checks/scheduler.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/notifications/desktop/freedesktop.d.ts +10 -0
- package/dist/integrations/notifications/desktop/freedesktop.d.ts.map +1 -0
- package/dist/integrations/notifications/desktop/freedesktop.js +33 -0
- package/dist/integrations/notifications/desktop/freedesktop.js.map +1 -0
- package/dist/integrations/notifications/desktop/macos.d.ts +6 -0
- package/dist/integrations/notifications/desktop/macos.d.ts.map +1 -0
- package/dist/integrations/notifications/desktop/macos.js +17 -0
- package/dist/integrations/notifications/desktop/macos.js.map +1 -0
- package/dist/integrations/notifications/desktop/sanitize.d.ts +16 -0
- package/dist/integrations/notifications/desktop/sanitize.d.ts.map +1 -0
- package/dist/integrations/notifications/desktop/sanitize.js +49 -0
- package/dist/integrations/notifications/desktop/sanitize.js.map +1 -0
- package/dist/integrations/notifications/desktop/throttle.d.ts +10 -0
- package/dist/integrations/notifications/desktop/throttle.d.ts.map +1 -0
- package/dist/integrations/notifications/desktop/throttle.js +39 -0
- package/dist/integrations/notifications/desktop/throttle.js.map +1 -0
- package/dist/integrations/notifications/desktop/windows-toast.d.ts +23 -0
- package/dist/integrations/notifications/desktop/windows-toast.d.ts.map +1 -0
- package/dist/integrations/notifications/desktop/windows-toast.js +56 -0
- package/dist/integrations/notifications/desktop/windows-toast.js.map +1 -0
- package/dist/integrations/notifications/desktop.d.ts +82 -0
- package/dist/integrations/notifications/desktop.d.ts.map +1 -0
- package/dist/integrations/notifications/desktop.js +198 -0
- package/dist/integrations/notifications/desktop.js.map +1 -0
- package/dist/integrations/resident/session-step.d.ts.map +1 -1
- package/dist/integrations/resident/session-step.js +1 -0
- package/dist/integrations/resident/session-step.js.map +1 -1
- package/dist/integrations/subagents/runtime.d.ts +6 -0
- package/dist/integrations/subagents/runtime.d.ts.map +1 -1
- package/dist/integrations/subagents/runtime.js +14 -5
- package/dist/integrations/subagents/runtime.js.map +1 -1
- package/dist/permissions/browser-sites.d.ts +95 -0
- package/dist/permissions/browser-sites.d.ts.map +1 -0
- package/dist/permissions/browser-sites.js +270 -0
- package/dist/permissions/browser-sites.js.map +1 -0
- package/dist/schedule/build.d.ts +79 -0
- package/dist/schedule/build.d.ts.map +1 -0
- package/dist/schedule/build.js +246 -0
- package/dist/schedule/build.js.map +1 -0
- package/dist/schedule/changes.d.ts +22 -0
- package/dist/schedule/changes.d.ts.map +1 -0
- package/dist/schedule/changes.js +55 -0
- package/dist/schedule/changes.js.map +1 -0
- package/dist/schedule/commands/add.d.ts +24 -0
- package/dist/schedule/commands/add.d.ts.map +1 -0
- package/dist/schedule/commands/add.js +459 -0
- package/dist/schedule/commands/add.js.map +1 -0
- package/dist/schedule/commands/args.d.ts +22 -0
- package/dist/schedule/commands/args.d.ts.map +1 -0
- package/dist/schedule/commands/args.js +80 -0
- package/dist/schedule/commands/args.js.map +1 -0
- package/dist/schedule/commands/confirm-prompt.d.ts +6 -0
- package/dist/schedule/commands/confirm-prompt.d.ts.map +1 -0
- package/dist/schedule/commands/confirm-prompt.js +21 -0
- package/dist/schedule/commands/confirm-prompt.js.map +1 -0
- package/dist/schedule/commands/lifecycle.d.ts +39 -0
- package/dist/schedule/commands/lifecycle.d.ts.map +1 -0
- package/dist/schedule/commands/lifecycle.js +585 -0
- package/dist/schedule/commands/lifecycle.js.map +1 -0
- package/dist/schedule/commands/list.d.ts +33 -0
- package/dist/schedule/commands/list.d.ts.map +1 -0
- package/dist/schedule/commands/list.js +202 -0
- package/dist/schedule/commands/list.js.map +1 -0
- package/dist/schedule/commands/logs.d.ts +7 -0
- package/dist/schedule/commands/logs.d.ts.map +1 -0
- package/dist/schedule/commands/logs.js +106 -0
- package/dist/schedule/commands/logs.js.map +1 -0
- package/dist/schedule/commands/service.d.ts +16 -0
- package/dist/schedule/commands/service.d.ts.map +1 -0
- package/dist/schedule/commands/service.js +458 -0
- package/dist/schedule/commands/service.js.map +1 -0
- package/dist/schedule/daemon/daemon.d.ts +156 -0
- package/dist/schedule/daemon/daemon.d.ts.map +1 -0
- package/dist/schedule/daemon/daemon.js +1248 -0
- package/dist/schedule/daemon/daemon.js.map +1 -0
- package/dist/schedule/daemon/endpoint.d.ts +38 -0
- package/dist/schedule/daemon/endpoint.d.ts.map +1 -0
- package/dist/schedule/daemon/endpoint.js +158 -0
- package/dist/schedule/daemon/endpoint.js.map +1 -0
- package/dist/schedule/daemon/exit.d.ts +8 -0
- package/dist/schedule/daemon/exit.d.ts.map +1 -0
- package/dist/schedule/daemon/exit.js +8 -0
- package/dist/schedule/daemon/exit.js.map +1 -0
- package/dist/schedule/daemon/log.d.ts +12 -0
- package/dist/schedule/daemon/log.d.ts.map +1 -0
- package/dist/schedule/daemon/log.js +53 -0
- package/dist/schedule/daemon/log.js.map +1 -0
- package/dist/schedule/daemon/notify.d.ts +31 -0
- package/dist/schedule/daemon/notify.d.ts.map +1 -0
- package/dist/schedule/daemon/notify.js +89 -0
- package/dist/schedule/daemon/notify.js.map +1 -0
- package/dist/schedule/daemon/retention.d.ts +16 -0
- package/dist/schedule/daemon/retention.d.ts.map +1 -0
- package/dist/schedule/daemon/retention.js +46 -0
- package/dist/schedule/daemon/retention.js.map +1 -0
- package/dist/schedule/daemon/sessions.d.ts +23 -0
- package/dist/schedule/daemon/sessions.d.ts.map +1 -0
- package/dist/schedule/daemon/sessions.js +52 -0
- package/dist/schedule/daemon/sessions.js.map +1 -0
- package/dist/schedule/env.d.ts +28 -0
- package/dist/schedule/env.d.ts.map +1 -0
- package/dist/schedule/env.js +99 -0
- package/dist/schedule/env.js.map +1 -0
- package/dist/schedule/fire/browser-preflight.d.ts +41 -0
- package/dist/schedule/fire/browser-preflight.d.ts.map +1 -0
- package/dist/schedule/fire/browser-preflight.js +73 -0
- package/dist/schedule/fire/browser-preflight.js.map +1 -0
- package/dist/schedule/fire/fire.d.ts +78 -0
- package/dist/schedule/fire/fire.d.ts.map +1 -0
- package/dist/schedule/fire/fire.js +482 -0
- package/dist/schedule/fire/fire.js.map +1 -0
- package/dist/schedule/fire/result.d.ts +13 -0
- package/dist/schedule/fire/result.d.ts.map +1 -0
- package/dist/schedule/fire/result.js +18 -0
- package/dist/schedule/fire/result.js.map +1 -0
- package/dist/schedule/fire/unattended-note.d.ts +22 -0
- package/dist/schedule/fire/unattended-note.d.ts.map +1 -0
- package/dist/schedule/fire/unattended-note.js +46 -0
- package/dist/schedule/fire/unattended-note.js.map +1 -0
- package/dist/schedule/floor.d.ts +116 -0
- package/dist/schedule/floor.d.ts.map +1 -0
- package/dist/schedule/floor.js +1051 -0
- package/dist/schedule/floor.js.map +1 -0
- package/dist/schedule/folder.d.ts +34 -0
- package/dist/schedule/folder.d.ts.map +1 -0
- package/dist/schedule/folder.js +84 -0
- package/dist/schedule/folder.js.map +1 -0
- package/dist/schedule/paths.d.ts +49 -0
- package/dist/schedule/paths.d.ts.map +1 -0
- package/dist/schedule/paths.js +53 -0
- package/dist/schedule/paths.js.map +1 -0
- package/dist/schedule/policy.d.ts +141 -0
- package/dist/schedule/policy.d.ts.map +1 -0
- package/dist/schedule/policy.js +378 -0
- package/dist/schedule/policy.js.map +1 -0
- package/dist/schedule/resume-command.d.ts +36 -0
- package/dist/schedule/resume-command.d.ts.map +1 -0
- package/dist/schedule/resume-command.js +72 -0
- package/dist/schedule/resume-command.js.map +1 -0
- package/dist/schedule/service/detect.d.ts +22 -0
- package/dist/schedule/service/detect.d.ts.map +1 -0
- package/dist/schedule/service/detect.js +29 -0
- package/dist/schedule/service/detect.js.map +1 -0
- package/dist/schedule/service/index.d.ts +48 -0
- package/dist/schedule/service/index.d.ts.map +1 -0
- package/dist/schedule/service/index.js +352 -0
- package/dist/schedule/service/index.js.map +1 -0
- package/dist/schedule/service/launchd.d.ts +24 -0
- package/dist/schedule/service/launchd.d.ts.map +1 -0
- package/dist/schedule/service/launchd.js +84 -0
- package/dist/schedule/service/launchd.js.map +1 -0
- package/dist/schedule/service/manifest.d.ts +44 -0
- package/dist/schedule/service/manifest.d.ts.map +1 -0
- package/dist/schedule/service/manifest.js +16 -0
- package/dist/schedule/service/manifest.js.map +1 -0
- package/dist/schedule/service/names.d.ts +9 -0
- package/dist/schedule/service/names.d.ts.map +1 -0
- package/dist/schedule/service/names.js +22 -0
- package/dist/schedule/service/names.js.map +1 -0
- package/dist/schedule/service/quote.d.ts +24 -0
- package/dist/schedule/service/quote.d.ts.map +1 -0
- package/dist/schedule/service/quote.js +64 -0
- package/dist/schedule/service/quote.js.map +1 -0
- package/dist/schedule/service/runner.d.ts +17 -0
- package/dist/schedule/service/runner.d.ts.map +1 -0
- package/dist/schedule/service/runner.js +31 -0
- package/dist/schedule/service/runner.js.map +1 -0
- package/dist/schedule/service/systemd.d.ts +37 -0
- package/dist/schedule/service/systemd.d.ts.map +1 -0
- package/dist/schedule/service/systemd.js +96 -0
- package/dist/schedule/service/systemd.js.map +1 -0
- package/dist/schedule/service/windows-task.d.ts +51 -0
- package/dist/schedule/service/windows-task.d.ts.map +1 -0
- package/dist/schedule/service/windows-task.js +134 -0
- package/dist/schedule/service/windows-task.js.map +1 -0
- package/dist/schedule/service/wsl.d.ts +57 -0
- package/dist/schedule/service/wsl.d.ts.map +1 -0
- package/dist/schedule/service/wsl.js +111 -0
- package/dist/schedule/service/wsl.js.map +1 -0
- package/dist/schedule/store/atomic.d.ts +43 -0
- package/dist/schedule/store/atomic.d.ts.map +1 -0
- package/dist/schedule/store/atomic.js +135 -0
- package/dist/schedule/store/atomic.js.map +1 -0
- package/dist/schedule/store/claims.d.ts +26 -0
- package/dist/schedule/store/claims.d.ts.map +1 -0
- package/dist/schedule/store/claims.js +57 -0
- package/dist/schedule/store/claims.js.map +1 -0
- package/dist/schedule/store/digest.d.ts +18 -0
- package/dist/schedule/store/digest.d.ts.map +1 -0
- package/dist/schedule/store/digest.js +139 -0
- package/dist/schedule/store/digest.js.map +1 -0
- package/dist/schedule/store/history.d.ts +21 -0
- package/dist/schedule/store/history.d.ts.map +1 -0
- package/dist/schedule/store/history.js +114 -0
- package/dist/schedule/store/history.js.map +1 -0
- package/dist/schedule/store/jobs.d.ts +61 -0
- package/dist/schedule/store/jobs.d.ts.map +1 -0
- package/dist/schedule/store/jobs.js +210 -0
- package/dist/schedule/store/jobs.js.map +1 -0
- package/dist/schedule/store/state.d.ts +19 -0
- package/dist/schedule/store/state.d.ts.map +1 -0
- package/dist/schedule/store/state.js +38 -0
- package/dist/schedule/store/state.js.map +1 -0
- package/dist/schedule/types.d.ts +256 -0
- package/dist/schedule/types.d.ts.map +1 -0
- package/dist/schedule/types.js +9 -0
- package/dist/schedule/types.js.map +1 -0
- package/dist/skills/catalog.d.ts +96 -0
- package/dist/skills/catalog.d.ts.map +1 -0
- package/dist/skills/catalog.js +226 -0
- package/dist/skills/catalog.js.map +1 -0
- package/dist/skills/save.d.ts +148 -0
- package/dist/skills/save.d.ts.map +1 -0
- package/dist/skills/save.js +344 -0
- package/dist/skills/save.js.map +1 -0
- package/dist/skills/store.d.ts +103 -16
- package/dist/skills/store.d.ts.map +1 -1
- package/dist/skills/store.js +227 -30
- package/dist/skills/store.js.map +1 -1
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +591 -72
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/LiveActivity.d.ts +8 -1
- package/dist/tui/LiveActivity.d.ts.map +1 -1
- package/dist/tui/LiveActivity.js +20 -4
- package/dist/tui/LiveActivity.js.map +1 -1
- package/dist/tui/PermissionOverlay.d.ts +24 -4
- package/dist/tui/PermissionOverlay.d.ts.map +1 -1
- package/dist/tui/PermissionOverlay.js +113 -15
- package/dist/tui/PermissionOverlay.js.map +1 -1
- package/dist/tui/SaveSkillOverlay.d.ts +33 -0
- package/dist/tui/SaveSkillOverlay.d.ts.map +1 -0
- package/dist/tui/SaveSkillOverlay.js +118 -0
- package/dist/tui/SaveSkillOverlay.js.map +1 -0
- package/dist/tui/agent.d.ts +185 -5
- package/dist/tui/agent.d.ts.map +1 -1
- package/dist/tui/agent.js +325 -54
- package/dist/tui/agent.js.map +1 -1
- package/dist/tui/browser-notices.d.ts +33 -0
- package/dist/tui/browser-notices.d.ts.map +1 -0
- package/dist/tui/browser-notices.js +102 -0
- package/dist/tui/browser-notices.js.map +1 -0
- package/dist/tui/permission-review.d.ts.map +1 -1
- package/dist/tui/permission-review.js +53 -4
- package/dist/tui/permission-review.js.map +1 -1
- package/dist/tui/schedule/host-commands.d.ts +34 -0
- package/dist/tui/schedule/host-commands.d.ts.map +1 -0
- package/dist/tui/schedule/host-commands.js +309 -0
- package/dist/tui/schedule/host-commands.js.map +1 -0
- package/dist/tui/schedule/integration.d.ts +62 -0
- package/dist/tui/schedule/integration.d.ts.map +1 -0
- package/dist/tui/schedule/integration.js +145 -0
- package/dist/tui/schedule/integration.js.map +1 -0
- package/dist/tui/schedule/loop-host.d.ts +42 -0
- package/dist/tui/schedule/loop-host.d.ts.map +1 -0
- package/dist/tui/schedule/loop-host.js +153 -0
- package/dist/tui/schedule/loop-host.js.map +1 -0
- package/dist/tui/schedule/resume.d.ts +122 -0
- package/dist/tui/schedule/resume.d.ts.map +1 -0
- package/dist/tui/schedule/resume.js +324 -0
- package/dist/tui/schedule/resume.js.map +1 -0
- package/dist/tui/schedule/startup.d.ts +11 -0
- package/dist/tui/schedule/startup.d.ts.map +1 -0
- package/dist/tui/schedule/startup.js +121 -0
- package/dist/tui/schedule/startup.js.map +1 -0
- package/dist/tui/schedule/tool-host.d.ts +44 -0
- package/dist/tui/schedule/tool-host.d.ts.map +1 -0
- package/dist/tui/schedule/tool-host.js +294 -0
- package/dist/tui/schedule/tool-host.js.map +1 -0
- package/dist/tui/skills/learning.d.ts +74 -0
- package/dist/tui/skills/learning.d.ts.map +1 -0
- package/dist/tui/skills/learning.js +131 -0
- package/dist/tui/skills/learning.js.map +1 -0
- package/dist/tui/skills/suggest-setting.d.ts +22 -0
- package/dist/tui/skills/suggest-setting.d.ts.map +1 -0
- package/dist/tui/skills/suggest-setting.js +55 -0
- package/dist/tui/skills/suggest-setting.js.map +1 -0
- package/dist/tui/skills/suggestion-ledger.d.ts +35 -0
- package/dist/tui/skills/suggestion-ledger.d.ts.map +1 -0
- package/dist/tui/skills/suggestion-ledger.js +52 -0
- package/dist/tui/skills/suggestion-ledger.js.map +1 -0
- package/dist/tui/slashCommands.d.ts +20 -0
- package/dist/tui/slashCommands.d.ts.map +1 -1
- package/dist/tui/slashCommands.js +76 -2
- package/dist/tui/slashCommands.js.map +1 -1
- package/dist/tui/turn-interruption.d.ts +20 -1
- package/dist/tui/turn-interruption.d.ts.map +1 -1
- package/dist/tui/turn-interruption.js +43 -0
- package/dist/tui/turn-interruption.js.map +1 -1
- package/dist/tui/types.d.ts +9 -1
- package/dist/tui/types.d.ts.map +1 -1
- package/package.json +6 -4
- package/skills/README.md +15 -0
- package/skills/browser-automation/SKILL.md +84 -0
- package/skills/schedule-task/SKILL.md +140 -0
- package/skills/skill-creator/SKILL.md +101 -0
|
@@ -0,0 +1,1248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scheduler daemon: one per `NAMZU_HOME`, started by a user service.
|
|
3
|
+
*
|
|
4
|
+
* ## One owner
|
|
5
|
+
*
|
|
6
|
+
* Ownership is a fenced lease in `schedule/daemon/` — the session lease's own
|
|
7
|
+
* store, `lease.<fence>.json` published with `link`, the highest fence owning,
|
|
8
|
+
* renewed every 30 s with a 90 s expiry. A second daemon does not exit (a
|
|
9
|
+
* supervisor would restart it in a loop): it waits on standby and takes a new
|
|
10
|
+
* fence when the owner's expires. Before every claim the owner checks its
|
|
11
|
+
* fence is still the highest, and one that was fenced out stops dispatching at
|
|
12
|
+
* once. The occurrence claim (`claims/`, also `link`) is the final guarantee
|
|
13
|
+
* that nothing starts twice, lease or no lease.
|
|
14
|
+
*
|
|
15
|
+
* ## The loop
|
|
16
|
+
*
|
|
17
|
+
* Every tick (at most 30 s, sooner when something is due, a job file changes
|
|
18
|
+
* or a run ends): evaluate each job with the SDK's pure evaluator, append what
|
|
19
|
+
* it decided to history, queue what is due, and dispatch the queue up to the
|
|
20
|
+
* concurrency cap — one run at a time per folder for jobs that can write.
|
|
21
|
+
* An occurrence is claimed when its run STARTS, never when it is queued, so a
|
|
22
|
+
* restart re-derives a waiting run instead of losing it.
|
|
23
|
+
*
|
|
24
|
+
* ## Runs
|
|
25
|
+
*
|
|
26
|
+
* A run is a child process, `schedule __fire`, whose stdout and stderr are a
|
|
27
|
+
* log FILE rather than pipes, so it outlives a daemon restart without dying of
|
|
28
|
+
* EPIPE. It enforces its own wall clock. A run this daemon did not start (it
|
|
29
|
+
* was started before a restart) is adopted: finalised from its result file,
|
|
30
|
+
* or from its session log once its lease lapses. Nothing is ever killed by a
|
|
31
|
+
* pid read from disk.
|
|
32
|
+
*
|
|
33
|
+
* ## Upgrades
|
|
34
|
+
*
|
|
35
|
+
* Every tick compares the installed CLI with the one this daemon started
|
|
36
|
+
* from. On a change it stops dispatching, waits for the runs it started and
|
|
37
|
+
* exits 0; the supervisor starts the new code.
|
|
38
|
+
*/
|
|
39
|
+
import { spawn } from 'node:child_process';
|
|
40
|
+
import { closeSync, existsSync, openSync, rmSync, statSync, watch, writeFileSync, } from 'node:fs';
|
|
41
|
+
import { dirname, join } from 'node:path';
|
|
42
|
+
import { DiskSessionLeaseStore, evaluateJob, generateScheduleRunId, } from '@namzu/sdk';
|
|
43
|
+
import { admitNotification } from '../../integrations/notifications/desktop/throttle.js';
|
|
44
|
+
import { childEnvironment } from '../env.js';
|
|
45
|
+
import { isFinal, readRunResult } from '../fire/result.js';
|
|
46
|
+
import { allowsWrites } from '../policy.js';
|
|
47
|
+
import { resumeCommand } from '../resume-command.js';
|
|
48
|
+
import { PRIVATE_FILE_MODE, ensureDir, writeJsonAtomic } from '../store/atomic.js';
|
|
49
|
+
import { claimOccurrence, isClaimed, pruneClaims } from '../store/claims.js';
|
|
50
|
+
import { appendHistory, compactHistory } from '../store/history.js';
|
|
51
|
+
import { confirmationHolds, jobSecurityDigest, listJobs, readJob, updateJob, } from '../store/jobs.js';
|
|
52
|
+
import { readState, withoutUndefined, writeState } from '../store/state.js';
|
|
53
|
+
import { startEndpoint } from './endpoint.js';
|
|
54
|
+
import { EXIT_STOP_REQUESTED } from './exit.js';
|
|
55
|
+
import { pruneDaemonLogs } from './log.js';
|
|
56
|
+
import { failureSignature, noticeText } from './notify.js';
|
|
57
|
+
import { archiveOldRuns } from './retention.js';
|
|
58
|
+
import { abandonParkedTurn, sessionFacts, sessionLeaseLive, turnOutcome } from './sessions.js';
|
|
59
|
+
export { EXIT_STOP_REQUESTED };
|
|
60
|
+
export const LEASE_TTL_MS = 90_000;
|
|
61
|
+
export const LEASE_RENEW_MS = 30_000;
|
|
62
|
+
export const TICK_MS = 30_000;
|
|
63
|
+
/** A run with no result and no session this long after it started is gone. */
|
|
64
|
+
const START_GRACE_MS = 120_000;
|
|
65
|
+
const CLAIM_RETENTION_EXTRA_MS = 24 * 60 * 60 * 1000;
|
|
66
|
+
const HOUSEKEEPING_MS = 24 * 60 * 60 * 1000;
|
|
67
|
+
const GAP_MS = 60_000;
|
|
68
|
+
export class ScheduleDaemon {
|
|
69
|
+
#o;
|
|
70
|
+
#now;
|
|
71
|
+
#mono;
|
|
72
|
+
#leaseStore;
|
|
73
|
+
#lease = null;
|
|
74
|
+
#leaseRenewedAt = 0;
|
|
75
|
+
#endpoint;
|
|
76
|
+
#watcher;
|
|
77
|
+
/** Runs in progress: ours (with a handle) and adopted ones (without). */
|
|
78
|
+
#running = new Map();
|
|
79
|
+
#stopping = false;
|
|
80
|
+
/** The stop came from `schedule stop`'s file, not a signal or the endpoint. */
|
|
81
|
+
#stopRequestedByFile = false;
|
|
82
|
+
#draining = false;
|
|
83
|
+
#standby = false;
|
|
84
|
+
#wake;
|
|
85
|
+
#lastWall = 0;
|
|
86
|
+
#lastMono = 0;
|
|
87
|
+
#startedAt = 0;
|
|
88
|
+
#observedGap;
|
|
89
|
+
#startFingerprint = '';
|
|
90
|
+
#lastHousekeeping = 0;
|
|
91
|
+
#manual = [];
|
|
92
|
+
/** Why a queued run is waiting, for its run record once it starts. */
|
|
93
|
+
#delayReasons = new Map();
|
|
94
|
+
#finalizing = [];
|
|
95
|
+
constructor(options) {
|
|
96
|
+
this.#o = options;
|
|
97
|
+
this.#now = options.now ?? Date.now;
|
|
98
|
+
this.#mono = options.monotonic ?? (() => performance.now());
|
|
99
|
+
this.#leaseStore = new DiskSessionLeaseStore(options.paths.daemon);
|
|
100
|
+
}
|
|
101
|
+
get standby() {
|
|
102
|
+
return this.#standby;
|
|
103
|
+
}
|
|
104
|
+
get lease() {
|
|
105
|
+
return this.#lease;
|
|
106
|
+
}
|
|
107
|
+
/** Ask the loop to stop at its next wake. Running children are left running. */
|
|
108
|
+
stop() {
|
|
109
|
+
this.#stopping = true;
|
|
110
|
+
this.#wake?.();
|
|
111
|
+
}
|
|
112
|
+
/** Stop dispatching, wait for our own runs, then stop (an upgrade restart). */
|
|
113
|
+
drainAndRestart() {
|
|
114
|
+
this.#draining = true;
|
|
115
|
+
this.#persistManual();
|
|
116
|
+
this.#wake?.();
|
|
117
|
+
}
|
|
118
|
+
wake() {
|
|
119
|
+
this.#wake?.();
|
|
120
|
+
}
|
|
121
|
+
/** Try once to own the home (for a caller driving {@link tick} itself). */
|
|
122
|
+
async claimOwnership() {
|
|
123
|
+
this.#startFingerprint ||= this.#o.fingerprint();
|
|
124
|
+
this.#startedAt ||= this.#now();
|
|
125
|
+
const lease = await this.#leaseStore.claim({
|
|
126
|
+
holder: this.#o.epoch,
|
|
127
|
+
ttlMs: this.#o.leaseTtlMs ?? LEASE_TTL_MS,
|
|
128
|
+
now: this.#now(),
|
|
129
|
+
});
|
|
130
|
+
if (lease) {
|
|
131
|
+
this.#lease = lease;
|
|
132
|
+
this.#leaseRenewedAt = this.#now();
|
|
133
|
+
}
|
|
134
|
+
return lease !== null;
|
|
135
|
+
}
|
|
136
|
+
/** Give the home up (a caller driving {@link tick} itself). */
|
|
137
|
+
async releaseOwnership() {
|
|
138
|
+
if (this.#lease)
|
|
139
|
+
await this.#leaseStore.release(this.#lease);
|
|
140
|
+
this.#lease = null;
|
|
141
|
+
}
|
|
142
|
+
/** Run until stopped. Returns the process exit code. */
|
|
143
|
+
async run() {
|
|
144
|
+
ensureDir(this.#o.paths.daemon);
|
|
145
|
+
this.#startedAt = this.#now();
|
|
146
|
+
this.#startFingerprint = this.#o.fingerprint();
|
|
147
|
+
for (;;) {
|
|
148
|
+
const acquired = await this.#acquire();
|
|
149
|
+
if (acquired === 'exit75')
|
|
150
|
+
return 75;
|
|
151
|
+
if (acquired === 'stopped')
|
|
152
|
+
return this.#stopRequestedByFile ? EXIT_STOP_REQUESTED : 0;
|
|
153
|
+
const outcome = await this.#own();
|
|
154
|
+
if (outcome === 'standby')
|
|
155
|
+
continue;
|
|
156
|
+
return this.#stopRequestedByFile ? EXIT_STOP_REQUESTED : 0;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async #acquire() {
|
|
160
|
+
let announced = false;
|
|
161
|
+
for (;;) {
|
|
162
|
+
if (this.#stopRequested())
|
|
163
|
+
this.#stopping = true;
|
|
164
|
+
if (this.#stopping)
|
|
165
|
+
return 'stopped';
|
|
166
|
+
const lease = await this.#leaseStore.claim({
|
|
167
|
+
holder: this.#o.epoch,
|
|
168
|
+
ttlMs: this.#o.leaseTtlMs ?? LEASE_TTL_MS,
|
|
169
|
+
now: this.#now(),
|
|
170
|
+
});
|
|
171
|
+
if (lease) {
|
|
172
|
+
this.#lease = lease;
|
|
173
|
+
this.#leaseRenewedAt = this.#now();
|
|
174
|
+
this.#standby = false;
|
|
175
|
+
this.#o.log.info('scheduler owns this home', {
|
|
176
|
+
'namzu.schedule.epoch': this.#o.epoch,
|
|
177
|
+
'namzu.schedule.fence': lease.fence,
|
|
178
|
+
});
|
|
179
|
+
return 'owned';
|
|
180
|
+
}
|
|
181
|
+
if (this.#o.onceOrExit) {
|
|
182
|
+
this.#o.log.warn('another scheduler owns this home', {
|
|
183
|
+
'namzu.schedule.epoch': this.#o.epoch,
|
|
184
|
+
});
|
|
185
|
+
return 'exit75';
|
|
186
|
+
}
|
|
187
|
+
this.#standby = true;
|
|
188
|
+
if (!announced) {
|
|
189
|
+
announced = true;
|
|
190
|
+
this.#o.log.info('scheduler on standby', { 'namzu.schedule.epoch': this.#o.epoch });
|
|
191
|
+
}
|
|
192
|
+
await this.#sleep(this.#o.standbyPollMs ?? LEASE_RENEW_MS);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async #own() {
|
|
196
|
+
this.#endpoint = await startEndpoint(this.#o.paths.endpoint, this.#o.epoch, {
|
|
197
|
+
status: () => this.status(),
|
|
198
|
+
reload: () => this.wake(),
|
|
199
|
+
runNow: (jobId) => this.requestRunNow(jobId),
|
|
200
|
+
stop: () => this.stop(),
|
|
201
|
+
drainAndRestart: () => this.drainAndRestart(),
|
|
202
|
+
});
|
|
203
|
+
if (this.#o.watchJobs !== false) {
|
|
204
|
+
try {
|
|
205
|
+
ensureDir(this.#o.paths.jobs);
|
|
206
|
+
this.#watcher = watch(this.#o.paths.jobs, () => this.wake());
|
|
207
|
+
this.#watcher.on('error', () => { });
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}
|
|
211
|
+
this.#lastWall = this.#now();
|
|
212
|
+
this.#lastMono = this.#mono();
|
|
213
|
+
try {
|
|
214
|
+
for (;;) {
|
|
215
|
+
if (this.#stopRequested())
|
|
216
|
+
this.#stopping = true;
|
|
217
|
+
if (this.#stopping)
|
|
218
|
+
return 'stopped';
|
|
219
|
+
if (!(await this.#holdLease())) {
|
|
220
|
+
this.#o.log.warn('scheduler lost its lease; standing by', {
|
|
221
|
+
'namzu.schedule.epoch': this.#o.epoch,
|
|
222
|
+
});
|
|
223
|
+
return 'standby';
|
|
224
|
+
}
|
|
225
|
+
await this.tick();
|
|
226
|
+
if (this.#draining && this.#ownRuns() === 0) {
|
|
227
|
+
this.#o.log.info('scheduler drained for a restart', {
|
|
228
|
+
'namzu.schedule.epoch': this.#o.epoch,
|
|
229
|
+
});
|
|
230
|
+
this.#stopping = true;
|
|
231
|
+
return 'stopped';
|
|
232
|
+
}
|
|
233
|
+
await this.#sleep(this.#nextSleep());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
this.#watcher?.close();
|
|
238
|
+
await this.#endpoint?.close();
|
|
239
|
+
this.#endpoint = undefined;
|
|
240
|
+
await Promise.allSettled(this.#finalizing);
|
|
241
|
+
if (this.#stopping)
|
|
242
|
+
this.#persistManual();
|
|
243
|
+
if (this.#lease && this.#stopping) {
|
|
244
|
+
await this.#leaseStore.release(this.#lease).catch(() => undefined);
|
|
245
|
+
this.#lease = null;
|
|
246
|
+
}
|
|
247
|
+
this.#heartbeat();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* `schedule stop` and `uninstall` leave `daemon/stop.json`; `start` and
|
|
252
|
+
* `install` remove it. It reaches a daemon the service manager cannot: one
|
|
253
|
+
* on standby (it has no endpoint), or one under WSL, whose Windows task
|
|
254
|
+
* ending does not end the Linux process. A daemon that finds it exits
|
|
255
|
+
* {@link EXIT_STOP_REQUESTED}.
|
|
256
|
+
*/
|
|
257
|
+
#stopRequested() {
|
|
258
|
+
if (!existsSync(stopRequestPath(this.#o.paths)))
|
|
259
|
+
return false;
|
|
260
|
+
this.#stopRequestedByFile = true;
|
|
261
|
+
this.#o.log.info('scheduler stop requested', { 'namzu.schedule.epoch': this.#o.epoch });
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
#ownRuns() {
|
|
265
|
+
let n = 0;
|
|
266
|
+
for (const t of this.#running.values())
|
|
267
|
+
if (t.run)
|
|
268
|
+
n++;
|
|
269
|
+
return n;
|
|
270
|
+
}
|
|
271
|
+
/** Renew when due; true while this daemon's fence is still the highest. */
|
|
272
|
+
async #holdLease() {
|
|
273
|
+
const lease = this.#lease;
|
|
274
|
+
if (!lease)
|
|
275
|
+
return false;
|
|
276
|
+
if (this.#now() - this.#leaseRenewedAt >= LEASE_RENEW_MS) {
|
|
277
|
+
const renewed = await this.#leaseStore.claim({ holder: this.#o.epoch, ttlMs: this.#o.leaseTtlMs ?? LEASE_TTL_MS, now: this.#now() }, { renew: lease });
|
|
278
|
+
if (!renewed) {
|
|
279
|
+
this.#lease = null;
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
this.#lease = renewed;
|
|
283
|
+
this.#leaseRenewedAt = this.#now();
|
|
284
|
+
}
|
|
285
|
+
return this.#stillOwner();
|
|
286
|
+
}
|
|
287
|
+
async #stillOwner() {
|
|
288
|
+
const lease = this.#lease;
|
|
289
|
+
return lease !== null && (await this.#leaseStore.fence()) === lease.fence;
|
|
290
|
+
}
|
|
291
|
+
#sleep(ms) {
|
|
292
|
+
return new Promise((resolve) => {
|
|
293
|
+
const timer = setTimeout(() => {
|
|
294
|
+
this.#wake = undefined;
|
|
295
|
+
resolve();
|
|
296
|
+
}, Math.max(10, ms));
|
|
297
|
+
this.#wake = () => {
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
this.#wake = undefined;
|
|
300
|
+
resolve();
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
#nextSleep() {
|
|
305
|
+
const tick = this.#o.tickMs ?? TICK_MS;
|
|
306
|
+
let soonest = this.#now() + tick;
|
|
307
|
+
for (const job of listJobs(this.#o.paths).jobs) {
|
|
308
|
+
try {
|
|
309
|
+
const next = readState(this.#o.paths, job.id).nextFireAt;
|
|
310
|
+
if (next)
|
|
311
|
+
soonest = Math.min(soonest, Date.parse(next) + 50);
|
|
312
|
+
}
|
|
313
|
+
catch { }
|
|
314
|
+
}
|
|
315
|
+
return Math.max(50, Math.min(tick, soonest - this.#now()));
|
|
316
|
+
}
|
|
317
|
+
/** What the endpoint's `status` reports. */
|
|
318
|
+
status() {
|
|
319
|
+
return {
|
|
320
|
+
epoch: this.#o.epoch,
|
|
321
|
+
pid: process.pid,
|
|
322
|
+
version: this.#o.version,
|
|
323
|
+
fence: this.#lease?.fence,
|
|
324
|
+
standby: this.#standby,
|
|
325
|
+
draining: this.#draining,
|
|
326
|
+
runs: [...this.#running.values()].map((t) => ({
|
|
327
|
+
jobId: t.jobId,
|
|
328
|
+
runId: t.runId,
|
|
329
|
+
adopted: t.run === undefined,
|
|
330
|
+
})),
|
|
331
|
+
notifications: this.#o.notificationBackend ?? 'unknown',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** Queue a manual run (`schedule run-now`). Refused while the job has a run. */
|
|
335
|
+
requestRunNow(jobId) {
|
|
336
|
+
const job = readJob(this.#o.paths, jobId);
|
|
337
|
+
if (!job)
|
|
338
|
+
return { ok: false, message: `no job ${jobId}` };
|
|
339
|
+
if (job.state !== 'active' || !confirmationHolds(job)) {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
message: `${job.name} is ${job.state === 'active' ? 'awaiting confirmation' : job.state}`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const state = readState(this.#o.paths, job.id);
|
|
346
|
+
if (state.activeRun || state.queued || this.#manual.some((m) => m.jobId === job.id)) {
|
|
347
|
+
return { ok: false, message: `${job.name} already has a run` };
|
|
348
|
+
}
|
|
349
|
+
this.#manual.push({ jobId: job.id, runId: generateScheduleRunId() });
|
|
350
|
+
if (this.#draining) {
|
|
351
|
+
// This daemon starts nothing more and its memory goes with it: the run
|
|
352
|
+
// is left queued on disk, for the daemon that takes over.
|
|
353
|
+
this.#persistManual();
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
message: `${job.name} queued; the scheduler is restarting for an upgrade and starts it once it is back`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
this.wake();
|
|
360
|
+
return { ok: true, message: `${job.name} queued` };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Move every manual run still waiting in memory (deferred by the cap or a
|
|
364
|
+
* busy folder lane) to `state.queued` on disk. A daemon that stops
|
|
365
|
+
* dispatching (a drain) or exits keeps no memory, and the CLI has already
|
|
366
|
+
* told the operator the run is queued; the daemon that takes over starts
|
|
367
|
+
* it from disk under the same run id.
|
|
368
|
+
*/
|
|
369
|
+
#persistManual() {
|
|
370
|
+
for (const manual of this.#manual.splice(0)) {
|
|
371
|
+
try {
|
|
372
|
+
const state = readState(this.#o.paths, manual.jobId);
|
|
373
|
+
if (state.activeRun || state.queued) {
|
|
374
|
+
this.#o.log.warn('manual run dropped; the job already has a run', {
|
|
375
|
+
'namzu.schedule.job_id': manual.jobId,
|
|
376
|
+
'namzu.schedule.run_id': manual.runId,
|
|
377
|
+
});
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const at = new Date(this.#now()).toISOString();
|
|
381
|
+
writeState(this.#o.paths, {
|
|
382
|
+
...state,
|
|
383
|
+
queued: {
|
|
384
|
+
key: `${MANUAL_KEY_PREFIX}${manual.runId}`,
|
|
385
|
+
scheduledFor: at,
|
|
386
|
+
trigger: 'manual',
|
|
387
|
+
queuedAt: at,
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
this.#o.log.error('manual run could not be queued on disk', {
|
|
393
|
+
'namzu.schedule.job_id': manual.jobId,
|
|
394
|
+
'namzu.schedule.run_id': manual.runId,
|
|
395
|
+
'exception.message': error instanceof Error ? error.message : String(error),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
#heartbeat() {
|
|
401
|
+
try {
|
|
402
|
+
writeJsonAtomic(this.#o.paths.heartbeat, {
|
|
403
|
+
v: 1,
|
|
404
|
+
kind: 'schedule-heartbeat',
|
|
405
|
+
at: new Date(this.#now()).toISOString(),
|
|
406
|
+
pid: process.pid,
|
|
407
|
+
epoch: this.#o.epoch,
|
|
408
|
+
version: this.#o.version,
|
|
409
|
+
standby: this.#standby,
|
|
410
|
+
draining: this.#draining,
|
|
411
|
+
runsInFlight: this.#running.size,
|
|
412
|
+
notifications: this.#o.notificationBackend ?? 'unknown',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
catch { }
|
|
416
|
+
}
|
|
417
|
+
#observeClock() {
|
|
418
|
+
const wall = this.#now();
|
|
419
|
+
const mono = this.#mono();
|
|
420
|
+
const wallDelta = wall - this.#lastWall;
|
|
421
|
+
const monoDelta = mono - this.#lastMono;
|
|
422
|
+
if (wallDelta - monoDelta > GAP_MS) {
|
|
423
|
+
this.#observedGap = {
|
|
424
|
+
from: new Date(this.#lastWall),
|
|
425
|
+
to: new Date(wall),
|
|
426
|
+
kind: monoDelta < wallDelta / 2 ? 'asleep' : 'clock-forward',
|
|
427
|
+
};
|
|
428
|
+
this.#o.log.info('scheduler observed a clock gap', {
|
|
429
|
+
'namzu.schedule.gap_ms': wallDelta - monoDelta,
|
|
430
|
+
'namzu.schedule.gap_kind': this.#observedGap.kind,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
else if (monoDelta - wallDelta > GAP_MS) {
|
|
434
|
+
this.#observedGap = {
|
|
435
|
+
from: new Date(this.#lastWall),
|
|
436
|
+
to: new Date(wall),
|
|
437
|
+
kind: 'clock-backward',
|
|
438
|
+
};
|
|
439
|
+
this.#o.log.info('scheduler observed the clock going back', {
|
|
440
|
+
'namzu.schedule.gap_ms': monoDelta - wallDelta,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
this.#lastWall = wall;
|
|
444
|
+
this.#lastMono = mono;
|
|
445
|
+
}
|
|
446
|
+
/** One pass: every job evaluated, the queue dispatched. Public for tests. */
|
|
447
|
+
async tick() {
|
|
448
|
+
this.#observeClock();
|
|
449
|
+
if (!this.#draining && this.#o.fingerprint() !== this.#startFingerprint) {
|
|
450
|
+
this.#o.log.info('installed CLI changed; draining for a restart', {
|
|
451
|
+
'namzu.schedule.epoch': this.#o.epoch,
|
|
452
|
+
});
|
|
453
|
+
this.#draining = true;
|
|
454
|
+
this.#persistManual();
|
|
455
|
+
}
|
|
456
|
+
const now = this.#now();
|
|
457
|
+
const { jobs, errors } = listJobs(this.#o.paths);
|
|
458
|
+
for (const error of errors) {
|
|
459
|
+
this.#o.log.warn('scheduled job file unreadable', {
|
|
460
|
+
'namzu.schedule.path': error.path,
|
|
461
|
+
'exception.message': error.message,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
for (const job of jobs) {
|
|
465
|
+
try {
|
|
466
|
+
await this.#tickJob(job, now);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
this.#o.log.error('scheduled job evaluation failed', {
|
|
470
|
+
'namzu.schedule.job_id': job.id,
|
|
471
|
+
'exception.message': error instanceof Error ? error.message : String(error),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
this.#observedGap = undefined;
|
|
476
|
+
if (!this.#draining)
|
|
477
|
+
await this.#dispatch(now);
|
|
478
|
+
// A dispatch that was under way when the drain began may have handed a
|
|
479
|
+
// deferred manual run back to memory.
|
|
480
|
+
if (this.#draining)
|
|
481
|
+
this.#persistManual();
|
|
482
|
+
if (now - this.#lastHousekeeping > HOUSEKEEPING_MS) {
|
|
483
|
+
this.#lastHousekeeping = now;
|
|
484
|
+
for (const job of jobs) {
|
|
485
|
+
try {
|
|
486
|
+
compactHistory(this.#o.paths, job.id, now);
|
|
487
|
+
pruneClaims(this.#o.paths, job.id, job.catchUp.windowMs + CLAIM_RETENTION_EXTRA_MS, now);
|
|
488
|
+
}
|
|
489
|
+
catch { }
|
|
490
|
+
}
|
|
491
|
+
pruneDaemonLogs(this.#o.paths.daemonLog, new Date(now));
|
|
492
|
+
}
|
|
493
|
+
this.#heartbeat();
|
|
494
|
+
}
|
|
495
|
+
async #notice(kind, job, extra) {
|
|
496
|
+
if (!this.#o.notifications)
|
|
497
|
+
return;
|
|
498
|
+
const flags = job.notify;
|
|
499
|
+
const wanted = kind === 'finished'
|
|
500
|
+
? flags.finished
|
|
501
|
+
: kind === 'awaiting-approval'
|
|
502
|
+
? flags.awaitingApproval
|
|
503
|
+
: kind === 'catch-up'
|
|
504
|
+
? flags.finished || flags.failed
|
|
505
|
+
: flags.failed || kind === 'held' || kind === 'needs-confirmation';
|
|
506
|
+
if (!wanted)
|
|
507
|
+
return;
|
|
508
|
+
// What needs the person is never throttled: each happens at most once
|
|
509
|
+
// per run or per change, and a dropped one is never sent again. A park
|
|
510
|
+
// right after a catch-up notice would otherwise sit unannounced until
|
|
511
|
+
// its approval expired, with every later occurrence skipped.
|
|
512
|
+
const always = kind === 'held' ||
|
|
513
|
+
kind === 'needs-confirmation' ||
|
|
514
|
+
kind === 'auto-paused' ||
|
|
515
|
+
kind === 'awaiting-approval' ||
|
|
516
|
+
kind === 'approval-expired';
|
|
517
|
+
if (!always &&
|
|
518
|
+
!admitNotification(join(this.#o.paths.daemon, 'notify.json'), job.id, this.#now()))
|
|
519
|
+
return;
|
|
520
|
+
const text = noticeText(kind, job, extra);
|
|
521
|
+
await this.#o.notify({ jobId: job.id, ...text });
|
|
522
|
+
this.#o.log.info('scheduled run notification', {
|
|
523
|
+
'namzu.schedule.job_id': job.id,
|
|
524
|
+
'namzu.schedule.notice': kind,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
async #tickJob(loaded, now) {
|
|
528
|
+
const paths = this.#o.paths;
|
|
529
|
+
let job = loaded;
|
|
530
|
+
let state = readState(paths, job.id);
|
|
531
|
+
// A confirmed job whose file no longer matches its confirmation was
|
|
532
|
+
// edited behind the CLI's back: hold it, say so once.
|
|
533
|
+
if (job.state === 'active' && !confirmationHolds(job)) {
|
|
534
|
+
job = updateJob(paths, job.id, job.revision, (j) => ({ ...j, state: 'pending-confirmation' }));
|
|
535
|
+
appendHistory(paths, job.id, {
|
|
536
|
+
v: 1,
|
|
537
|
+
kind: 'job',
|
|
538
|
+
at: new Date(now).toISOString(),
|
|
539
|
+
action: 'tampered',
|
|
540
|
+
by: 'daemon',
|
|
541
|
+
detail: 'the job file changed outside namzu; it waits for confirmation',
|
|
542
|
+
});
|
|
543
|
+
state = { ...state, lastHoldNotice: jobSecurityDigest(job) };
|
|
544
|
+
writeState(paths, state);
|
|
545
|
+
await this.#notice('held', job, { at: new Date(now) });
|
|
546
|
+
}
|
|
547
|
+
else if (job.state === 'pending-confirmation') {
|
|
548
|
+
const digest = jobSecurityDigest(job);
|
|
549
|
+
if (state.lastHoldNotice !== digest) {
|
|
550
|
+
state = { ...state, lastHoldNotice: digest };
|
|
551
|
+
writeState(paths, state);
|
|
552
|
+
await this.#notice('needs-confirmation', job, { at: new Date(now) });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
state = await this.#reconcile(job, state, now);
|
|
556
|
+
job = readJob(paths, job.id) ?? job;
|
|
557
|
+
// A draining daemon does not evaluate. Evaluating would move
|
|
558
|
+
// `lastEvaluatedAt` past an occurrence it cannot queue, so the daemon
|
|
559
|
+
// that takes over would never see it: no run, no `missed`, and a
|
|
560
|
+
// one-shot left active with nothing to fire. Left alone, the successor
|
|
561
|
+
// evaluates from where this daemon stopped and runs it (late, as a
|
|
562
|
+
// scheduled or catch-up run under the job's own policy).
|
|
563
|
+
if (this.#draining)
|
|
564
|
+
return;
|
|
565
|
+
const evaluationState = {
|
|
566
|
+
...(state.lastEvaluatedAt ? { lastEvaluatedAt: state.lastEvaluatedAt } : {}),
|
|
567
|
+
...(state.jobRevision !== undefined ? { jobRevision: state.jobRevision } : {}),
|
|
568
|
+
...(state.activeRun
|
|
569
|
+
? { activeRun: { status: state.activeRun.status } }
|
|
570
|
+
: state.queued || this.#manual.some((m) => m.jobId === job.id)
|
|
571
|
+
? { activeRun: { status: 'queued' } }
|
|
572
|
+
: {}),
|
|
573
|
+
...(state.quotaHoldUntil ? { quotaHoldUntil: state.quotaHoldUntil } : {}),
|
|
574
|
+
};
|
|
575
|
+
const decision = evaluateJob({
|
|
576
|
+
job: {
|
|
577
|
+
spec: job.schedule,
|
|
578
|
+
state: job.state,
|
|
579
|
+
catchUp: job.catchUp,
|
|
580
|
+
createdAt: job.createdAt,
|
|
581
|
+
updatedAt: job.updatedAt,
|
|
582
|
+
revision: job.revision,
|
|
583
|
+
},
|
|
584
|
+
state: evaluationState,
|
|
585
|
+
now: new Date(now),
|
|
586
|
+
daemon: {
|
|
587
|
+
startedAt: new Date(this.#startedAt),
|
|
588
|
+
...(this.#observedGap ? { observedGap: this.#observedGap } : {}),
|
|
589
|
+
},
|
|
590
|
+
isClaimed: (key) => isClaimed(paths, job.id, key),
|
|
591
|
+
});
|
|
592
|
+
const at = new Date(now).toISOString();
|
|
593
|
+
for (const skip of decision.skip) {
|
|
594
|
+
appendHistory(paths, job.id, {
|
|
595
|
+
v: 1,
|
|
596
|
+
kind: 'skip',
|
|
597
|
+
at,
|
|
598
|
+
scheduledFor: skip.scheduledFor.toISOString(),
|
|
599
|
+
reason: skip.reason,
|
|
600
|
+
count: skip.count,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
if (decision.missed) {
|
|
604
|
+
appendHistory(paths, job.id, {
|
|
605
|
+
v: 1,
|
|
606
|
+
kind: 'missed',
|
|
607
|
+
at,
|
|
608
|
+
from: decision.missed.from.toISOString(),
|
|
609
|
+
to: decision.missed.to.toISOString(),
|
|
610
|
+
count: decision.missed.count,
|
|
611
|
+
...(decision.missed.capped ? { capped: true } : {}),
|
|
612
|
+
reason: decision.missed.reason,
|
|
613
|
+
...(decision.fire ? { caughtUpBy: decision.fire.key } : {}),
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
let next = withoutUndefined({
|
|
617
|
+
...state,
|
|
618
|
+
lastEvaluatedAt: decision.nextState.lastEvaluatedAt.toISOString(),
|
|
619
|
+
jobRevision: decision.nextState.jobRevision,
|
|
620
|
+
nextFireAt: decision.nextState.nextFireAt?.toISOString(),
|
|
621
|
+
quotaHoldUntil: state.quotaHoldUntil && Date.parse(state.quotaHoldUntil) > now
|
|
622
|
+
? state.quotaHoldUntil
|
|
623
|
+
: undefined,
|
|
624
|
+
});
|
|
625
|
+
if (decision.fire) {
|
|
626
|
+
next = {
|
|
627
|
+
...next,
|
|
628
|
+
queued: {
|
|
629
|
+
key: decision.fire.key,
|
|
630
|
+
scheduledFor: decision.fire.scheduledFor.toISOString(),
|
|
631
|
+
trigger: decision.fire.trigger,
|
|
632
|
+
queuedAt: at,
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
if (decision.fire.trigger === 'catch-up') {
|
|
636
|
+
await this.#notice('catch-up', job, {
|
|
637
|
+
at: new Date(now),
|
|
638
|
+
scheduledFor: decision.fire.scheduledFor,
|
|
639
|
+
...(decision.missed ? { missed: decision.missed.count } : {}),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
writeState(paths, next);
|
|
644
|
+
if (decision.jobTransition && decision.jobTransition !== job.state) {
|
|
645
|
+
const transition = decision.jobTransition;
|
|
646
|
+
// A one-shot that fired completes once its run is dispatched; one
|
|
647
|
+
// that expired is done now.
|
|
648
|
+
if (transition === 'expired' || !decision.fire) {
|
|
649
|
+
updateJob(paths, job.id, job.revision, (j) => ({ ...j, state: transition }));
|
|
650
|
+
appendHistory(paths, job.id, { v: 1, kind: 'job', at, action: transition, by: 'daemon' });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Record how a run this daemon did not start has ended — a foreground
|
|
656
|
+
* `schedule run-now` — exactly as a run it started is recorded. False,
|
|
657
|
+
* and nothing written, when the job's state no longer names that run (a
|
|
658
|
+
* daemon that adopted it recorded it first).
|
|
659
|
+
*/
|
|
660
|
+
async finalizeRun(jobId, runId, result) {
|
|
661
|
+
const job = readJob(this.#o.paths, jobId);
|
|
662
|
+
const run = readState(this.#o.paths, jobId).activeRun;
|
|
663
|
+
if (!job || run?.runId !== runId)
|
|
664
|
+
return false;
|
|
665
|
+
this.#finalize(job, run, result, this.#now());
|
|
666
|
+
await this.settled();
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Settle the job's run in progress if its process is gone, as a tick
|
|
671
|
+
* would, without evaluating the schedule. For a foreground `run-now`
|
|
672
|
+
* finding a run no scheduler is left to settle.
|
|
673
|
+
*/
|
|
674
|
+
async reconcileJob(jobId) {
|
|
675
|
+
const job = readJob(this.#o.paths, jobId);
|
|
676
|
+
if (!job)
|
|
677
|
+
return undefined;
|
|
678
|
+
const state = await this.#reconcile(job, readState(this.#o.paths, jobId), this.#now());
|
|
679
|
+
await this.settled();
|
|
680
|
+
return state;
|
|
681
|
+
}
|
|
682
|
+
/** Finalise or adopt the job's run in progress, if it has one. */
|
|
683
|
+
async #reconcile(job, state, now) {
|
|
684
|
+
// A run adopted earlier that the job no longer names was recorded by
|
|
685
|
+
// someone else (a foreground `run-now` finishing): stop counting it.
|
|
686
|
+
for (const [runId, tracked] of this.#running) {
|
|
687
|
+
if (tracked.jobId === job.id && !tracked.run && state.activeRun?.runId !== runId)
|
|
688
|
+
this.#running.delete(runId);
|
|
689
|
+
}
|
|
690
|
+
const run = state.activeRun;
|
|
691
|
+
if (!run)
|
|
692
|
+
return state;
|
|
693
|
+
const tracked = this.#running.get(run.runId);
|
|
694
|
+
if (tracked?.run)
|
|
695
|
+
return state;
|
|
696
|
+
const result = readRunResult(this.#o.paths, job.id, run.runId);
|
|
697
|
+
if (run.status === 'running') {
|
|
698
|
+
if (isFinal(result))
|
|
699
|
+
return this.#finalize(job, run, result, now);
|
|
700
|
+
const slug = result?.projectSlug ?? run.projectSlug;
|
|
701
|
+
const sessionId = result?.sessionId ?? run.sessionId;
|
|
702
|
+
const ref = slug && sessionId ? { home: this.#o.paths.home, projectSlug: slug, sessionId } : undefined;
|
|
703
|
+
if (ref && (await sessionLeaseLive(ref, now))) {
|
|
704
|
+
this.#running.set(run.runId, {
|
|
705
|
+
jobId: job.id,
|
|
706
|
+
runId: run.runId,
|
|
707
|
+
folder: job.folder.canonical,
|
|
708
|
+
writes: allowsWrites(job.permissions),
|
|
709
|
+
});
|
|
710
|
+
return state;
|
|
711
|
+
}
|
|
712
|
+
if (!ref && now - Date.parse(run.startedAt) < START_GRACE_MS) {
|
|
713
|
+
this.#running.set(run.runId, {
|
|
714
|
+
jobId: job.id,
|
|
715
|
+
runId: run.runId,
|
|
716
|
+
folder: job.folder.canonical,
|
|
717
|
+
writes: allowsWrites(job.permissions),
|
|
718
|
+
});
|
|
719
|
+
return state;
|
|
720
|
+
}
|
|
721
|
+
this.#running.delete(run.runId);
|
|
722
|
+
// The process is gone. The session log says how far the turn got.
|
|
723
|
+
const facts = ref ? await sessionFacts(ref) : null;
|
|
724
|
+
if (facts?.activeTurn?.paused) {
|
|
725
|
+
return this.#finalize(job, run, {
|
|
726
|
+
...(result ?? this.#emptyResult(job, run)),
|
|
727
|
+
status: 'awaiting-approval',
|
|
728
|
+
exitCode: 0,
|
|
729
|
+
turnId: facts.activeTurn.turnId,
|
|
730
|
+
...(ref ? { sessionId: ref.sessionId, projectSlug: ref.projectSlug } : {}),
|
|
731
|
+
}, now);
|
|
732
|
+
}
|
|
733
|
+
const outcome = facts && !facts.activeTurn ? turnOutcome(facts, run.turnId ?? result?.turnId) : undefined;
|
|
734
|
+
return this.#finalize(job, run, {
|
|
735
|
+
...(result ?? this.#emptyResult(job, run)),
|
|
736
|
+
status: outcome?.status ?? 'interrupted',
|
|
737
|
+
exitCode: outcome?.status === 'completed' ? 0 : 1,
|
|
738
|
+
reason: outcome?.reason ?? 'the run stopped without recording a result (its process ended)',
|
|
739
|
+
}, now);
|
|
740
|
+
}
|
|
741
|
+
// Parked on a decision.
|
|
742
|
+
if (!run.sessionId || !run.projectSlug) {
|
|
743
|
+
return this.#finalize(job, run, { ...this.#emptyResult(job, run), status: 'interrupted', exitCode: 1 }, now);
|
|
744
|
+
}
|
|
745
|
+
const ref = { home: this.#o.paths.home, projectSlug: run.projectSlug, sessionId: run.sessionId };
|
|
746
|
+
const facts = await sessionFacts(ref);
|
|
747
|
+
if (!facts) {
|
|
748
|
+
return this.#finalize(job, run, {
|
|
749
|
+
...this.#emptyResult(job, run),
|
|
750
|
+
status: 'interrupted',
|
|
751
|
+
exitCode: 1,
|
|
752
|
+
reason: 'the parked session is gone',
|
|
753
|
+
}, now);
|
|
754
|
+
}
|
|
755
|
+
const active = facts.activeTurn;
|
|
756
|
+
if (active && active.turnId === run.turnId) {
|
|
757
|
+
if (!active.paused)
|
|
758
|
+
return state; // someone resumed it; wait for it to settle
|
|
759
|
+
const parkedAt = Date.parse(run.parkedAt ?? run.startedAt);
|
|
760
|
+
if (now - parkedAt > job.approvalTtlMs && !(await sessionLeaseLive(ref, now))) {
|
|
761
|
+
try {
|
|
762
|
+
await abandonParkedTurn(ref, active.turnId, 'Scheduled run: approval expired');
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
this.#o.log.warn('could not abandon an expired park', {
|
|
766
|
+
'namzu.schedule.job_id': job.id,
|
|
767
|
+
'exception.message': error instanceof Error ? error.message : String(error),
|
|
768
|
+
});
|
|
769
|
+
return state;
|
|
770
|
+
}
|
|
771
|
+
const expired = this.#finalize(job, run, {
|
|
772
|
+
...this.#emptyResult(job, run),
|
|
773
|
+
status: 'approval-expired',
|
|
774
|
+
exitCode: 1,
|
|
775
|
+
reason: 'nobody answered the approval in time; the turn was abandoned',
|
|
776
|
+
}, now);
|
|
777
|
+
await this.#notice('approval-expired', job, { at: new Date(now) });
|
|
778
|
+
return expired;
|
|
779
|
+
}
|
|
780
|
+
return state;
|
|
781
|
+
}
|
|
782
|
+
if (active)
|
|
783
|
+
return state;
|
|
784
|
+
const outcome = turnOutcome(facts, run.turnId);
|
|
785
|
+
if (!outcome)
|
|
786
|
+
return state;
|
|
787
|
+
return this.#finalize(job, run, {
|
|
788
|
+
...this.#emptyResult(job, run),
|
|
789
|
+
status: outcome.status,
|
|
790
|
+
exitCode: outcome.status === 'completed' ? 0 : 1,
|
|
791
|
+
...(outcome.reason ? { reason: outcome.reason } : {}),
|
|
792
|
+
}, now);
|
|
793
|
+
}
|
|
794
|
+
#emptyResult(job, run) {
|
|
795
|
+
return {
|
|
796
|
+
v: 1,
|
|
797
|
+
kind: 'schedule-run-result',
|
|
798
|
+
runId: run.runId,
|
|
799
|
+
jobId: job.id,
|
|
800
|
+
status: 'interrupted',
|
|
801
|
+
exitCode: 1,
|
|
802
|
+
startedAt: run.startedAt,
|
|
803
|
+
...(run.sessionId ? { sessionId: run.sessionId } : {}),
|
|
804
|
+
...(run.projectSlug ? { projectSlug: run.projectSlug } : {}),
|
|
805
|
+
...(run.turnId ? { turnId: run.turnId } : {}),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/** Record how a run ended, update counters, notify. Returns the new state (already written). */
|
|
809
|
+
#finalize(job, run, result, now) {
|
|
810
|
+
const paths = this.#o.paths;
|
|
811
|
+
const at = new Date(now).toISOString();
|
|
812
|
+
const status = result.status === 'running' ? 'interrupted' : result.status;
|
|
813
|
+
this.#running.delete(run.runId);
|
|
814
|
+
appendRunRecord(paths, job.id, run, result, at);
|
|
815
|
+
const current = readState(paths, job.id);
|
|
816
|
+
const failed = status === 'failed' ||
|
|
817
|
+
status === 'blocked-config' ||
|
|
818
|
+
status === 'timed-out' ||
|
|
819
|
+
status === 'interrupted' ||
|
|
820
|
+
status === 'approval-expired';
|
|
821
|
+
const counters = {
|
|
822
|
+
runs: current.counters.runs + (status === 'awaiting-approval' ? 0 : 1),
|
|
823
|
+
failures: current.counters.failures + (failed ? 1 : 0),
|
|
824
|
+
failureStreak: failed
|
|
825
|
+
? current.counters.failureStreak + 1
|
|
826
|
+
: status === 'completed'
|
|
827
|
+
? 0
|
|
828
|
+
: current.counters.failureStreak,
|
|
829
|
+
};
|
|
830
|
+
const quotaHold = status === 'failed' &&
|
|
831
|
+
result.retryAfterMs !== undefined &&
|
|
832
|
+
result.retryAfterMs > job.budget.waitForProviderMs
|
|
833
|
+
? new Date(now + result.retryAfterMs).toISOString()
|
|
834
|
+
: current.quotaHoldUntil;
|
|
835
|
+
let next;
|
|
836
|
+
if (status === 'awaiting-approval') {
|
|
837
|
+
next = withoutUndefined({
|
|
838
|
+
...current,
|
|
839
|
+
counters,
|
|
840
|
+
activeRun: withoutUndefined({
|
|
841
|
+
...run,
|
|
842
|
+
status: 'awaiting-approval',
|
|
843
|
+
sessionId: result.sessionId ?? run.sessionId,
|
|
844
|
+
projectSlug: result.projectSlug ?? run.projectSlug,
|
|
845
|
+
turnId: result.turnId ?? run.turnId,
|
|
846
|
+
parkedAt: run.parkedAt ?? at,
|
|
847
|
+
handoff: result.handoff ? { reason: result.handoff.reason } : undefined,
|
|
848
|
+
}),
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
next = withoutUndefined({
|
|
853
|
+
...current,
|
|
854
|
+
counters,
|
|
855
|
+
activeRun: undefined,
|
|
856
|
+
quotaHoldUntil: quotaHold,
|
|
857
|
+
lastRun: withoutUndefined({
|
|
858
|
+
runId: run.runId,
|
|
859
|
+
scheduledFor: run.scheduledFor,
|
|
860
|
+
status,
|
|
861
|
+
endedAt: result.endedAt ?? at,
|
|
862
|
+
sessionId: result.sessionId,
|
|
863
|
+
}),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
// The same failure is told once; a success clears it.
|
|
867
|
+
let incident = next.lastIncident;
|
|
868
|
+
let tell;
|
|
869
|
+
if (status === 'completed') {
|
|
870
|
+
incident = undefined;
|
|
871
|
+
tell = 'finished';
|
|
872
|
+
}
|
|
873
|
+
else if (status === 'awaiting-approval') {
|
|
874
|
+
tell = 'awaiting-approval';
|
|
875
|
+
}
|
|
876
|
+
else if (failed && status !== 'approval-expired') {
|
|
877
|
+
const signature = failureSignature(status, result.reason);
|
|
878
|
+
if (incident?.signature !== signature) {
|
|
879
|
+
tell =
|
|
880
|
+
status === 'blocked-config'
|
|
881
|
+
? 'blocked-config'
|
|
882
|
+
: status === 'timed-out'
|
|
883
|
+
? 'timed-out'
|
|
884
|
+
: status === 'interrupted'
|
|
885
|
+
? 'interrupted'
|
|
886
|
+
: 'failed';
|
|
887
|
+
incident = { signature, at };
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
next = withoutUndefined({ ...next, lastIncident: incident });
|
|
891
|
+
writeState(paths, next);
|
|
892
|
+
// A failing job pauses itself rather than failing every tick forever.
|
|
893
|
+
const pauseAfter = job.failurePolicy.pauseAfterFailures;
|
|
894
|
+
let autoPaused = false;
|
|
895
|
+
if (failed && pauseAfter > 0 && counters.failureStreak >= pauseAfter) {
|
|
896
|
+
const latest = readJob(paths, job.id);
|
|
897
|
+
if (latest && latest.state === 'active') {
|
|
898
|
+
updateJob(paths, job.id, latest.revision, (j) => ({
|
|
899
|
+
...j,
|
|
900
|
+
state: 'paused',
|
|
901
|
+
pausedAt: at,
|
|
902
|
+
pausedBy: 'auto-failure-streak',
|
|
903
|
+
}));
|
|
904
|
+
appendHistory(paths, job.id, {
|
|
905
|
+
v: 1,
|
|
906
|
+
kind: 'job',
|
|
907
|
+
at,
|
|
908
|
+
action: 'paused',
|
|
909
|
+
by: 'auto-failure-streak',
|
|
910
|
+
detail: `${counters.failureStreak} failed runs in a row`,
|
|
911
|
+
});
|
|
912
|
+
autoPaused = true;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// A one-shot that ran is complete.
|
|
916
|
+
if (job.schedule.kind === 'at' && status !== 'awaiting-approval' && run.trigger !== 'manual') {
|
|
917
|
+
const latest = readJob(paths, job.id);
|
|
918
|
+
if (latest && latest.state === 'active') {
|
|
919
|
+
updateJob(paths, job.id, latest.revision, (j) => ({ ...j, state: 'completed' }));
|
|
920
|
+
appendHistory(paths, job.id, { v: 1, kind: 'job', at, action: 'completed', by: 'daemon' });
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const notices = [];
|
|
924
|
+
if (tell) {
|
|
925
|
+
notices.push(this.#notice(tell, job, {
|
|
926
|
+
at: new Date(now),
|
|
927
|
+
...(result.summary ? { summary: result.summary } : {}),
|
|
928
|
+
...(tell === 'awaiting-approval' && next.activeRun?.sessionId
|
|
929
|
+
? { resumeCommand: resumeCommand(job, next.activeRun.sessionId) }
|
|
930
|
+
: {}),
|
|
931
|
+
...(tell === 'awaiting-approval' && result.handoff
|
|
932
|
+
? { handoff: result.handoff.reason }
|
|
933
|
+
: {}),
|
|
934
|
+
}));
|
|
935
|
+
}
|
|
936
|
+
if (autoPaused)
|
|
937
|
+
notices.push(this.#notice('auto-paused', job, { at: new Date(now), failures: counters.failureStreak }));
|
|
938
|
+
if (status === 'completed') {
|
|
939
|
+
notices.push(archiveOldRuns(paths, job, next).then((archived) => {
|
|
940
|
+
if (archived.length > 0) {
|
|
941
|
+
const latest = readState(paths, job.id);
|
|
942
|
+
writeState(paths, {
|
|
943
|
+
...latest,
|
|
944
|
+
archivedSessions: [...(latest.archivedSessions ?? []), ...archived].slice(-200),
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}, () => undefined));
|
|
948
|
+
}
|
|
949
|
+
this.#finalizing.push(Promise.allSettled(notices).then(() => undefined));
|
|
950
|
+
this.#o.log.info('scheduled run recorded', {
|
|
951
|
+
'namzu.schedule.job_id': job.id,
|
|
952
|
+
'namzu.schedule.run_id': run.runId,
|
|
953
|
+
'namzu.schedule.status': status,
|
|
954
|
+
});
|
|
955
|
+
return next;
|
|
956
|
+
}
|
|
957
|
+
/** Start queued runs, oldest first, within the cap and the folder lanes. */
|
|
958
|
+
async #dispatch(now) {
|
|
959
|
+
const paths = this.#o.paths;
|
|
960
|
+
const queue = [];
|
|
961
|
+
for (const job of listJobs(paths).jobs) {
|
|
962
|
+
const state = readState(paths, job.id);
|
|
963
|
+
if (state.queued)
|
|
964
|
+
queue.push({ job, state });
|
|
965
|
+
}
|
|
966
|
+
queue.sort((a, b) => (a.state.queued?.queuedAt ?? '').localeCompare(b.state.queued?.queuedAt ?? ''));
|
|
967
|
+
for (const manual of this.#manual.splice(0)) {
|
|
968
|
+
const job = readJob(paths, manual.jobId);
|
|
969
|
+
if (job)
|
|
970
|
+
queue.push({ job, state: readState(paths, job.id), manual: manual.runId });
|
|
971
|
+
}
|
|
972
|
+
for (const entry of queue) {
|
|
973
|
+
const { job } = entry;
|
|
974
|
+
const queued = entry.state.queued;
|
|
975
|
+
if (!(await this.#stillOwner())) {
|
|
976
|
+
this.#o.log.warn('scheduler fenced out; not dispatching', {
|
|
977
|
+
'namzu.schedule.epoch': this.#o.epoch,
|
|
978
|
+
});
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const latest = readJob(paths, job.id);
|
|
982
|
+
if (!latest || latest.state !== 'active' || !confirmationHolds(latest)) {
|
|
983
|
+
if (queued)
|
|
984
|
+
writeState(paths, withoutUndefined({ ...entry.state, queued: undefined }));
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const writes = allowsWrites(latest.permissions);
|
|
988
|
+
const busyLane = writes &&
|
|
989
|
+
[...this.#running.values()].some((t) => t.writes && t.folder === latest.folder.canonical);
|
|
990
|
+
if (this.#running.size >= this.#o.maxConcurrentRuns || busyLane) {
|
|
991
|
+
this.#delayReasons.set(job.id, busyLane ? 'folder-busy' : 'concurrency-cap');
|
|
992
|
+
if (entry.manual)
|
|
993
|
+
this.#manual.push({ jobId: job.id, runId: entry.manual });
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
const delayReason = this.#delayReasons.get(job.id);
|
|
997
|
+
this.#delayReasons.delete(job.id);
|
|
998
|
+
// A manual run queued on disk (during a drain) keeps the run id its key names.
|
|
999
|
+
const queuedManual = !entry.manual && queued?.trigger === 'manual' && queued.key.startsWith(MANUAL_KEY_PREFIX)
|
|
1000
|
+
? queued.key.slice(MANUAL_KEY_PREFIX.length)
|
|
1001
|
+
: undefined;
|
|
1002
|
+
const runId = entry.manual ?? queuedManual ?? generateScheduleRunId();
|
|
1003
|
+
const key = entry.manual ? `${MANUAL_KEY_PREFIX}${runId}` : queued?.key;
|
|
1004
|
+
const trigger = entry.manual ? 'manual' : (queued?.trigger ?? 'scheduled');
|
|
1005
|
+
if (!claimOccurrence(paths, {
|
|
1006
|
+
jobId: job.id,
|
|
1007
|
+
key,
|
|
1008
|
+
runId,
|
|
1009
|
+
daemonEpoch: this.#o.epoch,
|
|
1010
|
+
at: new Date(now).toISOString(),
|
|
1011
|
+
})) {
|
|
1012
|
+
// Somebody already started this occurrence.
|
|
1013
|
+
if (queued)
|
|
1014
|
+
writeState(paths, withoutUndefined({ ...readState(paths, job.id), queued: undefined }));
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
const delayedMs = queued && trigger !== 'manual' ? Math.max(0, now - Date.parse(queued.queuedAt)) : 0;
|
|
1018
|
+
const run = withoutUndefined({
|
|
1019
|
+
runId,
|
|
1020
|
+
key,
|
|
1021
|
+
trigger,
|
|
1022
|
+
scheduledFor: trigger === 'manual' ? undefined : queued?.scheduledFor,
|
|
1023
|
+
startedAt: new Date(now).toISOString(),
|
|
1024
|
+
daemonEpoch: this.#o.epoch,
|
|
1025
|
+
status: 'running',
|
|
1026
|
+
delayedMs: delayedMs >= 1_000 ? delayedMs : undefined,
|
|
1027
|
+
delayReason: delayedMs >= 1_000 ? (delayReason ?? 'concurrency-cap') : undefined,
|
|
1028
|
+
});
|
|
1029
|
+
writeState(paths, withoutUndefined({ ...readState(paths, job.id), queued: undefined, activeRun: run }));
|
|
1030
|
+
appendHistory(paths, job.id, {
|
|
1031
|
+
v: 1,
|
|
1032
|
+
kind: 'run',
|
|
1033
|
+
at: run.startedAt,
|
|
1034
|
+
runId,
|
|
1035
|
+
key,
|
|
1036
|
+
trigger,
|
|
1037
|
+
...(run.scheduledFor ? { scheduledFor: run.scheduledFor } : {}),
|
|
1038
|
+
startedAt: run.startedAt,
|
|
1039
|
+
...(run.delayedMs
|
|
1040
|
+
? { delayedMs: run.delayedMs, delayReason: run.delayReason ?? 'concurrency-cap' }
|
|
1041
|
+
: {}),
|
|
1042
|
+
status: 'running',
|
|
1043
|
+
});
|
|
1044
|
+
let spawned;
|
|
1045
|
+
try {
|
|
1046
|
+
spawned = this.#o.spawnFire({
|
|
1047
|
+
job: latest,
|
|
1048
|
+
runId,
|
|
1049
|
+
key,
|
|
1050
|
+
trigger,
|
|
1051
|
+
...(run.scheduledFor ? { scheduledFor: run.scheduledFor } : {}),
|
|
1052
|
+
epoch: this.#o.epoch,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
this.#finalize(latest, run, {
|
|
1057
|
+
...this.#emptyResult(latest, run),
|
|
1058
|
+
status: 'failed',
|
|
1059
|
+
exitCode: 1,
|
|
1060
|
+
reason: `the run could not be started: ${error instanceof Error ? error.message : String(error)}`,
|
|
1061
|
+
}, now);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
this.#running.set(runId, {
|
|
1065
|
+
jobId: job.id,
|
|
1066
|
+
runId,
|
|
1067
|
+
folder: latest.folder.canonical,
|
|
1068
|
+
writes,
|
|
1069
|
+
run: spawned,
|
|
1070
|
+
});
|
|
1071
|
+
this.#o.log.info('scheduled run started', {
|
|
1072
|
+
'namzu.schedule.job_id': job.id,
|
|
1073
|
+
'namzu.schedule.run_id': runId,
|
|
1074
|
+
'namzu.schedule.trigger': trigger,
|
|
1075
|
+
});
|
|
1076
|
+
void spawned.exited.then(() => {
|
|
1077
|
+
const tracked = this.#running.get(runId);
|
|
1078
|
+
if (tracked)
|
|
1079
|
+
this.#running.set(runId, { ...tracked, run: undefined });
|
|
1080
|
+
const jobNow = readJob(paths, job.id) ?? latest;
|
|
1081
|
+
const state = readState(paths, job.id);
|
|
1082
|
+
if (state.activeRun?.runId === runId) {
|
|
1083
|
+
const result = readRunResult(paths, job.id, runId);
|
|
1084
|
+
this.#finalize(jobNow, state.activeRun, isFinal(result)
|
|
1085
|
+
? result
|
|
1086
|
+
: {
|
|
1087
|
+
...this.#emptyResult(jobNow, state.activeRun),
|
|
1088
|
+
...(result ?? {}),
|
|
1089
|
+
status: 'interrupted',
|
|
1090
|
+
exitCode: 1,
|
|
1091
|
+
reason: 'the run ended without recording a result',
|
|
1092
|
+
}, this.#now());
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
this.#running.delete(runId);
|
|
1096
|
+
// The job was removed (`remove --force`) while this run went on:
|
|
1097
|
+
// nothing will ever settle it, so its end is written here, where
|
|
1098
|
+
// `prune` finds it.
|
|
1099
|
+
if (!readJob(paths, job.id)) {
|
|
1100
|
+
const result = readRunResult(paths, job.id, runId);
|
|
1101
|
+
appendRunRecord(paths, job.id, run, isFinal(result)
|
|
1102
|
+
? result
|
|
1103
|
+
: {
|
|
1104
|
+
...this.#emptyResult(latest, run),
|
|
1105
|
+
...(result ?? {}),
|
|
1106
|
+
status: 'interrupted',
|
|
1107
|
+
exitCode: 1,
|
|
1108
|
+
reason: 'the run ended without recording a result',
|
|
1109
|
+
}, new Date(this.#now()).toISOString());
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
this.wake();
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
/** For tests: wait until notifications and archiving started by finalisation are done. */
|
|
1117
|
+
async settled() {
|
|
1118
|
+
await Promise.allSettled(this.#finalizing.splice(0));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
/** A manual run's occurrence key is this and its run id. */
|
|
1122
|
+
export const MANUAL_KEY_PREFIX = 'manual-';
|
|
1123
|
+
/**
|
|
1124
|
+
* Append the record of how a run ended (or parked) to its job's history. A
|
|
1125
|
+
* run still `running` is recorded `interrupted`: the record is final.
|
|
1126
|
+
*/
|
|
1127
|
+
export function appendRunRecord(paths, jobId, run, result, at) {
|
|
1128
|
+
const status = result.status === 'running' ? 'interrupted' : result.status;
|
|
1129
|
+
appendHistory(paths, jobId, {
|
|
1130
|
+
v: 1,
|
|
1131
|
+
kind: 'run',
|
|
1132
|
+
at,
|
|
1133
|
+
runId: run.runId,
|
|
1134
|
+
key: run.key,
|
|
1135
|
+
trigger: run.trigger,
|
|
1136
|
+
...(run.scheduledFor ? { scheduledFor: run.scheduledFor } : {}),
|
|
1137
|
+
startedAt: run.startedAt,
|
|
1138
|
+
...(status === 'awaiting-approval' ? {} : { endedAt: result.endedAt ?? at }),
|
|
1139
|
+
...(run.delayedMs
|
|
1140
|
+
? { delayedMs: run.delayedMs, delayReason: run.delayReason ?? 'concurrency-cap' }
|
|
1141
|
+
: {}),
|
|
1142
|
+
...(result.sessionId ? { sessionId: result.sessionId } : {}),
|
|
1143
|
+
status,
|
|
1144
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
1145
|
+
exitCode: result.exitCode,
|
|
1146
|
+
...(result.summary ? { summary: result.summary } : {}),
|
|
1147
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
1148
|
+
...(result.warnings ? { warnings: result.warnings } : {}),
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
/** Where `schedule stop` leaves its request. See `ScheduleDaemon#stopRequested`. */
|
|
1152
|
+
export function stopRequestPath(paths) {
|
|
1153
|
+
return join(paths.daemon, 'stop.json');
|
|
1154
|
+
}
|
|
1155
|
+
/** Ask every daemon of this home, owner or standby, to stop; or clear the request. */
|
|
1156
|
+
export function requestStop(paths, stop) {
|
|
1157
|
+
if (stop)
|
|
1158
|
+
writeJsonAtomic(stopRequestPath(paths), {
|
|
1159
|
+
v: 1,
|
|
1160
|
+
kind: 'schedule-stop',
|
|
1161
|
+
at: new Date().toISOString(),
|
|
1162
|
+
});
|
|
1163
|
+
else
|
|
1164
|
+
rmSync(stopRequestPath(paths), { force: true });
|
|
1165
|
+
}
|
|
1166
|
+
/** Spawn `node <bin> schedule __fire …` with its output going to a log file, not a pipe. */
|
|
1167
|
+
export function spawnFireProcess(options) {
|
|
1168
|
+
return (request) => {
|
|
1169
|
+
const logPath = options.paths.runLog(request.job.id, request.runId);
|
|
1170
|
+
ensureDir(dirname(logPath));
|
|
1171
|
+
writeFileSync(logPath, '', { flag: 'a', mode: PRIVATE_FILE_MODE });
|
|
1172
|
+
const fd = openSync(logPath, 'a', PRIVATE_FILE_MODE);
|
|
1173
|
+
let child;
|
|
1174
|
+
try {
|
|
1175
|
+
child = spawn(options.node, [
|
|
1176
|
+
options.bin,
|
|
1177
|
+
'schedule',
|
|
1178
|
+
'__fire',
|
|
1179
|
+
'--home',
|
|
1180
|
+
options.paths.home,
|
|
1181
|
+
'--job',
|
|
1182
|
+
request.job.id,
|
|
1183
|
+
'--run',
|
|
1184
|
+
request.runId,
|
|
1185
|
+
'--key',
|
|
1186
|
+
request.key,
|
|
1187
|
+
'--revision',
|
|
1188
|
+
String(request.job.revision),
|
|
1189
|
+
'--trigger',
|
|
1190
|
+
request.trigger,
|
|
1191
|
+
...(request.scheduledFor ? ['--scheduled-for', request.scheduledFor] : []),
|
|
1192
|
+
'--epoch',
|
|
1193
|
+
request.epoch,
|
|
1194
|
+
], {
|
|
1195
|
+
cwd: request.job.folder.canonical,
|
|
1196
|
+
env: childEnvironment(options.env, options.paths.home),
|
|
1197
|
+
stdio: ['ignore', fd, fd],
|
|
1198
|
+
detached: process.platform !== 'win32',
|
|
1199
|
+
windowsHide: true,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
finally {
|
|
1203
|
+
closeSync(fd);
|
|
1204
|
+
}
|
|
1205
|
+
const exited = new Promise((resolve) => {
|
|
1206
|
+
child.once('exit', (code) => resolve(code));
|
|
1207
|
+
child.once('error', () => resolve(null));
|
|
1208
|
+
});
|
|
1209
|
+
child.unref();
|
|
1210
|
+
return {
|
|
1211
|
+
exited,
|
|
1212
|
+
terminate: () => {
|
|
1213
|
+
try {
|
|
1214
|
+
child.kill('SIGTERM');
|
|
1215
|
+
}
|
|
1216
|
+
catch { }
|
|
1217
|
+
},
|
|
1218
|
+
};
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* The CLI as installed, cheaply: its version file, its entry point and the
|
|
1223
|
+
* scheduler's own modules, by size and time. An npm install rewrites every
|
|
1224
|
+
* file; a rebuild rewrites the modules that changed.
|
|
1225
|
+
*/
|
|
1226
|
+
export function installedFingerprint(bin) {
|
|
1227
|
+
const dist = dirname(bin);
|
|
1228
|
+
const watched = [
|
|
1229
|
+
join(dist, '..', 'package.json'),
|
|
1230
|
+
bin,
|
|
1231
|
+
join(dist, 'schedule', 'daemon', 'daemon.js'),
|
|
1232
|
+
join(dist, 'schedule', 'fire', 'fire.js'),
|
|
1233
|
+
];
|
|
1234
|
+
return () => {
|
|
1235
|
+
const parts = [];
|
|
1236
|
+
for (const path of watched) {
|
|
1237
|
+
try {
|
|
1238
|
+
const st = statSync(path);
|
|
1239
|
+
parts.push(`${st.size}:${st.mtimeMs}`);
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
parts.push('missing');
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return parts.join('|');
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
//# sourceMappingURL=daemon.js.map
|