## Summary Adds a **session advisor** to the planner overseer so Fusion can review live executor transcripts the way [oh-my-pi’s advisor](https://github.com/can1357/oh-my-pi/tree/main/packages/coding-agent/src/advisor) does — without replacing the existing lifecycle supervisor (stage watch, retry, merge confirmation, human-control withhold). ### What ships - **Emission guard** (`OverseerEmissionGuard`) — content-free phrase filter, session dedupe with severity-rank escalation, one accept per advisor update - **Session delta runtime** — queues agent-log deltas, drains through an advisor agent, drops backlog after 3 failures - **Session advisor service** — model gate, level matrix (`observe` / `steer` / `autonomous`), human-control re-check at inject, `[session-advisor]` steering comments - **OVERSEER.md / WATCHDOG.md** discovery for project review priorities - **AgentLogger `onEntriesFlushed`** + poll-backed agent-log cursor for durable deltas - Workflow settings: `plannerOverseerAdvisorProvider` + `plannerOverseerAdvisorModelId` (both required; empty = soft-disabled for cost safety) - Docs + changeset ### What does not ship (deferred) - Multi-advisor YAML roster, mutating advisor tools, reviewer/merger shadowing, true tool-abort interrupt ### Plan `docs/plans/2026-07-13-001-feat-overseer-advisor-parity-plan.md` ## Enablement 1. Set workflow **Session advisor model provider** + **Session advisor model id** 2. Oversight level `observe` (log only), `steer`, or `autonomous` (inject) 3. Optional: add `OVERSEER.md` or `WATCHDOG.md` in the project ## Test plan - [x] `pnpm --filter @fusion/core exec vitest run src/__tests__/overseer-emission-guard.test.ts` - [x] `pnpm --filter @fusion/engine exec vitest run` overseer-* unit tests (21 tests) - [x] Related planner-overseer / intervention regression tests - [x] `@fusion/engine` + `@fusion/core` typecheck - [ ] Manual: configure advisor model, run an executor task, confirm `[session-advisor]` inject + timeline metadata when concern is raised ## Residual Review Findings None from autofix pass (log-cursor ordering fix already committed). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an off-by-default “session advisor” that can review live execution activity and provide severity-based guidance. * Added project and per-task controls to enable it, including a default enable switch and Quick Add / Task Detail toggles. * Enhanced advisor prompting by discovering and incorporating `OVERSEER.md`/`WATCHDOG.md` review files. * **Documentation** * Added architecture and settings documentation for the new session-advisor parity behavior. * **Bug Fixes** * Improved fail-soft handling so advisor behavior won’t disrupt execution. * Fixed concurrent PostgreSQL migration startup failures. * **Tests** * Added coverage for advice parsing, emission guarding, runtime behavior, and watchdog discovery. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
251 lines
7.9 KiB
TypeScript
251 lines
7.9 KiB
TypeScript
/**
|
|
* FNXC:PlannerOversight 2026-07-13-22:50:
|
|
* Session-advisor runtime control plane (OMP AdvisorRuntime parity).
|
|
* Queues transcript deltas from the executor log stream, drains them through
|
|
* an injected advisor agent (or fake in tests), and routes accepted notes
|
|
* back via the host. Never throws out of public methods; advisor failures
|
|
* retry up to 3 times then drop backlog so the executor is never stalled.
|
|
*
|
|
* v1 does not block the executor on advisor catch-up (syncBacklog default
|
|
* off). The waitForCatchup API is present for a future setting.
|
|
*/
|
|
|
|
import { createLogger, type Logger } from "./logger.js";
|
|
import { formatOverseerSessionDelta, type OverseerLogEntry } from "./overseer-session-delta.js";
|
|
|
|
const runtimeLog = createLogger("overseer-advisor-runtime");
|
|
|
|
/** Minimal advisor agent the runtime drives — satisfied by a real session or a test fake. */
|
|
export interface OverseerAdvisorAgent {
|
|
prompt(input: string): Promise<void>;
|
|
abort?(reason?: unknown): void;
|
|
reset?(): void;
|
|
}
|
|
|
|
export interface OverseerAdvisorRuntimeHost {
|
|
/** Surface one accepted advice note to the executor (after emission guard). */
|
|
enqueueAdvice(note: string, severity?: "nit" | "concern" | "blocker"): void | Promise<void>;
|
|
/** Clear per-update emission-guard budget before each prompt cycle. */
|
|
beginAdvisorUpdate?(): void;
|
|
onTurnError?(error: unknown): void | Promise<void>;
|
|
notifyFailure?(error: unknown): void;
|
|
}
|
|
|
|
interface PendingDelta {
|
|
text: string;
|
|
turns: number;
|
|
}
|
|
|
|
export interface OverseerAdvisorRuntimeOptions {
|
|
agent: OverseerAdvisorAgent;
|
|
host: OverseerAdvisorRuntimeHost;
|
|
retryDelayMs?: number;
|
|
logger?: Logger;
|
|
/** Injectable sleep for tests (defaults to real setTimeout). */
|
|
sleep?: (ms: number) => Promise<void>;
|
|
}
|
|
|
|
function defaultSleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* FNXC:PlannerOversight 2026-07-13-22:50:
|
|
* Per-task session advisor drain loop. Cursor tracks how many log entries
|
|
* have been rendered; seedTo jumps the cursor when advising enables mid-task.
|
|
*/
|
|
export class OverseerAdvisorRuntime {
|
|
#agent: OverseerAdvisorAgent;
|
|
#host: OverseerAdvisorRuntimeHost;
|
|
#retryDelayMs: number;
|
|
#logger: Logger;
|
|
#sleep: (ms: number) => Promise<void>;
|
|
|
|
#lastCount = 0;
|
|
#pending: PendingDelta[] = [];
|
|
#busy = false;
|
|
#backlog = 0;
|
|
#consecutiveFailures = 0;
|
|
#failureNotified = false;
|
|
#latestEntries: OverseerLogEntry[] = [];
|
|
#epoch = 0;
|
|
disposed = false;
|
|
|
|
constructor(options: OverseerAdvisorRuntimeOptions) {
|
|
this.#agent = options.agent;
|
|
this.#host = options.host;
|
|
this.#retryDelayMs = options.retryDelayMs ?? 1000;
|
|
this.#logger = options.logger ?? runtimeLog;
|
|
this.#sleep = options.sleep ?? defaultSleep;
|
|
}
|
|
|
|
get backlog(): number {
|
|
return this.#backlog;
|
|
}
|
|
|
|
get lastCount(): number {
|
|
return this.#lastCount;
|
|
}
|
|
|
|
/**
|
|
* Notify that the executor log grew. Pass the full entry list (or a
|
|
* snapshot); only entries after the internal cursor are rendered.
|
|
*/
|
|
onLogSnapshot(entries: ReadonlyArray<OverseerLogEntry>): void {
|
|
if (this.disposed) return;
|
|
try {
|
|
this.#latestEntries = [...entries];
|
|
const render = this.#renderDelta(this.#latestEntries);
|
|
if (render) {
|
|
this.#pending.push({ text: render, turns: 1 });
|
|
this.#backlog++;
|
|
void this.#drain();
|
|
}
|
|
} catch (err) {
|
|
this.#logger.warn(`onLogSnapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append-only convenience: notify with only the new entries since last call
|
|
* when the host cannot provide a full list. Advances the cursor by the
|
|
* number of entries provided.
|
|
*/
|
|
onLogDelta(newEntries: ReadonlyArray<OverseerLogEntry>): void {
|
|
if (this.disposed) return;
|
|
try {
|
|
if (!newEntries || newEntries.length === 0) return;
|
|
this.#latestEntries = this.#latestEntries.concat(newEntries);
|
|
const render = formatOverseerSessionDelta(newEntries);
|
|
// Advance cursor even when render is null (all filtered) so we don't reprocess.
|
|
this.#lastCount = this.#latestEntries.length;
|
|
if (render) {
|
|
this.#pending.push({ text: render, turns: 1 });
|
|
this.#backlog++;
|
|
void this.#drain();
|
|
}
|
|
} catch (err) {
|
|
this.#logger.warn(`onLogDelta failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Seed the cursor to the current transcript length when advising is enabled
|
|
* mid-session — avoid replaying the entire history on the first update.
|
|
*/
|
|
seedTo(count: number): void {
|
|
this.#lastCount = Math.max(0, count);
|
|
this.#pending = [];
|
|
this.#backlog = 0;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
}
|
|
|
|
/**
|
|
* Re-prime after history rewrite (session switch, compaction, worktree rebind).
|
|
*/
|
|
reset(): void {
|
|
this.#epoch++;
|
|
this.#lastCount = 0;
|
|
this.#pending = [];
|
|
this.#backlog = 0;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
try {
|
|
this.#agent.reset?.();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
this.#agent.abort?.("advisor reset");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
this.#epoch++;
|
|
this.#pending = [];
|
|
this.#backlog = 0;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
try {
|
|
this.#agent.abort?.("advisor disposed");
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
#renderDelta(all: ReadonlyArray<OverseerLogEntry>): string | null {
|
|
if (all.length < this.#lastCount) {
|
|
this.#lastCount = all.length;
|
|
return null;
|
|
}
|
|
const delta = all.slice(this.#lastCount);
|
|
this.#lastCount = all.length;
|
|
if (delta.length === 0) return null;
|
|
return formatOverseerSessionDelta(delta);
|
|
}
|
|
|
|
async #drain(): Promise<void> {
|
|
if (this.#busy) return;
|
|
this.#busy = true;
|
|
try {
|
|
while (!this.disposed && this.#pending.length > 0) {
|
|
const popped = this.#pending.splice(0);
|
|
const epoch = this.#epoch;
|
|
const batch = popped.map((b) => b.text).join("\n\n");
|
|
const turnsCovered = popped.reduce((sum, b) => sum + b.turns, 0);
|
|
if (!batch.trim()) {
|
|
this.#backlog = Math.max(0, this.#backlog - turnsCovered);
|
|
continue;
|
|
}
|
|
|
|
let success = false;
|
|
try {
|
|
this.#host.beginAdvisorUpdate?.();
|
|
await this.#agent.prompt(batch);
|
|
success = true;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
} catch (err) {
|
|
if (this.#epoch !== epoch) continue;
|
|
this.#logger.warn(`advisor turn failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
try {
|
|
await this.#host.onTurnError?.(err);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (this.#epoch !== epoch) continue;
|
|
this.#consecutiveFailures++;
|
|
if (this.#consecutiveFailures >= 3) {
|
|
this.#logger.warn("advisor failed consecutively 3 times; dropping backlog");
|
|
if (!this.#failureNotified) {
|
|
this.#failureNotified = true;
|
|
try {
|
|
this.#host.notifyFailure?.(err);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
this.#consecutiveFailures = 0;
|
|
// FNXC:PlannerOversight 2026-07-14-14:00: CodeRabbit — allow a *new* 3-failure streak to notify again after this drop is handled (do not latch forever until a success).
|
|
this.#failureNotified = false;
|
|
success = true; // treat as handled drop
|
|
} else {
|
|
this.#pending.unshift({ text: batch, turns: turnsCovered });
|
|
await this.#sleep(this.#retryDelayMs);
|
|
}
|
|
}
|
|
|
|
if (success && this.#epoch === epoch) {
|
|
this.#backlog = Math.max(0, this.#backlog - turnsCovered);
|
|
}
|
|
}
|
|
} finally {
|
|
this.#busy = false;
|
|
}
|
|
}
|
|
}
|