Files
gbrain/skills/migrations/v0.14.0.md
Garry Tan 5fd9cd2644 feat: shell job type + worker abort-path fix (v0.13.0) (#217)
* feat(minions): add protected-name constant + ctx.shutdownSignal

Introduce PROTECTED_JOB_NAMES ('shell') in a side-effect-free core module
so queue.ts can check it without importing from handlers/. MinionJobContext
gains shutdownSignal (distinct from signal) — handlers that need to run
SIGTERM-triggered cleanup subscribe to both; most handlers ignore shutdown
and run through the worker's 30s cleanup race to natural completion.

* fix(minions): MinionQueue.add gains trusted 4th arg + trim-normalized guard

Adds allowProtectedSubmit opt-in as a separate 4th parameter (NOT folded into
opts) so callers spreading user-provided opts ({...userOpts}) can't accidentally
carry the trust flag. PROTECTED_JOB_NAMES check runs on the trimmed name BEFORE
insert, closing the queue.add(' shell ', ...) whitespace bypass that would have
evaded a has(name) check.

* fix(minions): worker calls failJob on abort + wires ctx.shutdownSignal

Pre-v0.13.0 worker returned silently when ctx.signal.aborted fired, leaving
jobs in 'active' until stall sweep. Handlers using cooperative cancel had
no deterministic status flip — timeout/cancel/lock-loss all looked the same
from downstream callers (gbrain jobs get, --follow loops).

Fix: derive abort reason from abort.signal.reason ('timeout' | 'cancel' |
'lock-lost' | 'shutdown') and call failJob with 'aborted: <reason>' text.
failJob is idempotent via token+status match, so no-op when another path
already flipped status (handleTimeouts, cancelJob, stall).

Also: new shutdownAbort (instance-level AbortController) fires on process
SIGTERM/SIGINT and propagates to every handler's ctx.shutdownSignal.
Shell handler listens to both signals and runs SIGTERM→5s→SIGKILL on its
child on either; other handlers only listen to ctx.signal so deploy
restarts don't cancel them mid-flight.

* feat(minions): add shell job handler + submission audit log

New 'shell' job type spawns arbitrary commands under the Minions worker.
Deterministic cron scripts (API fetch, token refresh, scrape+write) can
move off the LLM gateway — zero Opus tokens per fire.

Handler contract:
- cmd or argv (exactly one required). cmd spawns via /bin/sh -c (absolute
  path, not 'sh', to block PATH-override shell substitution). argv spawns
  direct with no shell.
- cwd required, must be absolute. Operator-trust boundary.
- env defaults to SHELL_ENV_ALLOWLIST ({PATH, HOME, USER, LANG, TZ,
  NODE_ENV}) picked from process.env, with caller overrides merged on top.
  Prevents accidental $OPENAI_API_KEY interpolation into scripts.
- stdout/stderr retained as UTF-8-safe tails (64KB/16KB) via
  string_decoder.StringDecoder. Prepends [truncated N bytes] marker.
- Abort (either ctx.signal or ctx.shutdownSignal) fires SIGTERM → 5s grace
  → SIGKILL on child. Timer NOT .unref'd so worker's 30s race waits for
  the child to actually die.

shell-audit.ts writes a JSONL line per submission to
~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl (ISO-week rotated, override via
GBRAIN_AUDIT_DIR). argv logged as JSON array (not space-joined, which would
flatten args with spaces). Never logs env values. Best-effort writes:
failures log to stderr but don't block submission.

* feat(jobs): submit_job MCP guard + CLI --timeout-ms + starvation warning

submit_job operation gains timeout_ms param (was missing — couldn't plumb
the existing MinionJobInput field through from either CLI or MCP). When
ctx.remote=true and name is in PROTECTED_JOB_NAMES, throws
OperationError('permission_denied'). Combined with the queue.add trusted
guard, MCP callers can never submit shell jobs even if the env flag is on.

CLI submit: new --timeout-ms N flag. Passes {allowProtectedSubmit:true}
as the 4th arg to queue.add only when the submitted name is protected
(not blanket-set for every job). Prints a starvation-warning block to
stderr when a shell job is submitted without --follow, pointing at both
--follow and 'gbrain jobs work' remediation. Fires for every shell submit
regardless of the submitter's env — the submitter env is a weak proxy for
the worker env.

Worker handler registration: conditional on GBRAIN_ALLOW_SHELL_JOBS=1.
Default: off. 'gbrain jobs submit --help' now lists handler types with a
pointer to docs/guides/minions-shell-jobs.md for shell.

* test(minions): 40 unit + 4 E2E cases for shell handler

Unit (test/minions-shell.test.ts):
- Protected names: trim-normalized, case-sensitive, whitespace bypass defense
- MinionQueue.add: trusted opt-in, whitespace bypass, non-protected untouched
- Handler validation: cmd|argv exclusive, cwd required/absolute, env strings
- Spawn: cmd/argv happy paths, non-zero exit, ENOENT, result shape
- Env allowlist: leaked-secret blocked, PATH inherited, caller override
- Abort: ctx.signal, ctx.shutdownSignal, pre-aborted signal
- Audit: ISO-week year boundary (2027-01-01 → W53 2026), mid-year W52/W53,
  GBRAIN_AUDIT_DIR override, argv as JSON array, env never logged, EACCES
  non-blocking
- Output truncation: 100KB → last 64KB with [truncated N bytes] marker

E2E (test/e2e/minions-shell.test.ts):
- Full lifecycle: submit → worker claim → spawn → complete
- MinionQueue.add without trusted arg throws (including whitespace bypass)
- submit_job with ctx.remote=true rejects shell (MCP guard)
- submit_job with ctx.remote=false allows shell (CLI path)

* chore: bump version and changelog (v0.13.0)

Move gateway crons to Minions. Zero LLM tokens per cron fire.
Worker abort path finally marks aborted jobs dead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: reframe v0.13.0 copy for OpenClaw operators (not Wintermute-specific)

gbrain is an open-source product for any OpenClaw/Hermes operator, not
Garry's personal Wintermute deployment. Rewords the v0.13.0 CHANGELOG
entry, the minions-shell-jobs guide, and the deferred TODOS entries to
speak to "your OpenClaw" / "OpenClaw operators" instead.

Replaces /data/wintermute cwd examples with the canonical
/data/.openclaw/workspace path. Pre-existing Wintermute references in
older CHANGELOG entries (v0.11/v0.10.3) left unchanged.

* feat(migrations): add v0.13.0 adoption playbook for shell jobs

Adding the migration file the CEO review originally scoped out. Without
it, operators upgrade to v0.13.0 and the capability ships but adoption
doesn't happen — the 60% gateway CPU reduction only lands if someone
actually rewrites their crontab.

skills/migrations/v0.13.0.md is the instruction manual the host agent
reads on gbrain upgrade:

- Enable worker: GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work (Postgres)
  or per-tick --follow (PGLite)
- Audit cron manifest: classify LLM-requiring vs deterministic
- Propose per-cron rewrites with diffs, approved one at a time
- Env allowlist guidance for scripts that need API keys
- Verification playbook: run one fire, compare pre/post, only then
  approve the next batch
- Starvation sanity-check runbook item

Iron rules: never auto-rewrite the operator's crontab (host-specific
code per CLAUDE.md). LLM-requiring crons stay on the gateway. Ambiguous
cases ask the operator.

