@ucsandman/legcli 0.7.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 +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- package/src/worktree.mjs +207 -0
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Ported 2026-09-10 from private ucsandman team tooling; see NOTICE and docs/REUSE.md.
|
|
3
|
+
// runner — detached, supervised execution of one agent leg on a card.
|
|
4
|
+
// Subcommands: launch | supervise | sweep.
|
|
5
|
+
// State: $BATON_HOME/cards/<id>/runs/<n>/ (run.json, prompt.txt, out.log, err.log,
|
|
6
|
+
// supervisor.log). Ledger writes go through src/ledger.mjs. Exports sanitizeEnv.
|
|
7
|
+
import {
|
|
8
|
+
mkdirSync, readFileSync, existsSync, openSync, copyFileSync, readdirSync, rmSync, statSync,
|
|
9
|
+
} from 'node:fs'
|
|
10
|
+
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
11
|
+
import { spawn, spawnSync, execFileSync } from 'node:child_process'
|
|
12
|
+
import { join, dirname, resolve } from 'node:path'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { get as getAdapter } from './adapters/index.mjs'
|
|
16
|
+
import { classify } from './limits.mjs'
|
|
17
|
+
|
|
18
|
+
const SELF = fileURLToPath(import.meta.url)
|
|
19
|
+
const LEDGER = join(dirname(SELF), 'ledger.mjs')
|
|
20
|
+
const ROOT = process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg'))
|
|
21
|
+
const [NOTIFY_MS, KILL_MS, KILL_VERIFY_RAW] = ((process.env.LEG_TIMERS_MS || process.env.BATON_TIMERS_MS) || '1800000,5400000')
|
|
22
|
+
.split(',').map(Number)
|
|
23
|
+
const KILL_VERIFY_MS = Number.isFinite(KILL_VERIFY_RAW) ? KILL_VERIFY_RAW : 30000
|
|
24
|
+
const BATON_ACTOR = JSON.stringify({ type: 'baton' })
|
|
25
|
+
const ACTIVE = ['launching', 'running']
|
|
26
|
+
|
|
27
|
+
// Every adapter's env() calls sanitizeEnv; it lives in src/env.mjs (see there)
|
|
28
|
+
// and is re-exported here so callers have one import.
|
|
29
|
+
export { sanitizeEnv } from './env.mjs'
|
|
30
|
+
|
|
31
|
+
function die(code, msg) {
|
|
32
|
+
process.stderr.write(msg + '\n')
|
|
33
|
+
process.exit(code)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const args = {}
|
|
38
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
39
|
+
if (!argv[i].startsWith('--') || argv[i + 1] === undefined) {
|
|
40
|
+
die(2, `bad argument pair near "${argv[i]}"`)
|
|
41
|
+
}
|
|
42
|
+
args[argv[i].slice(2)] = argv[i + 1]
|
|
43
|
+
}
|
|
44
|
+
return args
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function need(args, key) {
|
|
48
|
+
const v = args[key]
|
|
49
|
+
if (!v) die(2, `missing --${key}`)
|
|
50
|
+
return v
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const now = () => new Date().toISOString()
|
|
54
|
+
|
|
55
|
+
function pidAlive(pid) {
|
|
56
|
+
if (!pid) return false
|
|
57
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cardDir(id) { return join(ROOT, 'cards', id) }
|
|
61
|
+
function runsDir(id) { return join(cardDir(id), 'runs') }
|
|
62
|
+
function runDir(id, n) { return join(runsDir(id), String(n)) }
|
|
63
|
+
|
|
64
|
+
function listRuns(id) {
|
|
65
|
+
const dir = runsDir(id)
|
|
66
|
+
if (!existsSync(dir)) return []
|
|
67
|
+
return readdirSync(dir).map(Number).filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => a - b)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readRun(id, n) {
|
|
71
|
+
const f = join(runDir(id, n), 'run.json')
|
|
72
|
+
if (!existsSync(f)) return null
|
|
73
|
+
try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeRun(id, n, r) {
|
|
77
|
+
// atomic: the orchestrator and the board poll this file (src/fsx.mjs)
|
|
78
|
+
writeJsonAtomic(join(runDir(id, n), 'run.json'), { ...r, updated_at: now() })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
82
|
+
|
|
83
|
+
// Read-modify-write of run.json under its lock. The supervisor (status,
|
|
84
|
+
// agent_pid, the verdict), the driving orchestrator (driver_pid, settled_at)
|
|
85
|
+
// and a human Kill (kill_requested) all patch this file from different
|
|
86
|
+
// processes; without the lock the last writer silently dropped the others'
|
|
87
|
+
// fields. A read that finds the file torn (the atomic write's direct-write
|
|
88
|
+
// fallback on Windows) is retried before fn sees a null.
|
|
89
|
+
export function updateRun(id, n, fn) {
|
|
90
|
+
// a longer budget than the hook default: nobody's turn is waiting on this,
|
|
91
|
+
// and a patch that runs unlocked is a patch that can be lost
|
|
92
|
+
return withFileLock(join(runDir(id, n), 'run.json.lock'), () => {
|
|
93
|
+
let cur = readRun(id, n)
|
|
94
|
+
for (let i = 0; cur === null && i < 5 && existsSync(join(runDir(id, n), 'run.json')); i++) { pause(20); cur = readRun(id, n) }
|
|
95
|
+
const next = fn(cur)
|
|
96
|
+
if (next) writeRun(id, n, next)
|
|
97
|
+
return next
|
|
98
|
+
}, { retries: 250, waitMs: 20, staleMs: 10000 })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Ledger writes must never crash the supervisor; failures go to its log.
|
|
102
|
+
function ledgerSafe(argsArr, log) {
|
|
103
|
+
try {
|
|
104
|
+
execFileSync(process.execPath, [LEDGER, ...argsArr],
|
|
105
|
+
{ windowsHide: true, encoding: 'utf8', env: process.env })
|
|
106
|
+
return true
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log(`ledger write failed: ${String(err.message).slice(0, 300)}`)
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ledgerAppend(id, type, summary, body, log) {
|
|
114
|
+
const argsArr = ['append', '--card', id, '--actor', BATON_ACTOR, '--type', type, '--summary', summary]
|
|
115
|
+
if (body) argsArr.push('--body', body)
|
|
116
|
+
return ledgerSafe(argsArr, log)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The ledger's assertNoSecrets dies on matches; scrub BEFORE logging.
|
|
120
|
+
// One pattern list for the whole project lives in src/redact.mjs.
|
|
121
|
+
import { scrub } from './redact.mjs'
|
|
122
|
+
export { scrub }
|
|
123
|
+
|
|
124
|
+
function errTail(path, lines = 10) {
|
|
125
|
+
if (!existsSync(path)) return '(no stderr)'
|
|
126
|
+
const all = readFileSync(path, 'utf8').trim().split('\n')
|
|
127
|
+
return scrub(all.slice(-lines).join(' | ')).slice(0, 1500)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function killTree(pid, log) {
|
|
131
|
+
if ((process.env.LEG_SKIP_KILL || process.env.BATON_SKIP_KILL) === '1') { // test seam: unkillable agent
|
|
132
|
+
log('BATON_SKIP_KILL=1: killTree skipped')
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (process.platform === 'win32') {
|
|
136
|
+
const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
|
|
137
|
+
if (r.status !== 0) log(`taskkill failed: ${String(r.stderr ?? '').slice(0, 300)}`)
|
|
138
|
+
} else {
|
|
139
|
+
try { process.kill(-pid, 'SIGKILL') } catch { try { process.kill(pid, 'SIGKILL') } catch {} }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Work evidence for the classifier: did the leg change anything in its cwd?
|
|
144
|
+
// Cheap and worktree-agnostic: porcelain status plus HEAD movement. Not a git
|
|
145
|
+
// repo → null (the classifier then trusts only the DONE marker).
|
|
146
|
+
function gitHead(cwd) {
|
|
147
|
+
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
148
|
+
return r.status === 0 ? r.stdout.trim() : null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function gitDiff(cwd, headAtStart) {
|
|
152
|
+
const r = spawnSync('git', ['status', '--porcelain'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
153
|
+
if (r.status !== 0) return null
|
|
154
|
+
const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.baton[\\/]/.test(l)).length
|
|
155
|
+
const head = gitHead(cwd)
|
|
156
|
+
return { changed: files > 0 || (headAtStart !== null && head !== headAtStart), files, head_at_start: headAtStart, head }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fallback when the cwd is not a git repo (tests, ad-hoc dirs): a shallow
|
|
160
|
+
// mtime snapshot, so "wrote a file but no DONE" still reads as incomplete.
|
|
161
|
+
const SNAP_SKIP = new Set(['.git', 'node_modules', '.baton', '.baton-worktrees'])
|
|
162
|
+
function fsSnapshot(cwd, depth = 3) {
|
|
163
|
+
const out = new Map()
|
|
164
|
+
const walk = (dir, rel, d) => {
|
|
165
|
+
let names
|
|
166
|
+
try { names = readdirSync(dir) } catch { return }
|
|
167
|
+
for (const name of names) {
|
|
168
|
+
if (SNAP_SKIP.has(name) || out.size > 5000) continue
|
|
169
|
+
const full = join(dir, name)
|
|
170
|
+
let st
|
|
171
|
+
try { st = statSync(full) } catch { continue }
|
|
172
|
+
if (st.isDirectory()) { if (d > 0) walk(full, `${rel}${name}/`, d - 1) } else out.set(`${rel}${name}`, st.mtimeMs)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
walk(cwd, '', depth)
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function fsDiff(cwd, before) {
|
|
180
|
+
const after = fsSnapshot(cwd)
|
|
181
|
+
let files = 0
|
|
182
|
+
for (const [k, v] of after) if (!before.has(k) || before.get(k) !== v) files += 1
|
|
183
|
+
for (const k of before.keys()) if (!after.has(k)) files += 1
|
|
184
|
+
return { changed: files > 0, files, head_at_start: null, head: null, source: 'fs' }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function legOpts(args) {
|
|
188
|
+
const opts = {}
|
|
189
|
+
if (args.mode) opts.mode = args.mode
|
|
190
|
+
if (args['env-json']) {
|
|
191
|
+
// Extra child variables (e.g. FAKE_MODE for the fake adapter); the adapter's
|
|
192
|
+
// env() still sanitizes the result, so auth keys cannot be smuggled in.
|
|
193
|
+
try { opts.extraEnv = JSON.parse(args['env-json']) } catch { die(2, 'invalid --env-json') }
|
|
194
|
+
if (!opts.extraEnv || typeof opts.extraEnv !== 'object') die(2, 'invalid --env-json')
|
|
195
|
+
}
|
|
196
|
+
if (args['max-turns']) {
|
|
197
|
+
opts.maxTurns = parseInt(args['max-turns'], 10)
|
|
198
|
+
if (!Number.isInteger(opts.maxTurns) || opts.maxTurns < 1) die(2, `invalid --max-turns "${args['max-turns']}"`)
|
|
199
|
+
}
|
|
200
|
+
if (args.resume) opts.resume = args.resume
|
|
201
|
+
if (args.model) opts.model = args.model
|
|
202
|
+
if (args.network === '1' || args.network === 'true') opts.network = true
|
|
203
|
+
return opts
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function legArgv(opts) {
|
|
207
|
+
const out = []
|
|
208
|
+
if (opts.mode) out.push('--mode', opts.mode)
|
|
209
|
+
if (opts.maxTurns) out.push('--max-turns', String(opts.maxTurns))
|
|
210
|
+
if (opts.resume) out.push('--resume', opts.resume)
|
|
211
|
+
if (opts.model) out.push('--model', opts.model)
|
|
212
|
+
if (opts.network) out.push('--network', '1')
|
|
213
|
+
if (opts.extraEnv) out.push('--env-json', JSON.stringify(opts.extraEnv))
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function main() {
|
|
218
|
+
const [cmd, ...rest] = process.argv.slice(2)
|
|
219
|
+
const args = parseArgs(rest)
|
|
220
|
+
|
|
221
|
+
if (cmd === 'launch') {
|
|
222
|
+
const id = need(args, 'card')
|
|
223
|
+
if (!existsSync(join(cardDir(id), 'card.json'))) die(3, `card not found: ${id}`)
|
|
224
|
+
const adapterName = need(args, 'adapter')
|
|
225
|
+
try { await getAdapter(adapterName) } catch (err) { die(2, err.message) }
|
|
226
|
+
const promptFile = need(args, 'prompt-file')
|
|
227
|
+
if (!existsSync(promptFile)) die(2, `prompt file not found: ${promptFile}`)
|
|
228
|
+
const cwd = resolve(args.cwd || process.cwd())
|
|
229
|
+
const opts = legOpts(args)
|
|
230
|
+
mkdirSync(runsDir(id), { recursive: true })
|
|
231
|
+
let last = 0
|
|
232
|
+
for (const n of listRuns(id)) {
|
|
233
|
+
last = n
|
|
234
|
+
const r = readRun(id, n)
|
|
235
|
+
if (!r || !ACTIVE.includes(r.status)) continue
|
|
236
|
+
if (pidAlive(r.supervisor_pid)) {
|
|
237
|
+
process.stdout.write(JSON.stringify({
|
|
238
|
+
ok: false, error: 'run_already_active', run: n, supervisor_pid: r.supervisor_pid,
|
|
239
|
+
}) + '\n')
|
|
240
|
+
process.exit(11)
|
|
241
|
+
}
|
|
242
|
+
writeRun(id, n, { ...r, status: 'orphaned' })
|
|
243
|
+
}
|
|
244
|
+
const n = last + 1
|
|
245
|
+
const dir = runDir(id, n)
|
|
246
|
+
mkdirSync(dir, { recursive: true })
|
|
247
|
+
copyFileSync(promptFile, join(dir, 'prompt.txt'))
|
|
248
|
+
const logFd = openSync(join(dir, 'supervisor.log'), 'a')
|
|
249
|
+
// run.json exists, naming its driver, before the supervisor is spawned: a
|
|
250
|
+
// second driver or a scheduler tick landing here sees a claimed run, never
|
|
251
|
+
// an empty directory it could launch into.
|
|
252
|
+
const driverPid = args['driver-pid'] ? parseInt(args['driver-pid'], 10) : null
|
|
253
|
+
writeRun(id, n, {
|
|
254
|
+
card_id: id, run: n, adapter: adapterName, mode: opts.mode ?? null,
|
|
255
|
+
max_turns: opts.maxTurns ?? null, model: opts.model ?? null, resume: opts.resume ?? null, cwd,
|
|
256
|
+
status: 'launching', supervisor_pid: null, agent_pid: null,
|
|
257
|
+
driver_pid: Number.isInteger(driverPid) ? driverPid : null,
|
|
258
|
+
started_at: now(), outcome: null,
|
|
259
|
+
})
|
|
260
|
+
const child = spawn(process.execPath,
|
|
261
|
+
[SELF, 'supervise', '--card', id, '--adapter', adapterName, '--run', String(n),
|
|
262
|
+
'--cwd', cwd, ...legArgv(opts)],
|
|
263
|
+
{ detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: process.env })
|
|
264
|
+
child.unref()
|
|
265
|
+
updateRun(id, n, (cur) => ({ ...cur, supervisor_pid: child.pid }))
|
|
266
|
+
process.stdout.write(JSON.stringify({
|
|
267
|
+
ok: true, run: n, supervisor_pid: child.pid, run_dir: dir,
|
|
268
|
+
}) + '\n')
|
|
269
|
+
process.exit(0)
|
|
270
|
+
} else if (cmd === 'supervise') {
|
|
271
|
+
const id = need(args, 'card')
|
|
272
|
+
const adapterName = need(args, 'adapter')
|
|
273
|
+
const n = parseInt(need(args, 'run'), 10)
|
|
274
|
+
if (!Number.isInteger(n) || n < 1) die(2, `invalid --run "${args.run}"`)
|
|
275
|
+
const cwd = resolve(args.cwd || process.cwd())
|
|
276
|
+
const opts = legOpts(args)
|
|
277
|
+
if (!Number.isFinite(NOTIFY_MS) || !Number.isFinite(KILL_MS)) {
|
|
278
|
+
die(2, `bad BATON_TIMERS_MS "${process.env.BATON_TIMERS_MS}"`)
|
|
279
|
+
}
|
|
280
|
+
const dir = runDir(id, n)
|
|
281
|
+
// what a run.json patch falls back to if the file cannot be read (torn)
|
|
282
|
+
const base = { card_id: id, run: n, adapter: adapterName, cwd, supervisor_pid: process.pid, started_at: now() }
|
|
283
|
+
// stdout IS supervisor.log when launched detached; timestamps make it a log.
|
|
284
|
+
const log = (msg) => process.stdout.write(`${now()} ${msg}\n`)
|
|
285
|
+
const prompt = readFileSync(join(dir, 'prompt.txt'), 'utf8')
|
|
286
|
+
const outPath = join(dir, 'out.log')
|
|
287
|
+
const errPath = join(dir, 'err.log')
|
|
288
|
+
const outFd = openSync(outPath, 'w')
|
|
289
|
+
const errFd = openSync(errPath, 'w')
|
|
290
|
+
let adapter
|
|
291
|
+
try { adapter = await getAdapter(adapterName) } catch (err) { die(2, err.message) }
|
|
292
|
+
let spec
|
|
293
|
+
try {
|
|
294
|
+
spec = adapter.argv({ ...opts, cwd, prompt, promptFile: join(dir, 'prompt.txt'), runDir: dir, killMs: KILL_MS })
|
|
295
|
+
} catch (err) {
|
|
296
|
+
// A forbidden mode/flag never spawns: record it and fail the leg.
|
|
297
|
+
log(`refusing to launch: ${err.message}`)
|
|
298
|
+
ledgerAppend(id, 'error', `[supervisor] refused to launch: ${scrub(err.message).slice(0, 200)}`, null, log)
|
|
299
|
+
updateRun(id, n, (cur) => ({ ...(cur ?? base), status: 'failed', exit_code: null, ended_at: now(), refusal: err.message }))
|
|
300
|
+
process.exit(13)
|
|
301
|
+
}
|
|
302
|
+
const childEnv = adapter.env({ ...process.env, ...(opts.extraEnv ?? {}) })
|
|
303
|
+
// A stale DONE marker from an earlier leg must not count for this one.
|
|
304
|
+
rmSync(join(cwd, '.leg', 'DONE'), { force: true })
|
|
305
|
+
rmSync(join(cwd, '.baton', 'DONE'), { force: true })
|
|
306
|
+
const headAtStart = gitHead(cwd)
|
|
307
|
+
const fsAtStart = headAtStart === null ? fsSnapshot(cwd) : null
|
|
308
|
+
log(`spawning leg: adapter=${adapterName} run=${n} mode=${opts.mode ?? 'default'} cwd=${cwd}`)
|
|
309
|
+
ledgerAppend(id, 'leg_started', `leg started: adapter=${adapterName} run=${n} mode=${opts.mode ?? 'default'}`, null, log)
|
|
310
|
+
const child = spawn(spec.bin, spec.args, {
|
|
311
|
+
cwd, windowsHide: true, stdio: [adapter.stdin === 'pipe' ? 'pipe' : 'ignore', outFd, errFd], env: childEnv,
|
|
312
|
+
})
|
|
313
|
+
if (adapter.stdin === 'pipe') {
|
|
314
|
+
child.stdin.on('error', () => {})
|
|
315
|
+
child.stdin.write(prompt)
|
|
316
|
+
child.stdin.end()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
child.on('error', (err) => {
|
|
320
|
+
log(`agent spawn error: ${err.message}`)
|
|
321
|
+
ledgerAppend(id, 'error', `[supervisor] agent spawn failed: ${scrub(err.message).slice(0, 200)}`, null, log)
|
|
322
|
+
// A run that never started still needs a verdict, or the orchestrator
|
|
323
|
+
// has nothing to transition on and the card sits in `running` forever.
|
|
324
|
+
const verdict = classify({
|
|
325
|
+
adapter: adapter.emulates ?? adapterName, exitCode: null, stdout: '', stderr: '', result: null,
|
|
326
|
+
doneMarker: false, diff: null, killedByTimer: false, killedByHuman: false, spawnError: err.message,
|
|
327
|
+
})
|
|
328
|
+
updateRun(id, n, (cur) => ({
|
|
329
|
+
...(cur ?? base), status: 'failed', exit_code: null, ended_at: now(),
|
|
330
|
+
outcome: verdict.outcome, signal: verdict.signal, handoff: verdict.handoff, reason: verdict.reason,
|
|
331
|
+
}))
|
|
332
|
+
process.exit(13)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
updateRun(id, n, (cur) => ({ ...(cur ?? base), status: 'running', agent_pid: child.pid }))
|
|
336
|
+
let killedByTimer = false
|
|
337
|
+
const notifyTimer = setTimeout(() => {
|
|
338
|
+
ledgerAppend(id, 'status',
|
|
339
|
+
`[supervisor] leg still running after ${Math.round(NOTIFY_MS / 60000)}m`,
|
|
340
|
+
`log: ${errPath}`, log)
|
|
341
|
+
}, NOTIFY_MS)
|
|
342
|
+
const killTimer = setTimeout(() => {
|
|
343
|
+
killedByTimer = true
|
|
344
|
+
log(`kill timer fired after ${KILL_MS}ms; killing pid ${child.pid}`)
|
|
345
|
+
killTree(child.pid, log)
|
|
346
|
+
// Kill-verify fallback: taskkill can fail and leave the agent burning
|
|
347
|
+
// tokens with the supervisor blocked on the child handle forever.
|
|
348
|
+
// Retry once, then surface the unkillable pid and exit anyway.
|
|
349
|
+
setTimeout(() => {
|
|
350
|
+
if (child.exitCode !== null || !pidAlive(child.pid)) return
|
|
351
|
+
log(`agent pid ${child.pid} survived kill; retrying once`)
|
|
352
|
+
killTree(child.pid, log)
|
|
353
|
+
setTimeout(() => {
|
|
354
|
+
if (child.exitCode !== null || !pidAlive(child.pid)) return
|
|
355
|
+
ledgerAppend(id, 'error',
|
|
356
|
+
`[supervisor] agent UNKILLABLE (pid ${child.pid}); manual kill required`, null, log)
|
|
357
|
+
updateRun(id, n, (cur) => ({ ...(cur ?? base), status: 'killed', exit_code: null, ended_at: now() }))
|
|
358
|
+
process.exit(12)
|
|
359
|
+
}, KILL_VERIFY_MS)
|
|
360
|
+
}, KILL_VERIFY_MS)
|
|
361
|
+
}, KILL_MS)
|
|
362
|
+
|
|
363
|
+
child.on('exit', (code) => {
|
|
364
|
+
clearTimeout(notifyTimer)
|
|
365
|
+
clearTimeout(killTimer)
|
|
366
|
+
// JSON-only-at-end: the result file is read here, after the exit event,
|
|
367
|
+
// and nowhere else (LESSONS 07-10).
|
|
368
|
+
let stdout = ''
|
|
369
|
+
let stderr = ''
|
|
370
|
+
try { stdout = readFileSync(outPath, 'utf8') } catch {}
|
|
371
|
+
try { stderr = readFileSync(errPath, 'utf8') } catch {}
|
|
372
|
+
let parsed = null
|
|
373
|
+
try { parsed = adapter.parseResult(stdout) } catch {}
|
|
374
|
+
const sessionId = parsed?.session_id ?? null
|
|
375
|
+
if (sessionId) {
|
|
376
|
+
ledgerSafe(['update', '--card', id, '--session-id', sessionId], log)
|
|
377
|
+
}
|
|
378
|
+
const current = readRun(id, n)
|
|
379
|
+
const doneMarker = existsSync(join(cwd, '.leg', 'DONE')) || existsSync(join(cwd, '.baton', 'DONE'))
|
|
380
|
+
const diff = fsAtStart ? fsDiff(cwd, fsAtStart) : gitDiff(cwd, headAtStart)
|
|
381
|
+
const verdict = classify({
|
|
382
|
+
adapter: adapter.emulates ?? adapterName, exitCode: code, stdout, stderr, result: parsed?.raw ?? null,
|
|
383
|
+
doneMarker, diff, killedByTimer, killedByHuman: current?.kill_requested === true, spawnError: null,
|
|
384
|
+
})
|
|
385
|
+
const final = {
|
|
386
|
+
exit_code: code, session_id: sessionId, ended_at: now(),
|
|
387
|
+
outcome: verdict.outcome, signal: verdict.signal, handoff: verdict.handoff, reason: verdict.reason,
|
|
388
|
+
done_marker: doneMarker, diff,
|
|
389
|
+
}
|
|
390
|
+
const summary = `[supervisor] leg ${verdict.outcome} (exit ${code}${sessionId ? `, session ${sessionId}` : ''}${verdict.signal !== 'none' ? `, signal ${verdict.signal}` : ''})`
|
|
391
|
+
if (killedByTimer || verdict.outcome === 'killed') {
|
|
392
|
+
ledgerAppend(id, 'killed', summary, verdict.reason, log)
|
|
393
|
+
updateRun(id, n, (cur) => ({ ...(cur ?? current ?? base), ...final, status: 'killed' }))
|
|
394
|
+
process.exit(12)
|
|
395
|
+
}
|
|
396
|
+
const eventType = verdict.outcome === 'limit' ? 'limit_detected'
|
|
397
|
+
: (verdict.outcome === 'auth_failed' || verdict.outcome === 'launch_failed') ? 'error'
|
|
398
|
+
: 'leg_exited'
|
|
399
|
+
ledgerAppend(id, eventType, summary,
|
|
400
|
+
verdict.outcome === 'completed' ? null : `${verdict.reason}${code === 0 ? '' : ` | stderr: ${errTail(errPath)}`}`, log)
|
|
401
|
+
updateRun(id, n, (cur) => ({ ...(cur ?? current ?? base), ...final, status: 'exited' }))
|
|
402
|
+
log(`leg exited code ${code}: ${verdict.outcome} (${verdict.reason})`)
|
|
403
|
+
process.exit(verdict.outcome === 'completed' ? 0 : 13)
|
|
404
|
+
})
|
|
405
|
+
} else if (cmd === 'sweep') {
|
|
406
|
+
const log = (msg) => process.stdout.write(`${msg}\n`)
|
|
407
|
+
const cardsRoot = join(ROOT, 'cards')
|
|
408
|
+
const ids = existsSync(cardsRoot) ? readdirSync(cardsRoot).sort() : []
|
|
409
|
+
let active = 0
|
|
410
|
+
const orphanLines = []
|
|
411
|
+
for (const id of ids) {
|
|
412
|
+
for (const n of listRuns(id)) {
|
|
413
|
+
const r = readRun(id, n)
|
|
414
|
+
if (!r || !ACTIVE.includes(r.status)) continue
|
|
415
|
+
if (pidAlive(r.supervisor_pid)) { active += 1; continue }
|
|
416
|
+
writeRun(id, n, { ...r, status: 'orphaned' })
|
|
417
|
+
ledgerAppend(id, 'error',
|
|
418
|
+
`[sweep] supervisor pid ${r.supervisor_pid} dead; run ${n} orphaned`, null, log)
|
|
419
|
+
const agentAlive = pidAlive(r.agent_pid)
|
|
420
|
+
orphanLines.push(`ORPHANED ${id} run ${n}: supervisor ${r.supervisor_pid} dead; ` +
|
|
421
|
+
(agentAlive
|
|
422
|
+
? `agent pid ${r.agent_pid} STILL ALIVE — consider: taskkill /PID ${r.agent_pid} /T /F`
|
|
423
|
+
: `agent pid ${r.agent_pid ?? 'unknown'} dead`))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const line of orphanLines) log(line)
|
|
427
|
+
if (!orphanLines.length) {
|
|
428
|
+
log(active ? `OK: ${active} active run(s), no orphans` : 'OK: no active runs')
|
|
429
|
+
}
|
|
430
|
+
process.exit(0)
|
|
431
|
+
} else {
|
|
432
|
+
die(2, `unknown command "${cmd ?? ''}" (expected launch|supervise|sweep)`)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const isMain = process.argv[1]
|
|
437
|
+
&& resolve(process.argv[1]).toLowerCase() === SELF.toLowerCase()
|
|
438
|
+
if (isMain) await main()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// scheduler — every tick reads the ledger (card.json files are truth, never
|
|
2
|
+
// in-memory state), starts queued cards whose path leases do not overlap any
|
|
3
|
+
// running card's leases, up to BATON_MAX_CONCURRENT, and records one
|
|
4
|
+
// blocked_by event per blocker change. Cards run inside this process via
|
|
5
|
+
// orchestrator.runCard (async, non-blocking).
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { conflicts } from './leases.mjs'
|
|
9
|
+
import { canonPath } from './fsx.mjs'
|
|
10
|
+
import { runCard, orphanedRun, unsettledRun, driverAlive } from './orchestrator.mjs'
|
|
11
|
+
import { listCards, ledgerAppend, ledgerLog, home, sleep } from './store.mjs'
|
|
12
|
+
|
|
13
|
+
export const MAX_CONCURRENT = Math.max(1, parseInt((process.env.LEG_MAX_CONCURRENT || process.env.BATON_MAX_CONCURRENT) || '2', 10) || 2)
|
|
14
|
+
const ACTIVE = ['running', 'handing_off']
|
|
15
|
+
|
|
16
|
+
// Two cards on one repo compare by the canonical path, so two spellings of a
|
|
17
|
+
// checkout (short name, symlink, case) never look like two repos.
|
|
18
|
+
const repoKey = (c) => { try { return canonPath(c.repo) } catch { return String(c.repo) } }
|
|
19
|
+
const stationKind = (c) => (c.pipeline ?? []).find((s) => s.name === c.station)?.kind
|
|
20
|
+
|
|
21
|
+
// Repos with a land station running right now (by station KIND: a land
|
|
22
|
+
// station can be named anything).
|
|
23
|
+
export function landingRepos(cards) {
|
|
24
|
+
return new Set(cards.filter((c) => c.status === 'running' && stationKind(c) === 'land').map(repoKey))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Pure: which queued cards may start now, and why the others cannot.
|
|
28
|
+
export function pickRunnable(cards, { max = MAX_CONCURRENT, landing = new Set() } = {}) {
|
|
29
|
+
const running = cards.filter((c) => ACTIVE.includes(c.status))
|
|
30
|
+
const queued = cards.filter((c) => c.status === 'queued').sort((a, b) => (a.created_at < b.created_at ? -1 : 1))
|
|
31
|
+
const keys = new Map(cards.map((c) => [c.card_id, repoKey(c)]))
|
|
32
|
+
const landingKeys = new Set([...landing].map((repo) => repoKey({ repo })))
|
|
33
|
+
const start = []
|
|
34
|
+
const blocked = []
|
|
35
|
+
for (const c of queued) {
|
|
36
|
+
if (running.length + start.length >= max) { blocked.push({ card: c, conflicts: [], reason: `concurrency cap ${max}` }); continue }
|
|
37
|
+
// a landing repo only holds back other cards that want to land; build stations keep running
|
|
38
|
+
const wantsLand = stationKind(c) === 'land'
|
|
39
|
+
if (wantsLand && landingKeys.has(keys.get(c.card_id))) { blocked.push({ card: c, conflicts: [], reason: 'repo is landing' }); continue }
|
|
40
|
+
const cf = conflicts({ card_id: c.card_id, leases: c.leases }, [...running, ...start].filter((r) => keys.get(r.card_id) === keys.get(c.card_id)))
|
|
41
|
+
if (cf.length) { blocked.push({ card: c, conflicts: cf, reason: 'lease overlap' }); continue }
|
|
42
|
+
start.push(c)
|
|
43
|
+
}
|
|
44
|
+
return { start, blocked, running }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function pidfile() { return join(home(), 'scheduler.pid') }
|
|
48
|
+
|
|
49
|
+
export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'baton' } } = {}) {
|
|
50
|
+
const state = { blockedKeys: new Map(), inflight: new Map(), stopped: false, ticks: 0 }
|
|
51
|
+
|
|
52
|
+
async function tick() {
|
|
53
|
+
state.ticks += 1
|
|
54
|
+
const cards = listCards()
|
|
55
|
+
const landing = landingRepos(cards)
|
|
56
|
+
const { start, blocked } = pickRunnable(cards, { max, landing })
|
|
57
|
+
for (const b of blocked) {
|
|
58
|
+
const key = b.conflicts.length ? b.conflicts.map((x) => `${x.holder}:${x.lease}`).join(',') : b.reason
|
|
59
|
+
if (state.blockedKeys.get(b.card.card_id) === key) continue
|
|
60
|
+
state.blockedKeys.set(b.card.card_id, key)
|
|
61
|
+
const summary = b.conflicts.length
|
|
62
|
+
? `blocked by ${b.conflicts[0].holder} on ${b.conflicts[0].lease} (against ${b.conflicts[0].against})`
|
|
63
|
+
: `blocked: ${b.reason}`
|
|
64
|
+
ledgerAppend(b.card.card_id, { actor, type: 'blocked_by', summary, station: b.card.station, leg: b.card.leg })
|
|
65
|
+
}
|
|
66
|
+
// Running cards nobody alive is driving (their orchestrator died with the
|
|
67
|
+
// last server) get re-attached so a finished run's verdict is applied, a
|
|
68
|
+
// test or land station left mid-way runs again, and a card that crashed
|
|
69
|
+
// between `start` and its launch gets its leg. A card a live `card run`
|
|
70
|
+
// is driving (its driver.lock names a live pid) is left to that process.
|
|
71
|
+
const reattach = cards.filter((c) => ACTIVE.includes(c.status) && !state.inflight.has(c.card_id) && !driverAlive(c.card_id)
|
|
72
|
+
&& (c.status === 'handing_off' || stationKind(c) !== 'agent' || !unsettledRun(c.card_id) || orphanedRun(c.card_id)))
|
|
73
|
+
for (const c of [...start, ...reattach]) {
|
|
74
|
+
if (state.inflight.has(c.card_id)) continue
|
|
75
|
+
state.blockedKeys.delete(c.card_id)
|
|
76
|
+
const p = runCard(c.card_id, { actor }).catch((err) => {
|
|
77
|
+
try { ledgerAppend(c.card_id, { actor, type: 'error', summary: `orchestrator crashed: ${String(err.message).slice(0, 300)}`, station: c.station, leg: c.leg }) } catch {}
|
|
78
|
+
}).finally(() => state.inflight.delete(c.card_id))
|
|
79
|
+
state.inflight.set(c.card_id, p)
|
|
80
|
+
}
|
|
81
|
+
return { started: start.map((c) => c.card_id), reattached: reattach.map((c) => c.card_id), blocked: blocked.map((b) => b.card.card_id), inflight: [...state.inflight.keys()] }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function run({ ticks = Infinity } = {}) {
|
|
85
|
+
// one scheduler per home: the pidfile is created atomically, and a live
|
|
86
|
+
// holder (the board's in-process scheduler, or a CLI one) wins
|
|
87
|
+
try { writeFileSync(pidfile(), String(process.pid), { flag: 'wx' }) } catch (err) {
|
|
88
|
+
if (err.code !== 'EEXIST') throw err
|
|
89
|
+
const st = schedulerStatus()
|
|
90
|
+
if (st.running && st.pid !== process.pid) throw new Error(`scheduler already running (pid ${st.pid})`)
|
|
91
|
+
writeFileSync(pidfile(), String(process.pid))
|
|
92
|
+
}
|
|
93
|
+
ledgerLog({ actor, type: 'scheduler_started', summary: `scheduler started (max ${max}, pid ${process.pid})` })
|
|
94
|
+
try {
|
|
95
|
+
for (let i = 0; i < ticks && !state.stopped; i++) {
|
|
96
|
+
await tick()
|
|
97
|
+
if (i + 1 < ticks && !state.stopped) await sleep(intervalMs)
|
|
98
|
+
}
|
|
99
|
+
await Promise.allSettled([...state.inflight.values()])
|
|
100
|
+
} finally {
|
|
101
|
+
ledgerLog({ actor, type: 'scheduler_stopped', summary: `scheduler stopped after ${state.ticks} tick(s)` })
|
|
102
|
+
try { if (existsSync(pidfile()) && readFileSync(pidfile(), 'utf8').trim() === String(process.pid)) unlinkSync(pidfile()) } catch {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stop() { state.stopped = true }
|
|
107
|
+
|
|
108
|
+
return { tick, run, stop, state }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function schedulerStatus() {
|
|
112
|
+
const f = pidfile()
|
|
113
|
+
if (!existsSync(f)) return { running: false, pid: null }
|
|
114
|
+
const pid = parseInt(readFileSync(f, 'utf8').trim(), 10)
|
|
115
|
+
let alive = false
|
|
116
|
+
try { process.kill(pid, 0); alive = true } catch {}
|
|
117
|
+
return { running: alive, pid, stale: !alive }
|
|
118
|
+
}
|