Files
gbrain/test/cycle-patterns-child-outcome.test.ts
T
e1cefd0654 fix(subagent): orchestration fix-wave G — configurable timeouts/caps, honest child outcomes, fenced timeline writes (#2937)
Four verified-open issues in the subagent-orchestration family, one PR:

- #1594: dream synthesize subagent job/wait timeouts promoted from
  hardcoded 30/35-min constants to config keys
  dream.synthesize.subagent_timeout_ms / subagent_wait_timeout_ms.
  Approach ported from stale PR #1596 (credit @ai920wisco).
- #2778: add_timeline_entry joins the subagent brain-tool allowlist,
  fenced fail-closed by the shared enforceSubagentSlugFence (extracted
  from put_page's inline check — same trusted-workspace allow-list /
  wiki/agents/<id>/ namespace policy). The per-turn output cap is now
  resolveMaxOutputTokens (data.max_tokens → agent.max_output_tokens →
  8192, was hardcoded 4096); a max_tokens stop surfaces as
  stop_reason 'max_tokens' instead of a silent end_turn, and a
  mid-tool-round cap hit injects a truncation note so the model
  re-issues the dropped call.
- #2782: patterns phase status now reflects the child outcome —
  non-complete outcome with zero writes → fail (PATTERNS_CHILD_<OUTCOME>),
  partial writes → warn. Patterns timeouts get the same config-key pair
  (dream.patterns.subagent_timeout_ms / subagent_wait_timeout_ms).
- #2113: facts extraction cap is config facts.extraction_max_tokens
  (default 4000, was hardcoded 1500); stopReason 'length' is checked,
  retried once at 2x the cap, and surfaced on stderr instead of
  silently extracting zero facts.

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: ai920wisco <ai920wisco@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:25:48 -07:00

104 lines
3.9 KiB
TypeScript

/**
* #2782 — patterns phase status must reflect the child subagent outcome.
*
* Pre-fix, runPhasePatterns returned status:ok with child_outcome:timeout and
* zero pattern pages written (e.g. when no subagent-capable worker slot was
* free for the whole wait window) — a silent no-op for days.
*
* Drives the real phase against PGLite with the (#1594-family) configurable
* wait timeout set to 1ms and NO worker running, so the child job never
* completes: waitForCompletion throws TimeoutError → outcome 'timeout' →
* nothing written → the phase must report status 'fail', not 'ok'.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { runPhasePatterns } from '../src/core/cycle/patterns.ts';
import { withEnv } from './helpers/with-env.ts';
let engine: PGLiteEngine;
let schemaVersion: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
// resetPgliteState truncates `config`, wiping the `version` row that
// MinionQueue.ensureSchema checks. Capture it so beforeEach can restore.
schemaVersion = (await engine.getConfig('version')) ?? '7';
}, 60_000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', schemaVersion);
});
async function seedReflections(): Promise<void> {
// Enough recent reflections to clear min_evidence (default 3).
for (let i = 0; i < 3; i++) {
await engine.executeRaw(
`INSERT INTO pages (slug, type, title, compiled_truth)
VALUES ($1, 'note', $2, $3)`,
[
`wiki/personal/reflections/2026-07-0${i + 1}-reflection`,
`Reflection ${i + 1}`,
`Recurring theme fixture number ${i + 1}.`,
],
);
}
}
describe('runPhasePatterns child-outcome status (#2782)', () => {
test('child timeout with zero writes → status fail (was silent ok)', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-outcome-'));
try {
await seedReflections();
// #1594-family knob: make the wait window elapse immediately. No
// minion worker runs in this test, so the child job stays queued.
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
const result = await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
runPhasePatterns(engine, { brainDir, dryRun: false }),
);
expect(result.status).toBe('fail');
expect(result.details.child_outcome).toBe('timeout');
expect(result.details.patterns_written).toBe(0);
expect(result.error?.code).toBe('PATTERNS_CHILD_TIMEOUT');
expect(result.error?.class).toBe('Timeout');
} finally {
rmSync(brainDir, { recursive: true, force: true });
}
}, 60_000);
test('dream.patterns.subagent_timeout_ms flows to the submitted job', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-timeout-'));
try {
await seedReflections();
await engine.setConfig('dream.patterns.subagent_timeout_ms', '600000');
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
runPhasePatterns(engine, { brainDir, dryRun: false }),
);
const jobs = await engine.executeRaw<{ timeout_ms: string | number | null }>(
`SELECT timeout_ms FROM minion_jobs WHERE name = 'subagent' ORDER BY id DESC LIMIT 1`,
);
expect(jobs).toHaveLength(1);
expect(Number(jobs[0]!.timeout_ms)).toBe(600000);
} finally {
rmSync(brainDir, { recursive: true, force: true });
}
}, 60_000);
});