No mechanical orchestrator ships with this migration — every rewrite
is operator judgment. A future gbrain crontab-to-minions helper is
tracked in TODOS.md as P1.

* docs: sync UPGRADING + SKILLPACK with v0.13.0 shell jobs

UPGRADING_DOWNSTREAM_AGENTS.md: append v0.13.0 section per the file's
convention (each release appends). No skill edits required, feature is
off-by-default, optional adoption via skills/migrations/v0.13.0.md.
Lists typical LLM-vs-deterministic classifications so operators know
which of their crons are candidates for migration.

GBRAIN_SKILLPACK.md: add shell-jobs guide row to the cron/Minions guide
table so it's discoverable alongside existing Cron via Minions, Plugin
Handlers, and Minions fix guides.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:54:31 +08:00

7.4 KiB

version, feature_pitch
version feature_pitch
0.14.0
headline description recipe tiers
Move deterministic crons off the LLM gateway. Zero tokens per fire. OpenClaw operators: your gateway pins at 100% CPU because most cron jobs boot a full Opus session to do deterministic work (API fetch, token refresh, scrape + markdown write) that doesn't need reasoning. v0.14.0 adds a `shell` job type to Minions so those jobs run as child processes under the existing Minions worker. ~60% gateway CPU reduction at typical scale. Retry, backoff, DLQ, unified `gbrain jobs list` visibility, all free. The LLM-reasoning crons stay on the gateway where they belong. docs/guides/minions-shell-jobs.md null

v0.14.0 Migration: Adopt the shell job type

Audience: host agents (OpenClaw deployments, Hermes operators, future hosts) reading this AFTER gbrain apply-migrations has run. There is no mechanical orchestrator for this release — every change described here is host-specific and requires operator judgment per cron. Shell jobs are off by default on upgrade (nothing breaks). This file walks the host agent through enabling and adopting them.

Iron rules

  1. Never auto-rewrite the operator's crontab. Crontab entries are host-specific code per the CLAUDE.md "host-specific code" exception. Every rewrite is an explicit human approval, per cron, with a diff.
  2. LLM-requiring crons stay on the gateway. If the cron's work includes reasoning (sentiment classification, triage, synthesis, prose generation), it belongs on the gateway. Moving it to a shell job throws away the LLM session the cron needs.
  3. Deterministic crons are the only candidates. API fetch, token refresh, database read + markdown write, HTTP refresh call, scrape. If the script is node scripts/x.mjs or curl ... | jq or similar and does zero LLM work, it's a candidate.

Step 1: Enable the worker

Pick the engine the operator is on:

Postgres (most OpenClaw/Hermes deployments):

# In the worker bootstrap, export the env flag and run the daemon:
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work

The worker claims shell jobs from the queue and executes them. Retries, backoff, and dead-letter all work the same as sync/embed jobs.

