diff --git a/CHANGELOG.md b/CHANGELOG.md index 894216583..5524c18ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to GBrain will be documented in this file. +## [0.22.10] - 2026-04-30 + +**`gbrain jobs submit autopilot-cycle --params '{"phases":["lint","backlinks"]}'` now actually runs only those phases.** + +If you ever submitted an `autopilot-cycle` job with a `phases:` array hoping to skip embed for a fast cycle, you got the full 6-phase cycle anyway. The handler in `src/commands/jobs.ts` was calling `runCycle(...)` without forwarding `job.data.phases`, so per-cycle phase selection was silently ignored. + +This release wires the array through. The handler imports `ALL_PHASES` from `src/core/cycle.ts`, builds a `Set` for O(1) validation, and filters the caller's `phases` array against it before forwarding to `runCycle`. Invalid phase names get dropped (no injection surface — `ALL_PHASES` is the authoritative list). Empty arrays and non-array values fall back to the default (run all phases), preserving the prior behavior for callers who didn't ask for selective phases. + +### What this means for you + +If you've been using `gbrain jobs submit autopilot-cycle --params '{"phases":[...]}'` for triage cycles (e.g. `["lint","backlinks"]` for a fast structural sweep, skipping the slow embed phase), you'll now see those cycles take seconds instead of minutes. The CLI surface didn't change — only the worker's handler now respects the `phases` it was already accepting. + +### Itemized changes + +#### Fixed + +- `autopilot-cycle` minion handler in `src/commands/jobs.ts` now forwards `job.data.phases` to `runCycle()`. Previously the handler accepted the array via `MinionJobInput.params` but discarded it before dispatch. +- Phase names validated against `ALL_PHASES` from `src/core/cycle.ts`. Filter is exhaustive: array → filtered, non-array → undefined (default), filtered-to-empty → no `phases` key in opts (also default). + +#### Tests + +- 4 new test cases in `test/handlers.test.ts` under `autopilot-cycle handler — phase passthrough`: valid phases forwarded, invalid names filtered, empty array falls back to all-phases, non-array `phases` value ignored. Pin both the contract and the fallback semantics. +- `test/cycle-abort.test.ts` regression-guard window widened from 500 → 2000 chars so the source-level `signal: job.signal` check finds the line after the new validation block was added between `worker.register('autopilot-cycle', ...)` and the `runCycle(...)` call. Pure test fix; the handler still propagates the abort signal correctly. + ## [0.22.9] - 2026-04-29 **Sync failures now tell you why, not just how many.** diff --git a/CLAUDE.md b/CLAUDE.md index 736fb0c2b..a17e7cbba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ strict behavior when unset. - `src/core/minions/attachments.ts` — Attachment validation (path traversal, null byte, oversize, base64, duplicate detection) - `src/commands/agent.ts` (v0.16) — `gbrain agent run [flags]` CLI. Submits `subagent` (or N children + 1 aggregator) under `{allowProtectedSubmit: true}`. Single-entry `--fanout-manifest` short-circuits. Children get `on_child_fail: 'continue'` + `max_stalled: 3`. `--follow` is the default on TTY; streams logs + polls `waitForCompletion` in parallel. Ctrl-C detaches, does not cancel. - `src/commands/agent-logs.ts` (v0.16) — `gbrain agent logs [--follow] [--since]`. Merges JSONL heartbeat audit + `subagent_messages` into a chronological timeline. `parseSince` accepts ISO-8601 or relative (`5m`, `1h`, `2d`). Transcript tail renders only for terminal jobs. -- `src/commands/jobs.ts` — `gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern). +- `src/commands/jobs.ts` — `gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern). v0.22.10 (#521): the `autopilot-cycle` handler now forwards `job.data.phases` to `runCycle` (was previously discarded — caller-supplied phase selection silently became a full cycle). Phases are validated against `ALL_PHASES` from `src/core/cycle.ts`; invalid names are filtered out and an empty/missing array falls back to the default 6-phase cycle. - `src/commands/features.ts` — `gbrain features --json --auto-fix`: usage scan + feature adoption salesman - `src/commands/autopilot.ts` — `gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed) - `src/mcp/server.ts` — MCP stdio server (generated from operations). v0.22.7: tool-call handler delegates to `dispatchToolCall` from `src/mcp/dispatch.ts` so stdio + HTTP transports share one validation, context-build, and error-format path. diff --git a/VERSION b/VERSION index 6b9f278d0..0eda0deba 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.9 +0.22.10 diff --git a/llms-full.txt b/llms-full.txt index fd54666ff..e524f8c78 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -167,7 +167,7 @@ strict behavior when unset. - `src/core/minions/attachments.ts` — Attachment validation (path traversal, null byte, oversize, base64, duplicate detection) - `src/commands/agent.ts` (v0.16) — `gbrain agent run [flags]` CLI. Submits `subagent` (or N children + 1 aggregator) under `{allowProtectedSubmit: true}`. Single-entry `--fanout-manifest` short-circuits. Children get `on_child_fail: 'continue'` + `max_stalled: 3`. `--follow` is the default on TTY; streams logs + polls `waitForCompletion` in parallel. Ctrl-C detaches, does not cancel. - `src/commands/agent-logs.ts` (v0.16) — `gbrain agent logs [--follow] [--since]`. Merges JSONL heartbeat audit + `subagent_messages` into a chronological timeline. `parseSince` accepts ISO-8601 or relative (`5m`, `1h`, `2d`). Transcript tail renders only for terminal jobs. -- `src/commands/jobs.ts` — `gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern). +- `src/commands/jobs.ts` — `gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern). v0.22.10 (#521): the `autopilot-cycle` handler now forwards `job.data.phases` to `runCycle` (was previously discarded — caller-supplied phase selection silently became a full cycle). Phases are validated against `ALL_PHASES` from `src/core/cycle.ts`; invalid names are filtered out and an empty/missing array falls back to the default 6-phase cycle. - `src/commands/features.ts` — `gbrain features --json --auto-fix`: usage scan + feature adoption salesman - `src/commands/autopilot.ts` — `gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed) - `src/mcp/server.ts` — MCP stdio server (generated from operations). v0.22.7: tool-call handler delegates to `dispatchToolCall` from `src/mcp/dispatch.ts` so stdio + HTTP transports share one validation, context-build, and error-format path. diff --git a/package.json b/package.json index 71692b75d..bef6bd2c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.22.9", + "version": "0.22.10", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/src/commands/jobs.ts b/src/commands/jobs.ts index 7e8d97479..07f3746d2 100644 --- a/src/commands/jobs.ts +++ b/src/commands/jobs.ts @@ -948,10 +948,19 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai ? job.data.repoPath : (await engine.getConfig('sync.repo_path')) ?? '.'; + // Allow callers to select phases via job data (e.g. skip embed for + // fast cycles). Validates against ALL_PHASES to prevent injection. + const { ALL_PHASES } = await import('../core/cycle.ts'); + const validPhases = new Set(ALL_PHASES); + const requestedPhases = Array.isArray(job.data.phases) + ? (job.data.phases as string[]).filter(p => validPhases.has(p as any)) + : undefined; + const report = await runCycle(engine, { brainDir: repoPath, pull: true, // autopilot daemon opts into git pull signal: job.signal, // propagate abort so cycle bails on timeout/cancel + ...(requestedPhases && requestedPhases.length > 0 ? { phases: requestedPhases as any } : {}), yieldBetweenPhases: async () => { // Yield to the event loop so worker lock-renewal can fire. await new Promise(r => setImmediate(r)); diff --git a/test/cycle-abort.test.ts b/test/cycle-abort.test.ts index f58de5ceb..65c7dcbdc 100644 --- a/test/cycle-abort.test.ts +++ b/test/cycle-abort.test.ts @@ -108,7 +108,7 @@ describe('autopilot-cycle handler contract (v0.20.5)', () => { // This is a source-level regression guard const handlerBlock = jobsSource.slice( jobsSource.indexOf("worker.register('autopilot-cycle'"), - jobsSource.indexOf("worker.register('autopilot-cycle'") + 500, + jobsSource.indexOf("worker.register('autopilot-cycle'") + 2000, ); expect(handlerBlock).toContain('signal: job.signal'); diff --git a/test/handlers.test.ts b/test/handlers.test.ts index cc7eedc59..487e2a3eb 100644 --- a/test/handlers.test.ts +++ b/test/handlers.test.ts @@ -120,3 +120,105 @@ describe('autopilot-cycle handler — partial failure does NOT throw', () => { } }, 30_000); }); + +describe('autopilot-cycle handler — phase passthrough', () => { + test('job.data.phases restricts which phases run', async () => { + const fs = await import('fs'); + const { execSync } = await import('child_process'); + const { tmpdir } = await import('os'); + const { join } = await import('path'); + const dir = fs.mkdtempSync(join(tmpdir(), 'gbrain-phase-pass-')); + try { + execSync('git init', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.email test@example.com', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.name Test', { cwd: dir, stdio: 'pipe' }); + execSync('git commit --allow-empty -m init', { cwd: dir, stdio: 'pipe' }); + + const handler = (worker as any).handlers.get('autopilot-cycle'); + // Request only lint and sync — embed should NOT appear + const result = await handler({ + data: { repoPath: dir, phases: ['lint', 'sync'] }, + signal: { aborted: false } as any, + job: { id: 10, name: 'autopilot-cycle' } as any, + }); + + expect(result).toBeDefined(); + const report = (result as any).report; + expect(report).toBeDefined(); + const phaseNames = report.phases.map((p: any) => p.phase); + expect(phaseNames).toContain('lint'); + expect(phaseNames).toContain('sync'); + // Phases NOT requested must be absent + expect(phaseNames).not.toContain('embed'); + expect(phaseNames).not.toContain('extract'); + expect(phaseNames).not.toContain('backlinks'); + expect(phaseNames).not.toContain('orphans'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 30_000); + + test('invalid phase names in job.data.phases are filtered out', async () => { + const fs = await import('fs'); + const { execSync } = await import('child_process'); + const { tmpdir } = await import('os'); + const { join } = await import('path'); + const dir = fs.mkdtempSync(join(tmpdir(), 'gbrain-phase-invalid-')); + try { + execSync('git init', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.email test@example.com', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.name Test', { cwd: dir, stdio: 'pipe' }); + execSync('git commit --allow-empty -m init', { cwd: dir, stdio: 'pipe' }); + + const handler = (worker as any).handlers.get('autopilot-cycle'); + // Mix valid and bogus names — only 'lint' should survive filtering + const result = await handler({ + data: { repoPath: dir, phases: ['lint', 'BOGUS', 'rm -rf /'] }, + signal: { aborted: false } as any, + job: { id: 11, name: 'autopilot-cycle' } as any, + }); + + const report = (result as any).report; + const phaseNames = report.phases.map((p: any) => p.phase); + expect(phaseNames).toContain('lint'); + expect(phaseNames).not.toContain('BOGUS'); + expect(phaseNames.length).toBe(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 30_000); + + test('empty phases array falls back to all phases (same as no phases)', async () => { + const handler = (worker as any).handlers.get('autopilot-cycle'); + // Empty array should fall through to ALL_PHASES (same as omitting phases) + const result = await handler({ + data: { repoPath: '/definitely-does-not-exist-for-phase-test', phases: [] }, + signal: { aborted: false } as any, + job: { id: 12, name: 'autopilot-cycle' } as any, + }); + + const report = (result as any).report; + // With all phases, filesystem phases fail on missing dir + const phaseNames = report.phases.map((p: any) => p.phase); + expect(phaseNames).toContain('lint'); + expect(phaseNames).toContain('backlinks'); + expect(phaseNames).toContain('sync'); + }, 30_000); + + test('non-array phases value is ignored (falls back to all)', async () => { + const handler = (worker as any).handlers.get('autopilot-cycle'); + // String instead of array — should be ignored + const result = await handler({ + data: { repoPath: '/definitely-does-not-exist-for-phase-test', phases: 'lint' }, + signal: { aborted: false } as any, + job: { id: 13, name: 'autopilot-cycle' } as any, + }); + + const report = (result as any).report; + const phaseNames = report.phases.map((p: any) => p.phase); + // Should have all phases since the string was ignored + expect(phaseNames).toContain('lint'); + expect(phaseNames).toContain('sync'); + expect(phaseNames).toContain('embed'); + }, 30_000); +});