mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 10:22:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9391fb9317 |
@@ -142,12 +142,16 @@ export async function runOnboard(engine: BrainEngine, args: string[]): Promise<v
|
||||
|
||||
// --auto path: runs through the T2 library orchestrator. Hooks emit CLI
|
||||
// progress to stderr; the final result lands as JSON on stdout (or human
|
||||
// summary).
|
||||
// summary). extraRemediations (gathered above from runAllOnboardChecks)
|
||||
// is threaded into the runner so the onboard-check remediations
|
||||
// (extract-ner, extract-timeline-from-meetings, etc.) reach the planner
|
||||
// — the same wiring the --check path uses above.
|
||||
const result = await runRemediation(
|
||||
engine,
|
||||
{
|
||||
targetScore,
|
||||
maxUsd,
|
||||
extraRemediations,
|
||||
// --auto --yes opts into the prompt_required tier too; library
|
||||
// doesn't distinguish auto_apply vs prompt_required, it just runs
|
||||
// every remediation in the plan. The plan-building side (T12 render)
|
||||
|
||||
@@ -453,14 +453,7 @@ function resolveActivity(
|
||||
* every `assemble()` call. 1 MB is generous for a human-edited task list. */
|
||||
const MAX_TASKS_MD_BYTES = 1_000_000;
|
||||
|
||||
/** Extract open tasks from ops/tasks.md Today section.
|
||||
*
|
||||
* The daily-task-manager skill's documented Output Format uses priority
|
||||
* headings (`## P1 — Today`) with plain `- [ ] task` lines; older fixtures
|
||||
* used a bare `## Today` heading with bold task names. Accept both so the
|
||||
* live-context reader matches the documented writer contract instead of
|
||||
* silently surfacing no tasks (#2186).
|
||||
*/
|
||||
/** Extract open tasks from ops/tasks.md "## Today" section. */
|
||||
function resolveTodayTasks(workspaceDir: string): string[] {
|
||||
try {
|
||||
const path = join(workspaceDir, 'ops', 'tasks.md');
|
||||
@@ -468,18 +461,14 @@ function resolveTodayTasks(workspaceDir: string): string[] {
|
||||
// statSync throws if the file doesn't exist; that lands in the outer catch.
|
||||
if (statSync(path).size > MAX_TASKS_MD_BYTES) return [];
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const todayMatch = raw.match(/^##\s+(?:P\d\s*[—–-]\s*)?Today\b[\s\S]*?(?=\n##\s|$(?![\s\S]))/m);
|
||||
const todayMatch = raw.match(/## Today[\s\S]*?(?=\n## |$)/);
|
||||
if (!todayMatch) return [];
|
||||
|
||||
const lines = todayMatch[0].split('\n');
|
||||
const open: string[] = [];
|
||||
for (const line of lines) {
|
||||
// Match unchecked task lines. Legacy bold form first (extracts just
|
||||
// the task name, dropping trailing metadata), then the documented
|
||||
// plain form (whole line body is the task).
|
||||
const m =
|
||||
line.match(/^\s*-\s*\[ \]\s*\*\*(.+?)\*\*/) ??
|
||||
line.match(/^\s*-\s*\[ \]\s*(.+?)\s*$/);
|
||||
// Match unchecked task lines: - [ ] **task name** ...
|
||||
const m = line.match(/^\s*-\s*\[ \]\s*\*\*(.+?)\*\*/);
|
||||
if (m) open.push(sanitizeForPrompt(m[1].trim()));
|
||||
}
|
||||
return open.slice(0, 5); // cap at 5 to keep prompt lean
|
||||
|
||||
@@ -4957,7 +4957,7 @@ const run_onboard: Operation = {
|
||||
// typo, the underlying queue.add would reject. Defense-in-depth.
|
||||
const result = await runRemediation(
|
||||
ctx.engine,
|
||||
{ targetScore, maxUsd },
|
||||
{ targetScore, maxUsd, extraRemediations: allowedExtras },
|
||||
{},
|
||||
);
|
||||
|
||||
|
||||
@@ -66,9 +66,10 @@ export async function runRemediation(
|
||||
} = await import('../remediation-checkpoint.ts');
|
||||
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
const extraRemediations = opts.extraRemediations ?? [];
|
||||
|
||||
// Pre-flight ceiling check via the shared plan computation.
|
||||
const initialPlan = await computeRemediationPlan(engine, { targetScore });
|
||||
const initialPlan = await computeRemediationPlan(engine, { targetScore, extraRemediations });
|
||||
if (initialPlan.target_unreachable) {
|
||||
hooks.onTargetUnreachable?.(targetScore, initialPlan.max_reachable_score);
|
||||
return {
|
||||
@@ -87,7 +88,7 @@ export async function runRemediation(
|
||||
}
|
||||
|
||||
const initialHealth = await engine.getHealth();
|
||||
let recs: RemediationStep[] = computeRecommendations(initialHealth, ctx)
|
||||
let recs: RemediationStep[] = computeRecommendations(initialHealth, ctx, extraRemediations)
|
||||
.filter((r) => r.status === 'remediable');
|
||||
if (recs.length === 0) {
|
||||
hooks.onNothingToDo?.(initialHealth.brain_score, targetScore);
|
||||
@@ -305,7 +306,13 @@ export async function runRemediation(
|
||||
// steps with bumped retry suffix (D1).
|
||||
if (recs.length === 0 || stepCount >= maxJobs) break;
|
||||
const freshHealth = await engine.getHealth();
|
||||
recs = computeRecommendations(freshHealth, ctx).filter((r) => r.status === 'remediable');
|
||||
// Extras carry a static status:'remediable' — a fresh health snapshot
|
||||
// never ages them out the way health-derived steps drop. Filter out
|
||||
// ids this run already processed (any terminal status), or the recheck
|
||||
// would resubmit completed extras every iteration, forever.
|
||||
const processedIds = new Set(submitted.map((s) => s.id));
|
||||
const pendingExtras = extraRemediations.filter((r) => !processedIds.has(r.id));
|
||||
recs = computeRecommendations(freshHealth, ctx, pendingExtras).filter((r) => r.status === 'remediable');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,16 @@ export interface RemediationOpts {
|
||||
resumePlanHash?: string;
|
||||
/** Whether to attempt resume at all (default false). */
|
||||
resume?: boolean;
|
||||
/**
|
||||
* Caller-supplied RemediationStep entries threaded into the planner.
|
||||
* Mirrors RemediationPlanOpts.extraRemediations so onboard's --apply
|
||||
* --auto path (and MCP run_onboard auto modes) forward the same
|
||||
* onboard-check remediations the --check path already passes through
|
||||
* computeRemediationPlan. Without this the runner saw only generic
|
||||
* brain_score remediations and reported "Nothing to do" whenever the
|
||||
* only applicable work was an extra (e.g. extract-ner).
|
||||
*/
|
||||
extraRemediations?: RemediationStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -322,28 +322,6 @@ describe('gbrain-context engine', () => {
|
||||
expect(result.systemPromptAddition).not.toContain('Something later');
|
||||
});
|
||||
|
||||
it('injects documented "## P1 — Today" plain tasks from ops/tasks.md (#2186)', async () => {
|
||||
tmpDir = makeWorkspace({
|
||||
heartbeat: { garryAwake: true },
|
||||
tasks: `# Tasks\n\n## P0 — Urgent\n- [ ] **Escalate outage**\n\n## P1 — Today\n- [ ] Call Alice about launch plan\n- [ ] **Review Bob contract** — due Friday\n- [x] Completed item\n\n## P2 — This Week\n- [ ] Should not surface`,
|
||||
});
|
||||
const engine = createGBrainContextEngine({ workspaceDir: tmpDir });
|
||||
|
||||
const result = await engine.assemble({
|
||||
sessionId: 'test-session',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.systemPromptAddition).toContain('Open tasks');
|
||||
expect(result.systemPromptAddition).toContain('Call Alice about launch plan');
|
||||
// Bold form still extracts just the task name, not trailing metadata.
|
||||
expect(result.systemPromptAddition).toContain('Review Bob contract');
|
||||
expect(result.systemPromptAddition).not.toContain('due Friday');
|
||||
expect(result.systemPromptAddition).not.toContain('Escalate outage');
|
||||
expect(result.systemPromptAddition).not.toContain('Completed item');
|
||||
expect(result.systemPromptAddition).not.toContain('Should not surface');
|
||||
});
|
||||
|
||||
it('no activity section when calendar is empty and no tasks', async () => {
|
||||
tmpDir = makeWorkspace({
|
||||
heartbeat: { garryAwake: true },
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// test/remediation-run-extras.serial.test.ts
|
||||
// Regression for PR #2161 takeover: `gbrain onboard --apply --auto` dropped
|
||||
// onboard-check extraRemediations. Two distinct halves of the bug:
|
||||
// 1. runRemediation built the pre-flight plan + initial recs WITHOUT the
|
||||
// extras, so an extras-only plan reported "Nothing to do".
|
||||
// 2. The D7 mid-run recheck rebuilt recs WITHOUT the extras after every
|
||||
// completed step, so with 2+ plannable steps all remaining extras were
|
||||
// dropped after step 1. The recheck must also filter out extras this
|
||||
// run already processed — extras carry static status:'remediable', so
|
||||
// unfiltered threading would resubmit completed extras forever.
|
||||
//
|
||||
// SERIAL: mock.module (queue + wait-for-completion stubs, R2) + GBRAIN_HOME
|
||||
// env mutation so checkpoint files land in a tmpdir, not ~/.gbrain.
|
||||
|
||||
import { describe, expect, test, beforeAll, afterAll, mock } 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 { makeRemediationStep } from '../src/core/remediation-step.ts';
|
||||
|
||||
// Stub the Minion queue: every submitted job is immediately 'completed'.
|
||||
// runRemediation only calls queue.add + waitForCompletion(queue, id).
|
||||
let nextJobId = 1;
|
||||
const submittedJobs: Array<{ name: string }> = [];
|
||||
mock.module('../src/core/minions/queue.ts', () => ({
|
||||
MinionQueue: class {
|
||||
async add(name: string) {
|
||||
submittedJobs.push({ name });
|
||||
return { id: nextJobId++, status: 'completed' };
|
||||
}
|
||||
},
|
||||
}));
|
||||
mock.module('../src/core/minions/wait-for-completion.ts', () => ({
|
||||
waitForCompletion: async () => ({ status: 'completed' }),
|
||||
}));
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let home: string;
|
||||
const prevHome = process.env.GBRAIN_HOME;
|
||||
|
||||
beforeAll(async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'gbrain-remextras-'));
|
||||
process.env.GBRAIN_HOME = home;
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prevHome;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function extra(id: string, job: string) {
|
||||
return makeRemediationStep({
|
||||
id,
|
||||
job,
|
||||
params: {},
|
||||
severity: 'medium',
|
||||
est_seconds: 5,
|
||||
est_usd_cost: 0,
|
||||
rationale: 'synthetic onboard-check extra',
|
||||
status: 'remediable',
|
||||
});
|
||||
}
|
||||
|
||||
describe('runRemediation extraRemediations threading', () => {
|
||||
test('extras-only plan runs BOTH extras and terminates (no Nothing-to-do, no resubmit loop)', async () => {
|
||||
// Empty PGLite brain → zero health-derived recommendations. Without the
|
||||
// fix, half 1 makes this run return submitted: [] via onNothingToDo.
|
||||
// With only half 1 (the original PR #2161 diff), the mid-run recheck
|
||||
// drops the second extra after step 1 — submitted has 1 entry, not 2.
|
||||
const { runRemediation } = await import('../src/core/remediation/run.ts');
|
||||
let nothingToDo = false;
|
||||
const result = await runRemediation(
|
||||
engine,
|
||||
{
|
||||
targetScore: 1,
|
||||
extraRemediations: [
|
||||
extra('onboard.extract_ner', 'extract-ner'),
|
||||
extra('onboard.extract_timeline', 'extract-timeline-from-meetings'),
|
||||
],
|
||||
// Safety bound: an unfiltered recheck would resubmit completed
|
||||
// extras forever; maxJobs turns that regression into a fast fail
|
||||
// (extra count > 1 below) instead of a hung test.
|
||||
maxJobs: 5,
|
||||
},
|
||||
{ onNothingToDo: () => { nothingToDo = true; } },
|
||||
);
|
||||
|
||||
expect(nothingToDo).toBe(false);
|
||||
const ids = result.submitted.map((s) => s.id);
|
||||
expect(ids).toContain('onboard.extract_ner');
|
||||
expect(ids).toContain('onboard.extract_timeline');
|
||||
// Each extra ran exactly once — the recheck must not re-plan extras the
|
||||
// run already processed.
|
||||
expect(ids.filter((i) => i === 'onboard.extract_ner').length).toBe(1);
|
||||
expect(ids.filter((i) => i === 'onboard.extract_timeline').length).toBe(1);
|
||||
expect(result.submitted.every((s) => s.status === 'completed')).toBe(true);
|
||||
expect(submittedJobs.map((j) => j.name).sort()).toEqual([
|
||||
'extract-ner',
|
||||
'extract-timeline-from-meetings',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user