PGLite: no persistent worker, per-tick inline execution only:

# Every crontab invocation must use --follow; PGLite's worker daemon
# exits immediately due to exclusive file lock.
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
  --params '{"cmd":"...","cwd":"..."}' --follow

Step 2: Audit the operator's cron manifest

Read the operator's cron manifest. Typical locations:

  • ~/.claude/cron/jobs.json (OpenClaw)
  • scripts/service-manager.sh in the host repo
  • System crontab (crontab -l)

For each entry, classify:

Pattern Class Action
agentTurn <skill> or any OpenClaw-dispatched LLM skill LLM-requiring Leave as-is. Needs gateway.
node scripts/*.mjs that hits an API and writes markdown Deterministic Propose shell-job rewrite.
Token refresh (ycli token-refresh, x-oauth2-refresh) Deterministic Propose shell-job rewrite.
Scrape + write (frameio-scan, flight-tracker) Deterministic Propose shell-job rewrite.
Audio transcription or any LLM-dependent extract LLM-requiring Leave as-is.
bash wrapper scripts that may call LLM tools internally Ambiguous Ask the operator. Don't assume.

Step 3: Propose rewrites per cron

For each deterministic cron, propose the exact rewrite with a diff. Show the operator both sides. Let them approve per-cron, not in bulk.

Before (LLM gateway):

OpenClaw cron: x-garrytan-unified, 3 13,16,19,22,1,4,7,10 * * *
  → runs agentTurn x-garrytan-unified
  → boots Opus context, invokes script, returns

After (Minions worker):

3 13,16,19,22,1,4,7,10 * * * \
  gbrain jobs submit shell \
    --params '{"cmd":"node /data/.openclaw/workspace/scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
    --max-attempts 3 --timeout-ms 300000

Rewrite rules:

  • cwd is required and must be an absolute path. Operator picks it. It should be the directory the script expects to run in (the host repo root, typically).
  • --max-attempts 3 matches the default Minions retry policy. Override if the script is non-idempotent and should only run once per fire.
  • --timeout-ms N caps the child's wall-clock runtime. Set to the 95th percentile of the script's observed runtime, plus slack. Examples: token refresh → 30s; API fetch → 300s; scrape → 600s.
  • PGLite operators: add --follow to every line. Skip Step 1.

Step 4: Secrets that the script needs

Shell jobs receive a minimal env allowlist by default: PATH, HOME, USER, LANG, TZ, NODE_ENV. They do NOT inherit OPENAI_API_KEY, ANTHROPIC_API_KEY, DATABASE_URL, or any other worker env vars.

If a cron's script needs an API key, name it explicitly:

gbrain jobs submit shell \
  --params '{"cmd":"node scripts/yc-sync.mjs","cwd":"/data/.openclaw/workspace","env":{"YC_API_TOKEN":"'"$YC_API_TOKEN"'"}}'

The shell expands $YC_API_TOKEN at submit time. The worker receives the JSON with the literal token value. Audit log does not log env values (keys don't carry sensitive data; values never appear).

Step 5: Verify the first migrated cron

After rewriting ONE cron with the operator's approval:

  1. Wait for the next scheduled fire (or trigger manually: gbrain jobs submit shell --params '...' --follow).
  2. Check gbrain jobs list --status completed --name shell --limit 5 for the result.
  3. gbrain jobs get <id> shows exit_code, stdout_tail, stderr_tail, duration_ms.
  4. Compare against the pre-migration behavior: did it do the same work? Same output files changed? Same side effects?

Only after one cron is verified working end-to-end should the operator approve the next batch.

Step 6: Starvation sanity check

If the operator submits shell jobs but forgot to set GBRAIN_ALLOW_SHELL_JOBS=1 on the worker, jobs sit in waiting indefinitely. The CLI warns on submission, but for daemon-style deployments the warning scrolls past. Add this to the operator's ops-check runbook:

gbrain jobs list --status waiting --name shell

If rows pile up here, either (a) no worker has the env flag set, or (b) the worker crashed. Fix by restarting with the flag.

Non-goals (explicitly deferred to later releases)

  • Automatic crontab rewrites. Deferred to a future gbrain crontab-to-minions <file> helper. P1 in TODOS.md.
  • DB-backed scheduler. minion_schedules table replaces host crontab entirely. P1 in TODOS.md.
  • Orphaned-shell-job stats. gbrain jobs stats --orphaned would surface the "no worker with env flag" case. P2 in TODOS.md.
  • Configurable buffer sizes. Output tails are fixed at 64KB stdout / 16KB stderr. P2 in TODOS.md.

When to stop

The migration is done when:

  1. The worker runs with GBRAIN_ALLOW_SHELL_JOBS=1 (Postgres) or every cron uses --follow (PGLite).
  2. Every deterministic cron the operator approved has been rewritten.
  3. The operator has verified at least one full cron fire cycle end-to-end and confirmed the output matches pre-migration.
  4. gbrain jobs stats shows shell jobs completing at expected rates with few or zero retries.

Gateway CPU should visibly drop after the first few rewrites. That's the signal the adoption is working.