mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
fix(jobs/autopilot): interval persistence, --lock-duration flag, dead-jobs doctor check, deployment-shape docs
Four backlog items in the jobs/autopilot workers, locks & installers area: - #2794: `gbrain autopilot --install` silently dropped `--interval`. The installer now parses + validates it, persists it to config (autopilot.interval), and threads it into the wrapper's exec line; a later flag-less --install regenerates the wrapper from the persisted value, and the daemon run path falls back to the same config key. - #1014: new `--lock-duration MS` flag (env: GBRAIN_LOCK_DURATION) on `gbrain jobs work` and `gbrain jobs supervisor` to tune the worker stall-lock window (and so the lockDuration x max_stalled wall-clock dead-letter cap). Validated like --health-interval (integer >= 1000ms); the supervisor propagates it to the spawned worker via buildWorkerArgs; shown in the worker startup banner. - Takeover of PR #1185 (@ethanbeard): `gbrain integrations doctor` now surfaces dead minion jobs as a cross-cutting [queue] check. Reworked from the original: consumes a new machine-readable `gbrain jobs list --json` surface instead of screen-scraping the human table (long job names shift the columns), and scopes to a 24h finished_at window so one ancient dead job can't flag ISSUES forever (parity with main doctor's queue checks). - #631: documented the production deployment shape for autopilot vs jobs supervisor in docs/guides/minions-deployment.md — recommend the `autopilot --no-worker` + `jobs supervisor` split, warn against running both worker lanes, and cross-link the --no-worker liveness probe. Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
ethanbeard
Claude Fable 5
parent
0612b0daa8
commit
8a8f4d737b
@@ -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
|
||||
|
||||
@@ -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 <path>] [--interval N] [--json] [--no-worker]\n' +
|
||||
' gbrain autopilot --install [--repo <path>]\n' +
|
||||
' gbrain autopilot --install [--repo <path>] [--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) {
|
||||
|
||||
@@ -707,6 +707,14 @@ async function cmdDoctor(args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string, number> = {};
|
||||
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 <id>`,
|
||||
};
|
||||
} 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();
|
||||
|
||||
+61
-4
@@ -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 <id>
|
||||
gbrain jobs cancel <id>
|
||||
gbrain jobs retry <id>
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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<SupervisorOpts, 'cliPath'> = {
|
||||
* niceness also inherits to the worker's own children automatically.
|
||||
*/
|
||||
export function buildWorkerArgs(
|
||||
opts: Pick<SupervisorOpts, 'concurrency' | 'queue' | 'maxRssMb' | 'nice_requested'>,
|
||||
opts: Pick<SupervisorOpts, 'concurrency' | 'queue' | 'maxRssMb' | 'nice_requested' | 'lockDuration'>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string | undefined> = {};
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user