diff --git a/docs/guides/minions-deployment.md b/docs/guides/minions-deployment.md index d7115e5d8..dab492bb3 100644 --- a/docs/guides/minions-deployment.md +++ b/docs/guides/minions-deployment.md @@ -93,6 +93,29 @@ usually want both. | **Linux VM with systemd** | Two-layer recommended: systemd supervises `gbrain jobs supervisor`, which in turn supervises `gbrain jobs work`. Buys you automatic restart on reboot (systemd) plus fast crash recovery (supervisor). See [systemd](#systemd). | | **Dev laptop / macOS** | `gbrain jobs supervisor` in a terminal. Ctrl-C stops it. No system-level setup needed. | +### Running alongside autopilot (production deployment shape) + +`gbrain autopilot` and `gbrain jobs supervisor` overlap: by default, +autopilot spawns its own managed worker for the jobs it dispatches. If you +run BOTH a default autopilot and a supervisor on the same brain, you get two +worker lanes claiming from the same queue — double concurrency, double memory, +and two processes competing for the same job locks. Pick one shape: + +| Shape | When | How | +|---|---|---| +| **Split (recommended for production)** | You already run (or want) a platform-supervised worker: systemd, Fly, container. | `gbrain autopilot --no-worker` as the dispatcher + `gbrain jobs supervisor` as the single worker lane. Autopilot submits jobs and probes for peer-worker liveness (it warns loudly after a few cycles if nothing is claiming); the supervisor owns execution, crash recovery, and drain. | +| **All-in-one** | Single machine, nothing else runs workers. | `gbrain autopilot` alone (it manages its own worker child). Do NOT also start a supervisor. | + +Never run a default (worker-spawning) autopilot and a supervisor +side-by-side. If `gbrain jobs stats` shows more active workers than you +expect, this footgun is the first thing to check. + +`gbrain autopilot --install` currently installs the all-in-one shape; for +the split shape, install the supervisor via systemd/your platform (below) +and run autopilot with `--no-worker`. See also +[queue-operations-runbook.md](queue-operations-runbook.md) for the +`--no-worker` liveness probe. + ### Variables used in this guide Substitute these once before copy-pasting any snippet. @@ -302,6 +325,13 @@ silently. The stall detector then dead-letters the job after (since v0.13.1). These write onto the job row at submit time — which is what `handleStalled()` reads — so per-job tuning is the real knob today. +**Tune per-worker.** `--lock-duration MS` on `gbrain jobs work` / +`gbrain jobs supervisor` (env: `GBRAIN_LOCK_DURATION`, flag wins; minimum +1000) raises the 30 s stall-lock window itself. The wall-clock dead-letter +cap for a running job is `lock-duration × max_stalled`, so with the +defaults (30000 × 5 ≈ 180 s wall-clock sweep window) long jobs on flaky +connections benefit from raising either side. + ### DO NOT pass `maxStalledCount` to `MinionWorker` It's a no-op. The stall detector reads the row's `max_stalled` column diff --git a/src/commands/autopilot.ts b/src/commands/autopilot.ts index 82979fb12..203cb4466 100644 --- a/src/commands/autopilot.ts +++ b/src/commands/autopilot.ts @@ -360,7 +360,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) { if (args.includes('--help') || args.includes('-h')) { console.log( 'Usage: gbrain autopilot [--repo ] [--interval N] [--json] [--no-worker]\n' + - ' gbrain autopilot --install [--repo ]\n' + + ' gbrain autopilot --install [--repo ] [--interval N]\n' + ' gbrain autopilot --uninstall\n' + ' gbrain autopilot --status [--json]\n\n' + 'Self-maintaining brain daemon. Runs the full maintenance cycle\n' + @@ -384,7 +384,12 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) { } const repoPath = parseArg(args, '--repo') || await engine.getConfig('sync.repo_path'); - const baseInterval = parseInt(parseArg(args, '--interval') || '300', 10); + // Flag → persisted config (written by --install --interval, #2794) → 300s. + const intervalRaw = parseArg(args, '--interval') + || await engine.getConfig('autopilot.interval') + || '300'; + const intervalParsed = parseInt(intervalRaw, 10); + const baseInterval = Number.isFinite(intervalParsed) && intervalParsed >= 1 ? intervalParsed : 300; const jsonMode = args.includes('--json'); const forceInline = args.includes('--inline'); const noWorker = !shouldSpawnAutopilotWorker(args); @@ -1166,7 +1171,8 @@ function detectOpenClaw(): { detected: boolean; bootstrapCandidates: string[] } return { detected: signal, bootstrapCandidates: existing }; } -function writeWrapperScript(repoPath: string): string { +// Exported for tests (issue #2794: the wrapper must carry --interval). +export function writeWrapperScript(repoPath: string, intervalSeconds?: number): string { const home = process.env.HOME || ''; const gbrainDir = join(home, '.gbrain'); mkdirSync(gbrainDir, { recursive: true }); @@ -1186,7 +1192,7 @@ function writeWrapperScript(repoPath: string): string { # OPENAI/ANTHROPIC keys exported in zshenv reach autopilot. [ -f ~/.zshenv ] && source ~/.zshenv 2>/dev/null source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true -exec '${safeGbrainPath}' autopilot --repo '${safeRepoPath}' +exec '${safeGbrainPath}' autopilot --repo '${safeRepoPath}'${intervalSeconds !== undefined ? ` --interval '${intervalSeconds}'` : ''} `; writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); return wrapperPath; @@ -1205,7 +1211,29 @@ async function installDaemon(engine: BrainEngine, args: string[]) { const injectBootstrap = args.includes('--inject-bootstrap'); const noInject = args.includes('--no-inject'); - const wrapperPath = writeWrapperScript(repoPath); + // issue #2794: --install used to silently drop --interval — the wrapper + // always exec'd bare `autopilot --repo ...` (default 300s). Parse it here, + // persist to config so a later flag-less --install regenerates the wrapper + // with the same tuning, and thread it into the exec line. + const intervalRaw = parseArg(args, '--interval'); + let intervalSeconds: number | undefined; + if (intervalRaw !== undefined) { + const parsed = Number(intervalRaw); + if (!Number.isInteger(parsed) || parsed < 1) { + console.error(`Error: --interval must be a positive integer (seconds), got "${intervalRaw}"`); + process.exit(2); + } + intervalSeconds = parsed; + await engine.setConfig('autopilot.interval', String(parsed)); + } else { + const persisted = await engine.getConfig('autopilot.interval'); + if (persisted) { + const parsed = Number(persisted); + if (Number.isInteger(parsed) && parsed >= 1) intervalSeconds = parsed; + } + } + + const wrapperPath = writeWrapperScript(repoPath, intervalSeconds); const home = process.env.HOME || ''; switch (target) { diff --git a/src/commands/integrations.ts b/src/commands/integrations.ts index 92cda4e20..2de4a36a1 100644 --- a/src/commands/integrations.ts +++ b/src/commands/integrations.ts @@ -707,6 +707,14 @@ async function cmdDoctor(args: string[]): Promise { } } + // Cross-cutting check (PR #1185, @ethanbeard): dead jobs in the minions + // queue. Shell jobs submitted by collectors die silently from the + // per-integration perspective — the collector's drain sees the submit + // succeed and stays green while the worker job dead-letters on the queue + // side, so a whole pipeline can be broken for days before anyone notices. + const deadJobsCheck = checkDeadJobs(); + if (deadJobsCheck) results.push(deadJobsCheck); + if (jsonMode) { const fails = results.filter(r => r.status !== 'ok'); console.log(JSON.stringify({ @@ -726,10 +734,85 @@ async function cmdDoctor(args: string[]): Promise { } } + // Surface cross-cutting [queue] checks below the per-integration sections. + const queueChecks = results.filter(r => r.integration === '[queue]'); + if (queueChecks.length > 0) { + console.log(` [queue]: ${queueChecks.every(c => c.status === 'ok') ? 'OK' : 'ISSUES'}`); + for (const c of queueChecks) { + const icon = c.status === 'ok' ? ' ✓' : c.status === 'timeout' ? ' ⏱' : ' ✗'; + console.log(`${icon} ${c.output}`); + } + } + const totalFails = results.filter(r => r.status !== 'ok').length; console.log(`\n OVERALL: ${totalFails === 0 ? 'All checks passed' : `${totalFails} issue(s) found`}`); } +/** + * Pure aggregation for the dead-jobs queue check (PR #1185 rework): given + * parsed `jobs list --status dead --json` output, count jobs whose + * finished_at falls inside the last 24 hours — parity with the main + * `gbrain doctor` queue checks, so one ancient dead job can't flag + * `[queue]: ISSUES` forever — grouped by job name, biggest first. + * Exported for unit tests. + */ +export function summarizeDeadJobs( + jobs: Array<{ name?: unknown; finished_at?: unknown }>, + now: Date = new Date(), +): { total: number; breakdown: string } { + const cutoff = now.getTime() - 24 * 60 * 60 * 1000; + const byName: Record = {}; + let total = 0; + for (const job of jobs) { + if (typeof job.finished_at !== 'string' && !(job.finished_at instanceof Date)) continue; + const finished = new Date(job.finished_at as string | Date); + if (!Number.isFinite(finished.getTime()) || finished.getTime() < cutoff) continue; + const name = typeof job.name === 'string' ? job.name : 'unknown'; + byName[name] = (byName[name] ?? 0) + 1; + total++; + } + const breakdown = Object.entries(byName) + .sort((a, b) => b[1] - a[1]) + .map(([n, c]) => `${n}:${c}`) + .join(', '); + return { total, breakdown }; +} + +/** + * Dead minion jobs in the last 24h. Returns a CheckResult only when dead + * jobs exist (silent when healthy, per doctor's ISSUES-only model). + * + * Shells out to `gbrain jobs list --json` instead of opening a DB + * connection — preserves this file's standalone-CLI design. The JSON + * surface exists for exactly this: the human table's column widths shift + * with long job names, so screen-scraping it is not an option. + */ +function checkDeadJobs(): CheckResult | null { + try { + // --limit 500 caps memory while comfortably covering a day of failures; + // getJobs orders by created_at DESC so the most recent dead jobs come first. + const out = execSync('gbrain jobs list --status dead --limit 500 --json', { + encoding: 'utf8', + timeout: 10_000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const jobs: unknown = JSON.parse(out); + if (!Array.isArray(jobs)) return null; + const { total, breakdown } = summarizeDeadJobs(jobs); + if (total === 0) return null; + return { + integration: '[queue]', + check: 'dead_jobs', + status: 'fail', + output: `${total} dead job(s) in the last 24h (${breakdown}). Inspect: gbrain jobs list --status dead; details: gbrain jobs get `, + }; + } catch { + // DB unreachable / gbrain not on PATH / non-JSON output — skip silently. + // Doctor still surfaces the per-recipe checks; this one is best-effort. + return null; + } +} + function cmdStats(args: string[]): void { const jsonMode = args.includes('--json'); const recipes = loadAllRecipes(); diff --git a/src/commands/jobs.ts b/src/commands/jobs.ts index 31aa24e14..ce55a680a 100644 --- a/src/commands/jobs.ts +++ b/src/commands/jobs.ts @@ -122,6 +122,27 @@ export function parseNiceFlag(args: string[], env: NodeJS.ProcessEnv = process.e } } +/** Parse `--lock-duration MS` (then `GBRAIN_LOCK_DURATION` env; flag wins). + * Tunes the worker's stall-lock window — the wall-clock dead-letter cap is + * lockDuration × max_stalled, so the default 30s lock caps long jobs at + * ~180s with the schema defaults (issue #1014). Returns undefined when + * absent (worker default 30000ms applies). Throws on non-integer values or + * values < 1000 — sub-1000 is almost always a seconds-vs-ms unit-confusion + * typo (parity with --health-interval). Exported for unit tests; CLI call + * sites wrap with console.error + process.exit(1). */ +export function parseLockDurationFlag(args: string[], env: NodeJS.ProcessEnv = process.env): number | undefined { + const raw = parseFlag(args, '--lock-duration') ?? env.GBRAIN_LOCK_DURATION; + if (raw === undefined || raw === '') return undefined; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1000) { + throw new Error( + `--lock-duration must be an integer >= 1000 (milliseconds), got "${raw}". ` + + `The flag takes milliseconds; for a 60-second lock pass 60000.`, + ); + } + return parsed; +} + export function resolveWorkerConcurrency(args: string[], env: NodeJS.ProcessEnv = process.env): number { const raw = parseFlag(args, '--concurrency') ?? env.GBRAIN_WORKER_CONCURRENCY ?? '1'; const parsed = parseInt(raw, 10); @@ -204,7 +225,7 @@ USAGE [--idempotency-key K] [--queue Q] [--dry-run] [--redact-secrets] (shell only; scrubs inherit values from stdout/stderr) - gbrain jobs list [--status S] [--queue Q] [--limit N] + gbrain jobs list [--status S] [--queue Q] [--limit N] [--json] gbrain jobs get gbrain jobs cancel gbrain jobs retry @@ -213,12 +234,17 @@ USAGE gbrain jobs stats gbrain jobs smoke gbrain jobs work [--queue Q] [--concurrency N] [--max-rss MB] - [--health-interval MS] [--nice N] + [--health-interval MS] [--nice N] [--lock-duration MS] gbrain jobs supervisor [start] [--detach] [--json] [--concurrency N] [--queue Q] [--pid-file PATH] [--max-crashes N] [--health-interval N] [--allow-shell-jobs] [--cli-path PATH] - [--max-rss MB] [--nice N] + [--max-rss MB] [--nice N] [--lock-duration MS] + + --lock-duration MS Worker stall-lock window in milliseconds (default + 30000). The wall-clock dead-letter cap for a running job is + lock-duration × max_stalled, so raise this for long-running + handlers. Minimum 1000. Env: GBRAIN_LOCK_DURATION (flag wins). --nice N OS scheduling priority, -20 (highest) to 19 (nicest). Lowers CPU priority without cutting concurrency — full throughput when the @@ -484,6 +510,7 @@ HANDLER TYPES (built in) const status = parseFlag(args, '--status') as MinionJobStatus | undefined; const queueName = parseFlag(args, '--queue'); const limit = parseInt(parseFlag(args, '--limit') ?? '20', 10); + const jsonMode = hasFlag(args, '--json'); // v0.32: thin-client routing. The `list_jobs` MCP op is admin-scoped // but not localOnly, so a thin-client install with admin access can @@ -503,6 +530,14 @@ HANDLER TYPES (built in) jobs = await queue.getJobs({ status, queue: queueName, limit }); } + // --json: machine-readable output (one JSON array on stdout) so + // downstream tooling (e.g. `gbrain integrations doctor`) never + // screen-scrapes the human table, whose column widths shift. + if (jsonMode) { + console.log(JSON.stringify(jobs)); + return; + } + if (jobs.length === 0) { console.log('No jobs found.'); return; @@ -945,6 +980,16 @@ HANDLER TYPES (built in) healthCheckInterval = parsed; } + // --lock-duration MS (issue #1014): tune the stall-lock window (and so + // the lockDuration × max_stalled wall-clock dead-letter cap). + let lockDuration: number | undefined; + try { + lockDuration = parseLockDurationFlag(args); + } catch (e) { + console.error(`Error: ${e instanceof Error ? e.message : String(e)}`); + process.exit(1); + } + // --nice N (issue #1815): renice this worker process so background work // yields CPU to foreground tasks without sacrificing concurrency. Applied // at the CLI layer (worker.ts stays embeddable). Niceness inherits to the @@ -966,6 +1011,7 @@ HANDLER TYPES (built in) const worker = new MinionWorker(engine, { queue: queueName, concurrency, maxRssMb, healthCheckInterval, + ...(lockDuration !== undefined ? { lockDuration } : {}), }); await registerBuiltinHandlers(worker, engine); @@ -1006,7 +1052,8 @@ HANDLER TYPES (built in) : `, health-check: ${Math.round(healthCheckInterval / 1000)}s`) : ''; const niceNote = niceResult ? `, nice: ${formatNice(niceResult.effective ?? niceVal!)}` : ''; - console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote}${healthNote}${niceNote})`); + const lockNote = lockDuration !== undefined ? `, lock: ${lockDuration}ms` : ''; + console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote}${healthNote}${niceNote}${lockNote})`); console.log(`Registered handlers: ${worker.registeredNames.join(', ')}`); // Register in the live worker registry (issue #1815) so jobs stats / doctor @@ -1258,6 +1305,15 @@ HANDLER TYPES (built in) } healthInterval = parsed; } + // --lock-duration (supervisor): validated here (fail-fast even for + // --detach), passed down to the spawned worker via buildWorkerArgs. + let supLockDuration: number | undefined; + try { + supLockDuration = parseLockDurationFlag(args); + } catch (e) { + console.error(`Error: ${e instanceof Error ? e.message : String(e)}`); + process.exit(1); + } const allowShellJobs = hasFlag(args, '--allow-shell-jobs') || !!process.env.GBRAIN_ALLOW_SHELL_JOBS; const detach = hasFlag(args, '--detach'); @@ -1325,6 +1381,7 @@ HANDLER TYPES (built in) allowShellJobs, json: jsonMode, maxRssMb, + ...(supLockDuration !== undefined ? { lockDuration: supLockDuration } : {}), ...(supNice !== undefined ? { nice_requested: supNice } : {}), ...(supNiceResult?.effective != null ? { nice_effective: supNiceResult.effective } : {}), ...(supNiceResult?.error ? { nice_error: supNiceResult.error } : {}), diff --git a/src/core/minions/supervisor.ts b/src/core/minions/supervisor.ts index 9a71355d1..9ffc3a96c 100644 --- a/src/core/minions/supervisor.ts +++ b/src/core/minions/supervisor.ts @@ -87,6 +87,10 @@ export interface SupervisorOpts { * resolveDefaultMaxRssMb() (issue #1678) instead of a flat default. * Set to 0 to spawn the worker without a watchdog. */ maxRssMb: number; + /** Worker stall-lock window in ms (issue #1014), passed to the spawned + * worker as `--lock-duration N`. The wall-clock dead-letter cap is + * lockDuration × max_stalled. Undefined → worker default (30000ms). */ + lockDuration?: number; /** Niceness (issue #1815) the operator requested via `--nice` / `GBRAIN_NICE`, * or undefined to inherit. When set, the worker is spawned with `--nice N` so * it re-applies the value (the supervisor itself is reniced by the CLI layer, @@ -172,7 +176,7 @@ const DEFAULTS: Omit = { * niceness also inherits to the worker's own children automatically. */ export function buildWorkerArgs( - opts: Pick, + opts: Pick, ): string[] { const args = [ 'jobs', 'work', @@ -185,6 +189,9 @@ export function buildWorkerArgs( if (opts.nice_requested !== undefined) { args.push('--nice', String(opts.nice_requested)); } + if (opts.lockDuration !== undefined) { + args.push('--lock-duration', String(opts.lockDuration)); + } return args; } diff --git a/test/autopilot-install.test.ts b/test/autopilot-install.test.ts index 023b03d7d..a5543d8ac 100644 --- a/test/autopilot-install.test.ts +++ b/test/autopilot-install.test.ts @@ -20,7 +20,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync import { join } from 'path'; import { tmpdir } from 'os'; -import { detectInstallTarget } from '../src/commands/autopilot.ts'; +import { detectInstallTarget, writeWrapperScript } from '../src/commands/autopilot.ts'; let tmp: string; const envSnapshot: Record = {}; @@ -85,6 +85,23 @@ describe('detectInstallTarget', () => { // exported in zshrc never reach the LaunchAgent subprocess. Operators who // exported GBRAIN_DATABASE_URL or {OPENAI,ANTHROPIC}_API_KEY in zshrc and // expected autopilot to inherit them hit silent missing-secret failures. +// issue #2794: `--install --interval N` used to be silently dropped — the +// wrapper always exec'd bare `autopilot --repo ...` (default 300s). The +// wrapper's exec line must carry the interval when one is known. +describe('autopilot wrapper script — --interval threading (#2794)', () => { + test('exec line carries --interval when provided', () => { + const wrapperPath = writeWrapperScript('/tmp/some repo', 600); + const script = readFileSync(wrapperPath, 'utf8'); + expect(script).toContain(`--repo '/tmp/some repo' --interval '600'`); + }); + + test('exec line omits --interval when not provided (default applies)', () => { + const wrapperPath = writeWrapperScript('/tmp/some-repo'); + const script = readFileSync(wrapperPath, 'utf8'); + expect(script).not.toContain('--interval'); + }); +}); + describe('autopilot wrapper script — env source order (v0.36.1.x #966)', () => { test('wrapper sources ~/.zshenv before ~/.zshrc', async () => { const { readFileSync } = await import('fs'); diff --git a/test/integrations-dead-jobs.test.ts b/test/integrations-dead-jobs.test.ts new file mode 100644 index 000000000..1774de3b7 --- /dev/null +++ b/test/integrations-dead-jobs.test.ts @@ -0,0 +1,72 @@ +/** + * Unit tests for summarizeDeadJobs (PR #1185 rework) — the dead-jobs queue + * check behind `gbrain integrations doctor`. The check must: + * - count only jobs that dead-lettered in the last 24h (one ancient dead + * job must NOT flag [queue]: ISSUES forever — parity with the main + * `gbrain doctor` queue checks), and + * - consume machine-readable `jobs list --json` output, never the human + * table (long job names shift the column widths). + */ + +import { describe, test, expect } from 'bun:test'; +import { summarizeDeadJobs } from '../src/commands/integrations.ts'; + +const NOW = new Date('2026-07-21T12:00:00Z'); +const HOUR = 60 * 60 * 1000; + +function job(name: string, finishedAgoMs: number | null) { + return { + name, + finished_at: finishedAgoMs === null ? null : new Date(NOW.getTime() - finishedAgoMs).toISOString(), + }; +} + +describe('summarizeDeadJobs', () => { + test('counts recent dead jobs grouped by name, biggest first', () => { + const { total, breakdown } = summarizeDeadJobs([ + job('email-collector', 1 * HOUR), + job('email-collector', 2 * HOUR), + job('signal-detect', 3 * HOUR), + ], NOW); + expect(total).toBe(3); + expect(breakdown).toBe('email-collector:2, signal-detect:1'); + }); + + test('ignores dead jobs older than 24h (no ISSUES-forever)', () => { + const { total } = summarizeDeadJobs([ + job('autopilot-cycle', 25 * HOUR), + job('autopilot-cycle', 30 * 24 * HOUR), + ], NOW); + expect(total).toBe(0); + }); + + test('mixes windows correctly', () => { + const { total, breakdown } = summarizeDeadJobs([ + job('subagent', 23 * HOUR), + job('subagent', 25 * HOUR), + ], NOW); + expect(total).toBe(1); + expect(breakdown).toBe('subagent:1'); + }); + + test('tolerates missing / null / malformed finished_at', () => { + const { total } = summarizeDeadJobs([ + job('shell', null), + { name: 'shell' }, + { name: 'shell', finished_at: 'not-a-date' }, + ], NOW); + expect(total).toBe(0); + }); + + test('handles long job names (the human table screen-scrape broke here)', () => { + // 'autopilot-cycle' is >14 chars and breaks the padEnd(14) column + // alignment in formatJob — the old regex scrape missed these rows. + const { total, breakdown } = summarizeDeadJobs([job('autopilot-cycle', HOUR)], NOW); + expect(total).toBe(1); + expect(breakdown).toBe('autopilot-cycle:1'); + }); + + test('empty array → zero', () => { + expect(summarizeDeadJobs([], NOW).total).toBe(0); + }); +}); diff --git a/test/jobs-lock-duration-flag.test.ts b/test/jobs-lock-duration-flag.test.ts new file mode 100644 index 000000000..5be0938dc --- /dev/null +++ b/test/jobs-lock-duration-flag.test.ts @@ -0,0 +1,42 @@ +/** + * Unit tests for parseLockDurationFlag (issue #1014) — + * flag > GBRAIN_LOCK_DURATION env > undefined (worker default 30000ms). + */ + +import { describe, test, expect } from 'bun:test'; +import { parseLockDurationFlag } from '../src/commands/jobs.ts'; + +describe('parseLockDurationFlag', () => { + test('returns undefined when absent (no flag, no env)', () => { + expect(parseLockDurationFlag(['jobs', 'work'], {})).toBeUndefined(); + }); + + test('reads the --lock-duration flag', () => { + expect(parseLockDurationFlag(['jobs', 'work', '--lock-duration', '120000'], {})).toBe(120000); + }); + + test('falls back to GBRAIN_LOCK_DURATION env', () => { + expect(parseLockDurationFlag(['jobs', 'work'], { GBRAIN_LOCK_DURATION: '60000' })).toBe(60000); + }); + + test('flag wins over env', () => { + expect(parseLockDurationFlag( + ['jobs', 'work', '--lock-duration', '45000'], + { GBRAIN_LOCK_DURATION: '60000' }, + )).toBe(45000); + }); + + test('empty env string is treated as absent', () => { + expect(parseLockDurationFlag(['jobs', 'work'], { GBRAIN_LOCK_DURATION: '' })).toBeUndefined(); + }); + + test('rejects sub-1000ms values (seconds-vs-ms unit confusion)', () => { + expect(() => parseLockDurationFlag(['jobs', 'work', '--lock-duration', '30'], {})).toThrow(/milliseconds/); + }); + + test('rejects non-integer / garbage values', () => { + expect(() => parseLockDurationFlag(['jobs', 'work', '--lock-duration', 'abc'], {})).toThrow(); + expect(() => parseLockDurationFlag(['jobs', 'work', '--lock-duration', '1500.5'], {})).toThrow(); + expect(() => parseLockDurationFlag(['jobs', 'work', '--lock-duration', '-1'], {})).toThrow(); + }); +}); diff --git a/test/supervisor-build-worker-args.test.ts b/test/supervisor-build-worker-args.test.ts index c6b72a2a2..e0831a3f0 100644 --- a/test/supervisor-build-worker-args.test.ts +++ b/test/supervisor-build-worker-args.test.ts @@ -36,4 +36,15 @@ describe('buildWorkerArgs', () => { expect(buildWorkerArgs({ concurrency: 1, queue: 'q', maxRssMb: 0 })) .not.toContain('--nice'); }); + + // issue #1014: --lock-duration propagates supervisor → worker child. + test('appends --lock-duration when lockDuration is set', () => { + expect(buildWorkerArgs({ concurrency: 2, queue: 'default', maxRssMb: 0, lockDuration: 120000 })) + .toEqual(['jobs', 'work', '--concurrency', '2', '--queue', 'default', '--lock-duration', '120000']); + }); + + test('omits --lock-duration when undefined (worker default 30000ms)', () => { + expect(buildWorkerArgs({ concurrency: 1, queue: 'q', maxRssMb: 0 })) + .not.toContain('--lock-duration'); + }); });