diff --git a/.changeset/fix-concurrent-project-ownership-migration.md b/.changeset/fix-concurrent-project-ownership-migration.md new file mode 100644 index 0000000000..80df334455 --- /dev/null +++ b/.changeset/fix-concurrent-project-ownership-migration.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix startup failures when several projects migrate against one PostgreSQL cluster at the same time. +category: fix +dev: Migration 0006's `fusion_runtime` setup used a check-then-CREATE ROLE that cannot be atomic — roles live in cluster-wide `pg_authid`, but the applier's `pg_advisory_xact_lock('fusion:schema-applier')` is per-database, so concurrent appliers on different databases of one cluster all saw the role as absent and raced, and the losers failed with 23505 on `pg_authid_rolname_index`. The create now tolerates losing the race (`EXCEPTION WHEN duplicate_object OR unique_violation`), catching the index-level violation the race actually raises as well as the plain duplicate. diff --git a/.changeset/overseer-session-advisor.md b/.changeset/overseer-session-advisor.md new file mode 100644 index 0000000000..a35d68f80c --- /dev/null +++ b/.changeset/overseer-session-advisor.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Optional LLM session advisor for planner overseer (off by default; enable and set model to use). +category: feature +dev: OMP-advisor parity — OverseerEmissionGuard, delta runtime, OVERSEER.md/WATCHDOG.md. Gate: plannerOverseerAdvisorEnabled (default false) plus provider/model ids. Lifecycle supervisor unchanged. diff --git a/.changeset/session-advisor-project-task-controls.md b/.changeset/session-advisor-project-task-controls.md new file mode 100644 index 0000000000..f4235d31bd --- /dev/null +++ b/.changeset/session-advisor-project-task-controls.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Control the overseer session advisor from project settings, per task, and Quick Add. +category: feature +dev: Adds `sessionAdvisorEnabledByDefault` project setting, `task.sessionAdvisorEnabled` override, Quick Add eye toggle, and `resolveTaskSessionAdvisorEnabled` (task override → project default; workflow `plannerOverseerAdvisorEnabled` is a legacy/master gate that can still enable when the project default is off — not the final inheritance fallback after project). diff --git a/docs/architecture.md b/docs/architecture.md index 0123227428..7bab82d6ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1367,6 +1367,36 @@ Limits are controlled by project settings (`maxSpawnedAgentsPerParent`, `maxSpaw ### Custom instructions `packages/engine/src/agent-instructions.ts` resolves per-agent instruction text/path with path-traversal and extension validation. +### Planner overseer session advisor (OMP advisor parity) + +/* +FNXC:PlannerOversight 2026-07-13-23:10 / 2026-07-14-12:00: +Session-advisor layer shadows executor agent-log deltas with a second model, +severity-routed notes, and an emission guard. **Off by default** via workflow +setting `plannerOverseerAdvisorEnabled` (false). When enabled, also requires +both `plannerOverseerAdvisorProvider` and `plannerOverseerAdvisorModelId`. +Lifecycle supervisor (FN-7511–7520) remains authoritative for stage signals, +retry, and merge confirmation and does not depend on the LLM advisor. +*/ + +When enabled and a model is configured, `OverseerAdvisorService` (engine) queues +transcript deltas from `AgentLogger.onEntriesFlushed` and the planner-overseer +poll's agent-log cursor, prompts an isolated advisor model, and — after +`OverseerEmissionGuard` — injects `[session-advisor]` steering comments for +levels `steer`/`autonomous` (observe logs only). The system prompt +(`OVERSEER_ADVISOR_SYSTEM_PROMPT`) ports oh-my-pi advisor judgment policy +(peer-programmer role, critical silence rules, nit/concern/blocker criteria) +with Fusion anchors (PROMPT.md, File Scope, verification) and a JSON +`note`/`severity` or `silence` reply contract. Human-control withhold still +applies at inject time. + +**Watchdog / review-priority discovery** (`discoverOverseerWatchdogFiles` in +`overseer-watchdog.ts`) loads every readable `OVERSEER.md` and `WATCHDOG.md` +from: (1) the user agent dir when configured, (2) each directory from the task +worktree/cwd upward to the repo root (or home), including both bare and +`.fusion/` / `.omp/` nested variants. Multiple files concatenate; user-level +first, then project ancestor→leaf so the leaf is most prominent. + ### Planner overseer monitoring (records-only) /* diff --git a/docs/plans/2026-07-13-001-feat-overseer-advisor-parity-plan.md b/docs/plans/2026-07-13-001-feat-overseer-advisor-parity-plan.md new file mode 100644 index 0000000000..b70b0f7a5e --- /dev/null +++ b/docs/plans/2026-07-13-001-feat-overseer-advisor-parity-plan.md @@ -0,0 +1,457 @@ +--- +title: "feat: Overseer session advisor (OMP advisor parity)" +type: feat +status: completed +date: 2026-07-13 +origin: conversation gap analysis of https://github.com/can1357/oh-my-pi/tree/main/packages/coding-agent/src/advisor (no brainstorm requirements doc) +branch: feature/improve-overseer +base: origin/main@8e4514e58 (refreshed 2026-07-13) +--- + +# feat: Overseer session advisor (OMP advisor parity) + +## Summary + +Add a live, transcript-aware **session advisor** beside Fusion’s existing lifecycle planner overseer so in-flight executors get concrete, severity-routed advice (oh-my-pi advisor shape), without discarding stage watching, bounded recovery, merge confirmation, or human-control withhold. + +## Problem Frame + +Fusion’s planner overseer (FN-7511–7520, FN-7551, FN-7577, FN-7743) is a strong **lifecycle supervisor**: it polls store state every ~45s, derives stage signals, and at `autonomous` may inject canned steering, retry, or request confirmation. It does **not** read agent transcripts or run a second model, so it cannot catch “working hard in the wrong direction,” thin verification, or churn until a coarse stall signal fires (often hours later). + +oh-my-pi’s advisor is the complementary shape: after each primary turn it feeds transcript deltas to a separate agent with read tools and an `advise` tool, prefers silence, enforces emission hygiene in code, and routes by severity. + +Operators want Fusion’s overseer to **work like that advisor** for coding sessions while keeping board-native recovery and safety gates. + +## Requirements + +- R1. While an executor session is active and session advising is enabled, a second model reviews **transcript deltas** (not only board column metadata) and may produce at most one concrete note per advisor update. +- R2. Advice delivery is severity-aware: `nit` (non-interrupting), `concern` / `blocker` (steering-weight); silence is the default when the agent is on track. +- R3. Content-free and duplicate advice never reaches the executor (load-bearing emission guard, not prompt-only). +- R4. Project-specific review priorities can be configured without code changes (`OVERSEER.md`, optionally honoring `WATCHDOG.md` aliases). +- R5. Effective planner oversight levels remain the master gate: `off` does nothing; `observe` may run AI but does not inject; `steer` injects advice only; `autonomous` allows advice plus existing bounded lifecycle recovery. +- R6. Human-control policy still withholds **all** inject/retry/confirm paths for user-paused and auto-merge-off/human-review tasks (`evaluateOverseerHumanControl`). +- R7. Merge/PR and destructive side effects remain confirmation-gated; the session advisor never self-approves merge. +- R8. Lifecycle supervisor behavior (stage observation, stall→guidance/retry, intervention timeline, nudge/stop/explain) continues to work when the session advisor is off, misconfigured, or failing. +- R9. Overseer/advisor model usage is attributable separately from the executor (distinct agent role / log stream). +- R10. v1 ships **executor lane only**; multi-advisor YAML, mutating advisor tools, and reviewer/merger shadowing are deferred. + +## Assumptions + +Product call-outs resolved as planning defaults (redirect before implementation if wrong): + +- A1. **Session AI defaults off** until a dedicated overseer/advisor model role (or explicit model setting) is configured — even if oversight level is `autonomous`. Avoids surprise cost on every task after upgrade. +- A2. **v1 delivery = steering comments** via existing `addSteeringComment` + executor real-time injection. True tool-abort interrupt is deferred unless a safe runtime seam is already free. +- A3. Config discovery accepts **`OVERSEER.md` and `WATCHDOG.md`** (and `.fusion/` / user agent-dir equivalents) so OMP-familiar operators are not blocked. +- A4. First ship is **executor-only**; other stages keep lifecycle rules only. + +## Scope Boundaries + +### In scope + +- Hybrid architecture: lifecycle supervisor + session advisor +- Emission guard, severity metadata, intervention timeline extensions +- Agent-log / turn-boundary delta runtime for executor +- LLM advisor agent (read-only tools + `advise`), system prompt adapted from OMP peer-reviewer framing +- Settings: enablement via model role + level matrix; optional sync backlog / immune turns +- Docs: architecture, settings-reference, dashboard-guide + +### Deferred to Follow-Up Work + +- Multi-advisor roster (`OVERSEER.yml` / `WATCHDOG.yml` specialists) +- Mutating advisor tools (`edit`/`write`/`bash`) +- Reviewer / merger / PR-lane session shadowing +- True in-flight tool abort on `blocker` +- Subagent / workspace-repo per-cwd advisors +- Full Agent Hub-style advisor dump UX (minimal status first) + +### Out of scope + +- Replacing self-healing or StuckTaskDetector +- Changing merge authorization / auto-merge contracts +- Publishing a separate npm package for the advisor runtime + +## High-Level Technical Design + +### Hybrid topology + +```mermaid +flowchart TB + subgraph keep [Lifecycle Supervisor - keep] + Poll["ProjectEngine poll ~45s"] + Mon["PlannerOverseerMonitor"] + Rec["PlannerRecoveryController"] + Poll --> Mon --> Rec + Rec --> Rules["inject_guidance / retry / confirm"] + end + + subgraph new [Session Advisor - new] + Log["AgentLogger / executor turn boundary"] + RT["OverseerAdvisorRuntime"] + Ag["Advisor Agent + tools"] + Guard["OverseerEmissionGuard"] + Log --> RT --> Ag --> Guard + end + + Rules --> Del["Unified delivery"] + Guard --> Del + Del --> SC["addSteeringComment"] + Del --> Audit["overseer:intervention + timeline"] + HC["evaluateOverseerHumanControl + effective level"] -.-> Rec + HC -.-> Guard +``` + +### Level × path matrix + +| Effective level | Lifecycle supervisor | Session advisor | +|---|---|---| +| `off` | idle | idle | +| `observe` | record observations | LLM may run; **no inject** (timeline/observe only) | +| `steer` | no retry/re-enqueue | LLM injects nit/concern (and blocker as strong steer) | +| `autonomous` | full bounded recovery | full advice + recovery coupling | + +### Session advisor runtime loop (directional) + +``` +onExecutorLogFlush / turn-end marker + → render delta since cursor (filter prior advisories) + → optional backlog wait (default off) + → advisor.prompt(batch) + → advise(note, severity?) → emission guard + → if accepted and level allows inject → addSteeringComment + intervention + → on 3 consecutive advisor failures → drop backlog, notify once, continue executor +``` + +### Delivery mapping + +| Severity | v1 Fusion delivery | +|---|---| +| silence | no comment | +| `nit` | steering comment, metadata `severity=nit` | +| `concern` / `blocker` | steering comment, higher severity metadata; immuneTurns suppresses repeated hard steers | + +## Key Technical Decisions + +- KTD1. **Composite overseer, not a rewrite:** keep FN-7511–7520 modules; add parallel session-advisor modules and a thin composition point in `ProjectEngine` / executor wiring. +- KTD2. **Steering channel reuse:** all advice lands through `TaskStore.addSteeringComment` so existing executor mid-flight injection and step-session paths apply without a new IPC channel. +- KTD3. **Emission guard in code first:** port OMP `AdvisorEmissionGuard` semantics (normalize, content-free phrase set, session dedupe FIFO, one accept per advisor update, severity-rank escalation for same note text) before enabling LLM defaults. +- KTD4. **Delta source = agent log stream:** hook after durable agent-log flushes (and/or explicit turn-end markers if present), not a second 45s poll of task rows. Cursor seeds when advising enables mid-task; resets on session switch / worktree rebind / compaction-like history rewrite. +- KTD5. **Model gate separate from oversight level:** session AI runs only when (level ≠ off) **and** an overseer/advisor model resolves; missing model is soft-disable with a single diagnostic, not a hard task failure. +- KTD6. **Read-only tool pool in v1:** grant investigative tools scoped to the task worktree; no mutating tools. +- KTD7. **Lifecycle canned guidance remains fallback:** when AI is unavailable, existing `decidePlannerRecovery` + canned `[planner-oversight] ${reason}` paths still operate; when AI is available, prefer AI-authored notes for inject_guidance-class actions. +- KTD8. **Cost defaults conservative:** `syncBacklog` default `off`; immune turns default `3`; no lockstep blocking of the executor. +- KTD9. **Postgres/storage-neutral design:** advisor state is in-memory runtime + existing store APIs (steering comments, run-audit, agent logs). No new SQLite assumptions after the PG cutover on main. + +## Implementation Units + +### U1. Advice contracts and emission guard + +**Goal:** Shared types and pure emission policy that make silence/dedupe load-bearing. + +**Requirements:** R2, R3 + +**Dependencies:** none + +**Files:** +- create: `packages/core/src/overseer-advice.ts` +- create: `packages/core/src/overseer-emission-guard.ts` +- create: `packages/core/src/__tests__/overseer-emission-guard.test.ts` +- modify: `packages/core/src/index.ts` (exports) + +**Approach:** Define `OverseerAdviceSeverity`, `OverseerAdviceNote`, severity rank (nit < concern < blocker), and `OverseerEmissionGuard` with `beginUpdate` / `accept` / `reset`. Mirror OMP normalization (NFKC, alnum fold) and the conservative content-free phrase set. No engine imports. + +**Patterns to follow:** pure never-throw style of `packages/core/src/planner-recovery.ts` and `packages/core/src/planner-confirmation.ts`. + +**Test scenarios:** +- Empty / whitespace-only note rejected +- `"Stop."`, `"LGTM"`, `"no issue; continue."` variants rejected after normalize +- Same note text accepted once; second identical call rejected +- Same text at higher severity accepted once (nit → concern escalation) +- After `beginUpdate`, second different note in same update rejected; next `beginUpdate` allows one again +- `reset` clears history so prior note can re-fire + +**Verification:** core unit tests green; package exports resolve. + +--- + +### U2. Steering and intervention metadata for severity / source + +**Goal:** Operators and agents can distinguish lifecycle canned guidance from session advice and see severity on the timeline. + +**Requirements:** R2, R9 + +**Dependencies:** U1 + +**Files:** +- modify: `packages/core/src/planner-intervention.ts` (and/or types for intervention metadata) +- modify: `packages/core/src/planner-overseer-events.ts` +- modify: `packages/core/src/__tests__/planner-overseer-events.test.ts` +- modify: `packages/engine/src/project-engine.ts` (handler comment prefixes / metadata) +- modify: `packages/dashboard/app/components/PlannerInterventionTimeline.tsx` (severity/source display) +- modify: dashboard timeline tests if present + +**Approach:** Extend intervention entries with optional `severity`, `source` (`lifecycle` | `session-advisor` | `manual`), and optional `advisorSlug`. Keep backward compatibility for existing timeline rows. Emit façade helpers accept the new optional fields without breaking call sites. + +**Patterns to follow:** `emitOverseerSteering` / `recordPlannerIntervention` additive metadata style (FN-7520). + +**Test scenarios:** +- Existing intervention parse still works without severity/source +- New emission with severity+source round-trips through parse helpers +- Lifecycle inject path still records timeline entries +- Timeline UI renders severity badge when present; omits when absent + +**Verification:** core + dashboard component tests for timeline rendering. + +--- + +### U3. Session delta runtime (no LLM) + +**Goal:** OMP-shaped cursor/backlog/reset runtime over executor log deltas, fully testable with fakes. + +**Requirements:** R1, R8 + +**Dependencies:** U1 + +**Files:** +- create: `packages/engine/src/overseer-advisor-runtime.ts` +- create: `packages/engine/src/overseer-session-delta.ts` (log entries → markdown batch) +- create: `packages/engine/src/__tests__/overseer-advisor-runtime.test.ts` +- create: `packages/engine/src/__tests__/overseer-session-delta.test.ts` + +**Approach:** Port the control plane of OMP `AdvisorRuntime`: pending batches, backlog count, epoch invalidation on reset/dispose, 3-failure drop, `seedTo`, filter self-advisories from deltas. Host interface: `snapshotDelta()`, `enqueueAdvice()`, `beginAdvisorUpdate()`, optional `onTurnError` / `notifyFailure`. No real model in this unit — fake agent records prompts. + +**Patterns to follow:** OMP `runtime.ts` control flow; Fusion degrade-to-no-op conventions from `PlannerRecoveryController`. + +**Test scenarios:** +- Happy path: two flushes produce two deltas then one drained prompt when batched +- Seed mid-session: first update does not replay entire history +- Reset after “session switch”: epoch drops in-flight batch; next prompt is re-prime/replay from new cursor +- Self-advisory lines filtered out of next delta +- Three consecutive prompt failures drop backlog and call notify once +- Dispose aborts and clears waiters + +**Verification:** engine unit tests with fake timers only (no real polling sleeps). + +--- + +### U4. Executor log-tail seam + +**Goal:** Wire runtime to real executor logging without blocking the executor path. + +**Requirements:** R1, R8, R10 + +**Dependencies:** U3 + +**Files:** +- modify: `packages/engine/src/agent-logger.ts` (optional non-blocking `onEntriesFlushed` / turn marker hook) +- modify: `packages/engine/src/executor.ts` and/or `packages/engine/src/project-engine.ts` (construct/dispose per-task runtime) +- create: `packages/engine/src/__tests__/overseer-advisor-wiring.test.ts` (or extend existing executor harness if lighter) + +**Approach:** After durable log flush for agent role `executor`, notify the task’s advisor runtime with best-effort async (never throw into AgentLogger). Create runtime when task enters in-progress with advising eligible; dispose/clear on terminal or lane change. Prefer fail-soft if store/getAgentLogs is unavailable. + +**Patterns to follow:** AgentLogger external callbacks (`onAgentText`); ProjectEngine planner-overseer poll init/teardown. + +**Test scenarios:** +- Flush with advising eligible → runtime `onTurnEnd` invoked +- Flush when level `off` or no model → no runtime prompt +- Logger flush failure in subscriber does not reject AgentLogger.flush +- Task completion disposes runtime and clears maps (no leak across tasks) + +**Verification:** focused engine tests; no full-suite requirement. + +--- + +### U5. LLM advisor agent + advise tool + model gate + +**Goal:** Real second model produces notes that pass the guard and inject when policy allows. + +**Requirements:** R1–R3, R5–R7, R9 + +**Dependencies:** U2, U4 + +**Files:** +- create: `packages/engine/src/overseer-advise-tool.ts` +- create: `packages/engine/src/overseer-advisor-session.ts` (build agent, tools, system prompt packing) +- create: `packages/engine/src/__tests__/overseer-advise-tool.test.ts` +- create: `packages/engine/src/__tests__/overseer-advisor-session.test.ts` (mock/scripted provider) +- modify: `packages/core/src/builtin-workflow-settings.ts` and/or model-role resolution surfaces (overseer model setting) +- modify: `packages/engine/src/project-engine.ts` (compose delivery with human-control + level) +- docs settings as needed in U8 + +**Approach:** Isolated session identity (`-overseer` / agent=`overseer`). Tools: worktree-scoped read/grep/glob + `advise`. System prompt: OMP peer-reviewer framing adapted to Fusion (PROMPT.md, File Scope, verification, don’t restate known tool errors). `advise` executes → emission guard → if inject allowed, `addSteeringComment` + intervention. Attribute logs with agent overseer. Mock/scripted provider is the CI path. + +**Patterns to follow:** existing multi-lane model resolution; mock provider `testMode` forcing; FN-7514 human-control before inject. + +**Test scenarios:** +- Scripted model returns concern → steering comment text is the note (not canned lifecycle reason) +- Scripted model returns “no issue continue” phrase → guard drops; no steering comment +- User-paused task → no inject even if model advises +- Level `observe` → intervention/observe recorded, no steering comment +- Level `off` or missing model → no advisor prompt +- Merge stage not handled by session advisor (lifecycle only) + +**Verification:** mock-provider engine tests; intervention timeline shows session-advisor source. + +--- + +### U6. OVERSEER.md / WATCHDOG.md discovery + +**Goal:** Project and user review priorities append to the advisor system prompt. + +**Requirements:** R4 + +**Dependencies:** U5 + +**Files:** +- create: `packages/engine/src/overseer-watchdog.ts` +- create: `packages/engine/src/__tests__/overseer-watchdog.test.ts` +- modify: `packages/engine/src/overseer-advisor-session.ts` (prompt assembly) + +**Approach:** Discover readable candidates from user agent dir + walk cwd → repo root for `OVERSEER.md`, `WATCHDOG.md`, and `.fusion/` / `.omp/` variants. Concatenate user-first then ancestor→leaf. Malformed/missing files never throw. Expand `@imports` only if an existing Fusion helper already does for context files; otherwise ship plain content first and note import expansion as follow-up. + +**Patterns to follow:** OMP `collectConfigCandidates` search path; Fusion project context loading conventions. + +**Test scenarios:** +- No files → undefined/empty blocks, session still builds +- User + project files both load; leaf project more prominent (order asserted) +- Only `WATCHDOG.md` present still loads (alias) +- Unreadable path skipped without throwing + +**Verification:** unit tests with temp dirs (bounded, not system `$TMPDIR` walks). + +--- + +### U7. Lifecycle ↔ advisor coupling (bounded) + +**Goal:** AI and rules reinforce each other without double-spamming. + +**Requirements:** R1, R7, R8 + +**Dependencies:** U5 + +**Files:** +- modify: `packages/engine/src/planner-recovery-controller.ts` and/or `packages/engine/src/project-engine.ts` handlers +- modify: `packages/core/src/planner-recovery.ts` only if pure decision needs an optional “hasRecentAdvisorBlocker” input (prefer engine-side) +- create/modify: `packages/engine/src/__tests__/overseer-lifecycle-coupling.test.ts` +- optional: feed StuckTaskDetector / stall path only via existing signals + +**Approach:** When session advisor accepts a `blocker` about churn, allow lifecycle to treat guidance as already spent or to accelerate a single targeted-fix steering comment with log refs — without bypassing attempt budgets. When AI is healthy and already advised this stage, suppress identical canned lifecycle inject_guidance text via emission guard. Keep merge confirmation path untouched. + +**Patterns to follow:** FN-7577 healthy-signal no-op; FN-7743 stall detection remains independent fail-safe. + +**Test scenarios:** +- Advisor concern already injected → lifecycle same-reason canned inject deduped +- Advisor disabled → lifecycle canned inject still works on stuck +- Exhausted recovery budget still escalates once + +**Verification:** controller unit tests with fake snapshot + fake handlers. + +--- + +### U8. Operator UX and documentation + +**Goal:** Status visibility and docs for the new layer without a full redesign. + +**Requirements:** R5, R9 + +**Dependencies:** U5 + +**Files:** +- modify: `packages/core/src/planner-overseer-state.ts` (optional fields: lastAdviceSeverity, advisorBacklog, advisorModel?) +- modify: `packages/engine/src/planner-overseer-runtime-snapshot.ts` +- modify: `packages/dashboard/app/components/TaskDetailModal.tsx` (oversight cluster status strip) +- modify: related dashboard tests +- modify: `docs/architecture.md`, `docs/settings-reference.md`, `docs/dashboard-guide.md`, `docs/getting-started.md` pointer +- changeset for `@runfusion/fusion` if published surface/behavior changes + +**Approach:** Additive snapshot fields; never fail board load. Document level matrix, model gate, OVERSEER.md locations, cost defaults. Changeset category `feature` with operator-facing summary. + +**Patterns to follow:** FN-7531 snapshot enrichment; existing oversight cluster controls. + +**Test scenarios:** +- Snapshot without advisor → existing fields only +- Snapshot with backlog/last severity → detail UI shows them +- Settings reference documents new keys + +**Verification:** dashboard unit tests + docs updated; `pnpm check:changesets` if changeset added. + +--- + +## Alternative Approaches Considered + +| Approach | Why not (for this plan) | +|---|---| +| Replace lifecycle overseer entirely with OMP-style advisor | Loses merge confirmation, multi-stage recovery, board semantics Fusion already ships | +| Poll agent logs every 45s only (no turn-boundary hook) | Too laggy for “wrong direction” advice; worse than OMP turn-end | +| Always-on second model for every autonomous task | Cost blow-up; rejected via A1 model gate | +| Full multi-advisor YAML in v1 | High config surface; defer until single-advisor path is proven | + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| Cost explosion across many concurrent tasks | Model gate default off; backlog off; 1 note/update; cheap model role | +| Advice spam (OMP #3520 class) | U1 guard before LLM ship | +| Recursive self-review | Filter advisory-origin content from deltas | +| Executor latency | Never block executor by default | +| Secrets in transcript | Worktree-scoped tools; reuse secret-redaction if present; no secrets tools | +| Human-control regression | Re-check guard at inject; tests in U5 | +| PG migration / storage churn on main | No new local DB files; use store APIs only (KTD9) | +| Flaky tests | Mock provider + fake timers; quarantine-on-sight policy | + +**Dependencies:** Existing executor steering injection; agent-log persistence; planner oversight settings; mock provider for CI. + +## Phased Delivery + +1. **Foundation:** U1 → U2 → U3 +2. **Wire + AI:** U4 → U5 → U6 +3. **Harden + UX:** U7 → U8 + +Recommended first shippable milestone: **U1–U6** (executor session advisor with docs light), then U7–U8 polish. + +## Success Metrics + +- Executor going wrong-direction receives a specific note without waiting for the 2h stall threshold +- Healthy progressing tasks do not accumulate noise interventions from the session advisor +- User-paused / autoMerge-off tasks never receive advisor injects +- Advisor off or failing does not block task completion or lifecycle recovery +- Overseer token usage visible separately from executor + +## Documentation Plan + +- Architecture: hybrid supervisor + session advisor subsection +- Settings reference: model gate, levels, optional backlog/immune turns, OVERSEER.md paths +- Dashboard guide: timeline severity/source; detail status strip +- Getting started: one-line pointer + +## Open Questions + +None blocking. Redirect A1–A4 before U5 if product defaults should change. + +## Deferred Implementation Notes + +- Exact helper names and whether advise is a pi tool vs internal function +- Whether turn-end is an explicit session event or inferred from tool_result quiescence +- Optional `@import` expansion for OVERSEER.md +- Whether immuneTurns is workflow setting or constant in v1 + +## Sources & Research + +- Upstream: [oh-my-pi advisor package](https://github.com/can1357/oh-my-pi/tree/main/packages/coding-agent/src/advisor), [advisor-watchdog.md](https://github.com/can1357/oh-my-pi/blob/main/docs/advisor-watchdog.md) +- Local lifecycle stack (still present on main after 2026-07-13 refresh): + `packages/engine/src/planner-overseer.ts`, `planner-recovery-controller.ts`, `overseer-human-control-policy.ts`, `packages/core/src/planner-recovery.ts`, `planner-overseer-events.ts`, `project-engine.ts` poll wiring +- Delivery seam: `TaskStore.addSteeringComment` + executor real-time steering injection in `packages/engine/src/executor.ts` +- Log seam: `packages/engine/src/agent-logger.ts` +- Strategy alignment: Fusion orchestration track (task completion quality / concurrent agent reliability) — session advising reduces wasted executor turns + +## System-Wide Impact + +- Engine concurrency: one additional model stream per advised in-progress task when enabled +- Run-audit volume: more `overseer:intervention` rows when AI advises; keep dedupe +- Dashboard: timeline + detail only; board list stays best-effort snapshot +- No merge contract change; no multi-node protocol change + +--- + +## Prior draft + +Supersedes the freeform gap analysis previously at `docs/plans/2026-07-11-001-feat-overseer-advisor-parity-plan.md` (rewritten into this ce-plan contract after `origin/main` refresh). diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 649680b668..5827e10fc4 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -335,7 +335,7 @@ These groups moved out of project settings and into workflow settings (built-in |---|---| | **Step execution** | `workflowStepTimeoutMs`, `runStepsInNewSessions`, `maxParallelSteps`, `workflowStepScopeEnforcement`, `strictScopeEnforcement`, `verificationFixRetries`, `maxPostReviewFixes`, `buildRetryCount` | | **Review / approval** | Workflow values: `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`, `planReviewMaxRevisions`, `codeReviewMaxRevisions`; project override: `planApprovalMode` | -| **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h) | +| **Planner oversight** | `plannerOversightLevel` (workflow-native; values: `off`, `observe`, `steer`, `autonomous`); `plannerOversightNotificationLevel` (workflow-native; values: `silent`, `errors`, `important`, `all`); `plannerOverseerExecutorStuckAfterMs` (workflow-native; number, default `7200000` = 2h); `plannerOverseerAdvisorEnabled` (boolean, **default false**); `plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId` (session-advisor model; both required when enabled) | | **Per-phase model lanes** | `executionProvider`/`executionModelId` + `executionThinkingLevel`, `planningProvider`/`planningModelId` + `planningThinkingLevel` (+ fallbacks), `validatorProvider`/`validatorModelId` + `validatorThinkingLevel` (+ fallbacks). Thinking values accept `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`; unset inherits. | ### Workflow-native triage policy settings @@ -371,6 +371,9 @@ The built-in workflows also declare triage/spec policy settings that were **not* | `codeReviewMaxRevisions` | unset | Workflow-native Code Review remediation cap. Unset/empty means unbounded automatic code-fix passes; a non-negative integer caps attempts; `0` disables automatic Code Review remediation. | | `plannerOversightLevel` | `autonomous` | Workflow-native planner oversight mode. `off` disables oversight; `observe` watches only; `steer` injects guidance or suggests revisions; `autonomous` enables bounded retry and targeted-fix recovery — but merge/PR progression and any destructive or external-service side effect ALWAYS require an explicit, recorded human confirmation before they run, even at `autonomous` (FN-7513's confirmation gate; see `docs/architecture.md` → "Planner overseer confirmation gate"). Tasks may set a nullable `Task.plannerOversightLevel` override (same four values) that wins over this workflow value when present; `null`/unset means "inherit the workflow value". `resolveEffectivePlannerOversightLevel` in `@fusion/core` computes the effective level (task override → workflow effective → `autonomous`). The per-task override is exposed in the dashboard as a "Planner oversight" selector (Inherit from workflow / Off / Observe / Steer / Autonomous recovery) in both the New Task dialog and Task Detail edit form, threaded through `createTask`/`updateTask` (FN-7515); the project/global default is set via the **Workflow Editor → Values** tab on the default workflow's `plannerOversightLevel` value, not in Project Settings. FN-7517 additionally exposes a quick inline oversight-level select in the Task Detail modal's meta-controls cluster (same `updateTask` override plumbing, no parallel path) plus manual nudge/stop-oversight/explain-current-action controls that call the overseer runtime directly — see `docs/dashboard-guide.md`. Engine read-site behavior beyond the FN-7513 confirmation gate remains follow-up work (FN-7510+). | | `plannerOversightNotificationLevel` | `important` | Workflow-native planner-overseer notification verbosity (FN-7518). `silent` suppresses overseer notifications; `errors` notifies only on failures/escalations; `important` (the default) notifies on interventions/recovery actions and errors; `all` notifies on every observation. Resolves through the generic `resolveEffectiveSettings` default path with no special-casing, alongside `plannerOversightLevel`. This is a declaration-only setting: the notification-emission gating that reads it lands downstream in FN-7519 (intervention timeline) and FN-7520 (run-audit/activity events). | +| `plannerOverseerAdvisorEnabled` | `false` | Master switch for the planner overseer **session advisor** (live LLM transcript review). **Off by default.** When false, no second-model advisor runs regardless of model fields or `plannerOversightLevel`. Lifecycle stage watching, stall recovery, and merge confirmation are unaffected. | +| `plannerOverseerAdvisorProvider` | `""` | Session-advisor model provider (OMP advisor parity). Used only when `plannerOverseerAdvisorEnabled` is true. Must be set together with `plannerOverseerAdvisorModelId`. | +| `plannerOverseerAdvisorModelId` | `""` | Session-advisor model id. Used only when `plannerOverseerAdvisorEnabled` is true. When enabled and both model fields are set, the advisor reviews executor agent-log deltas and may inject `[session-advisor]` steering comments at `steer`/`autonomous` (observe = log only). Discover project review priorities via `OVERSEER.md` / `WATCHDOG.md`. See `docs/architecture.md` → "Planner overseer session advisor". | | `plannerOverseerExecutorStuckAfterMs` | `7200000` (2h) | Workflow-native executor-stage stall threshold (FN-7743). Milliseconds of executor-stage inactivity — no execution activity since the task's last column move/update (`columnMovedAt ?? updatedAt`) — before a non-paused `in-progress` task is reported `signal: "stuck"` instead of `"progressing"`, feeding the existing `decidePlannerRecovery` → bounded `inject_guidance` recovery path at the `autonomous` oversight level (no effect at `off`/`observe`/`steer`). Fixes the class of bug where a genuinely hung/idle executor (dead session, silent agent) was indistinguishable from a healthy one and was never nudged, retried, or escalated. A missing/malformed activity timestamp degrades to `"progressing"` (fail-safe — never fabricates a stall), and a user-paused/approval-blocked/`autoMerge:false` task is still fully withheld from any autonomous action regardless of this threshold. Resolves through the generic `resolveEffectiveSettings` default path alongside `plannerOversightLevel`. See `docs/architecture.md` → "Executor-stage stall detection (FN-7743)". | When `triageProactiveSubtaskSplittingEnabled` is `true` (the default), triage may proactively replace a large task with 2-5 child tasks when the size, step-count, package breadth, file-scope, or remediation-batch signals justify the coordination overhead. When it is `false`, those automatic oversized-task signals are advisory only for writing a realistic single-task spec; triage must not split solely because the task is large. The per-task `breakIntoSubtasks: true` flag is separate and remains mandatory: if a user explicitly asks for subtask breakdown, triage still evaluates and creates child tasks when the work is meaningfully decomposable. @@ -624,6 +627,7 @@ Default notes: | `githubCommentTemplate` | `string` | `undefined` | Optional issue comment template used by `githubCommentOnDone`. Supports `{taskId}` and `{taskTitle}` placeholders. If unset, Fusion uses a default completion message. When the linked source issue's repository is the Fusion self-repo (`runfusion/fusion`, case-insensitive), Fusion appends a `Current version: v` line and a `Target release: v` line (next-minor bump, patch reset to 0, e.g. `0.55.0` → `0.56.0`), resolved via the published `@runfusion/fusion` CLI package version. If that version is unresolved/unparseable, the base comment is posted with no version lines. Comments on every other repository are byte-for-byte unchanged. | | `githubCloseSourceIssueOnDone` | `boolean` | `false` | When enabled, source-imported GitHub issues are automatically closed with `state_reason: completed` when the Fusion task moves to `done`. A startup reconciliation sweep also closes missed open source issues on boot. | | `githubTrackingEnabledByDefault` | `boolean` | `false` | Project-level default for enabling issue tracking on ordinary new tasks. When this is false, the Quick Entry GitHub toggle is disabled until tracking is enabled in Settings. Imported GitHub issues still follow this default unless `githubLinkImportedIssuesToTracking` is enabled. | +| `sessionAdvisorEnabledByDefault` | `boolean` | `false` | Project-level default for the session advisor (LLM overseer agent that reviews live executor transcripts). Off by default (opt-in). Quick Add exposes an eye toggle next to GitHub that inherits this default; each task can override via `sessionAdvisorEnabled`. Provider and model ids still come from workflow settings (`plannerOverseerAdvisorProvider` / `plannerOverseerAdvisorModelId`). Dashboard location: **Settings → Project → General → Session advisor (overseer agent)**. | | `githubLinkImportedIssuesToTracking` | `boolean` | `false` | Project-scoped, import-only option. When enabled, GitHub issue imports from the dashboard, CLI, and extension tools persist `githubTracking: { enabled: true }` so Fusion adopts the imported source issue as the tracking issue without turning tracking on for ordinary new tasks. Duplicate/skipped imports do not create tasks or tracking metadata. | | `githubTrackingDefaultRepo` | `string` | `undefined` | Project default issue-tracking repo (`owner/repo`) used before global fallback for tracked task creation (precedence: task override → project default → global default). In Settings UI this is a detected-remote dropdown with a Custom fallback for manual entry. This key is dual-scope: project saves go through `PUT /api/settings` (Settings → General → GitHub Tracking) while global saves go through `PUT /api/settings/global` (Settings → Global General). | | `gitlabEnabled` | `boolean` | `undefined` (effective global fallback, then `true`) | Project GitLab integration enable switch. Explicit `false` disables outbound GitLab API imports, completion comments, close/reopen, source closed-at backfill, and tracking refresh side effects for this project without deleting saved URL/token fields. Dashboard location: **Settings → Project → General → GitLab Configuration** and **Settings → Project → Merge → GitLab Authentication** disclosure headers. | diff --git a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts index 378c26daa1..d5fa31f554 100644 --- a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts +++ b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts @@ -90,7 +90,15 @@ describe("workflow-native built-in workflow settings", () => { "plannerOversightLevel", "plannerOversightNotificationLevel", "plannerOverseerExecutorStuckAfterMs", + "plannerOverseerAdvisorEnabled", + "plannerOverseerAdvisorProvider", + "plannerOverseerAdvisorModelId", ]); + // FNXC:PlannerOversight 2026-07-14-12:00: LLM session advisor must default OFF. + expect(BUILTIN_OVERSIGHT_SETTINGS.find((s) => s.id === "plannerOverseerAdvisorEnabled")).toMatchObject({ + type: "boolean", + default: false, + }); const oversight = BUILTIN_OVERSIGHT_SETTINGS[0]; expect(oversight).toMatchObject({ type: "enum", diff --git a/packages/core/src/__tests__/overseer-emission-guard.test.ts b/packages/core/src/__tests__/overseer-emission-guard.test.ts new file mode 100644 index 0000000000..05553e4abf --- /dev/null +++ b/packages/core/src/__tests__/overseer-emission-guard.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeOverseerAdviceNote, + normalizeOverseerAdviceSeverity, + overseerAdviceSeverityRank, +} from "../overseer-advice.js"; +import { OverseerEmissionGuard } from "../overseer-emission-guard.js"; + +describe("normalizeOverseerAdviceNote", () => { + it("folds case, punctuation, and whitespace into one key", () => { + expect(normalizeOverseerAdviceNote("Stop.")).toBe("stop"); + expect(normalizeOverseerAdviceNote("*Stop*")).toBe("stop"); + expect(normalizeOverseerAdviceNote(" STOP ")).toBe("stop"); + expect(normalizeOverseerAdviceNote("No issue; continue.")).toBe("no issue continue"); + }); +}); + +describe("normalizeOverseerAdviceSeverity", () => { + it("accepts known severities case-insensitively and rejects unknowns", () => { + expect(normalizeOverseerAdviceSeverity("blocker")).toBe("blocker"); + expect(normalizeOverseerAdviceSeverity("CONCERN")).toBe("concern"); + expect(normalizeOverseerAdviceSeverity("nit")).toBe("nit"); + expect(normalizeOverseerAdviceSeverity("warn")).toBeUndefined(); + expect(normalizeOverseerAdviceSeverity(null)).toBeUndefined(); + }); +}); + +describe("overseerAdviceSeverityRank", () => { + it("ranks omitted severity as nit", () => { + expect(overseerAdviceSeverityRank(undefined)).toBe(1); + expect(overseerAdviceSeverityRank("nit")).toBe(1); + expect(overseerAdviceSeverityRank("concern")).toBe(2); + expect(overseerAdviceSeverityRank("blocker")).toBe(3); + }); +}); + +describe("OverseerEmissionGuard", () => { + it("rejects empty and whitespace-only notes", () => { + const guard = new OverseerEmissionGuard(); + expect(guard.accept("")).toBe(false); + expect(guard.accept(" ")).toBe(false); + expect(guard.accept({ note: "\n\t" })).toBe(false); + }); + + it("rejects content-free phrases after normalization", () => { + const guard = new OverseerEmissionGuard(); + expect(guard.accept("Stop.")).toBe(false); + expect(guard.accept("LGTM")).toBe(false); + expect(guard.accept("no issue; continue.")).toBe(false); + expect(guard.accept("nothing to add")).toBe(false); + }); + + it("accepts a concrete note once and rejects an equal-severity repeat", () => { + const guard = new OverseerEmissionGuard(); + const note = "You are editing the wrong module; File Scope says packages/engine only."; + expect(guard.accept({ note, severity: "concern" })).toBe(true); + expect(guard.accept({ note, severity: "concern" })).toBe(false); + expect(guard.accept({ note, severity: "nit" })).toBe(false); + }); + + it("allows the same note text when severity strictly escalates", () => { + const guard = new OverseerEmissionGuard(); + const note = "Missing await on writeStream.end will drop buffered writes."; + expect(guard.accept({ note, severity: "nit" })).toBe(true); + // Per-update budget already consumed — need a new update cycle. + guard.beginUpdate(); + expect(guard.accept({ note, severity: "concern" })).toBe(true); + guard.beginUpdate(); + expect(guard.accept({ note, severity: "blocker" })).toBe(true); + guard.beginUpdate(); + expect(guard.accept({ note, severity: "blocker" })).toBe(false); + }); + + it("allows only one accept per update cycle; noise does not burn the slot", () => { + const guard = new OverseerEmissionGuard(); + expect(guard.accept("Stop.")).toBe(false); + expect( + guard.accept({ + note: "Parallelize the two independent test runs.", + severity: "nit", + }), + ).toBe(true); + expect( + guard.accept({ + note: "Also extract the shared helper.", + severity: "nit", + }), + ).toBe(false); + guard.beginUpdate(); + expect( + guard.accept({ + note: "Also extract the shared helper.", + severity: "nit", + }), + ).toBe(true); + }); + + it("reset clears history so a prior note can re-fire", () => { + const guard = new OverseerEmissionGuard(); + const note = "Re-check File Scope before editing dashboard."; + expect(guard.accept({ note })).toBe(true); + guard.reset(); + expect(guard.accept({ note })).toBe(true); + }); + + it("FIFO-evicts oldest keys when capacity is exceeded", () => { + const guard = new OverseerEmissionGuard({ capacity: 2 }); + expect(guard.accept({ note: "alpha unique advice" })).toBe(true); + guard.beginUpdate(); + expect(guard.accept({ note: "beta unique advice" })).toBe(true); + guard.beginUpdate(); + expect(guard.accept({ note: "gamma unique advice" })).toBe(true); + // alpha was evicted; may re-accept after a new update + guard.beginUpdate(); + expect(guard.accept({ note: "alpha unique advice" })).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index cd068e3a50..cae8364cef 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -37,6 +37,7 @@ import { MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION, MULTI_PROJECT_CUTOVER_SCHEMA_VERSION, PROJECT_OWNERSHIP_SCHEMA_VERSION, + SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, SQLITE_SCHEMA_PARITY_VERSION, } from "../../postgres/schema-applier.js"; import { rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; @@ -76,7 +77,12 @@ describe("schema-applier: immutable migration identities", () => { it("keeps SQLite schema parity assigned to version 0007", () => { expect(SQLITE_SCHEMA_PARITY_VERSION).toBe("0007"); - expect(SCHEMA_BASELINE_VERSION).toBe(SQLITE_SCHEMA_PARITY_VERSION); + expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(SQLITE_SCHEMA_PARITY_VERSION)); + }); + + it("keeps session advisor enabled column assigned to version 0008", () => { + expect(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION).toBe("0008"); + expect(SCHEMA_BASELINE_VERSION).toBe(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); }); }); @@ -398,7 +404,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version = '0007'; + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0007', '0008'); ALTER TABLE project.tasks DROP COLUMN board_id, DROP COLUMN task_question_interrupt, @@ -431,6 +437,32 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", expect(await getAppliedMigrations(ctx.db)).toContain(SQLITE_SCHEMA_PARITY_VERSION); }); + /* + FNXC:PlannerOversight 2026-07-14-18:49: + A cluster that already recorded through 0007 must still gain session_advisor_enabled + before TaskStore SELECTs run — Gate boot-smoke failure mode on this branch. + */ + it("upgrades a 0007 target with session_advisor_enabled", async () => { + ctx = await setupFreshDb(); + await applySchemaBaseline(ctx.db, { pluginHooks: [] }); + await ctx.db.execute(sql.raw(` + DELETE FROM public.fusion_schema_migrations WHERE version = '0008'; + ALTER TABLE project.tasks DROP COLUMN session_advisor_enabled; + `)); + + expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true); + const columns = (await ctx.db.execute(sql` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'project' + AND table_name = 'tasks' + AND column_name = 'session_advisor_enabled' + `)) as unknown as Array<{ column_name: string }>; + expect(columns).toEqual([{ column_name: "session_advisor_enabled" }]); + expect(await getAppliedMigrations(ctx.db)).toContain(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); + }); + + /* FNXC:ProjectDataIsolation 2026-07-14-12:10: Every table in the shared PostgreSQL project schema is project-owned unless it is one of the three explicitly cluster-wide coordination tables. Require a physical project_id plus forced row-level security so a missed application predicate cannot expose agents, secrets, inbox messages, missions, workflows, or plugin data to another project. @@ -656,7 +688,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0006', '0007'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0006', '0007', '0008'); CREATE TABLE public.fusion_sqlite_migrations ( migration_key text PRIMARY KEY, project_id text, @@ -714,7 +746,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)", ALTER TABLE project.agent_heartbeats ADD CONSTRAINT agent_heartbeats_legacy_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES project.agents(id) ON DELETE CASCADE; - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0006', '0007'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0006', '0007', '0008'); `)); await expect(applySchemaBaseline(ctx.db)).resolves.toMatchObject({ applied: true }); @@ -934,7 +966,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0002', '0003', '0004', '0005', '0006', '0007'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0002', '0003', '0004', '0005', '0006', '0007', '0008'); DROP POLICY fusion_project_isolation ON project.activity_log; DROP POLICY fusion_project_isolation ON project.agent_runs; DROP POLICY fusion_project_isolation ON project.usage_events; @@ -963,7 +995,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ))) as unknown as Array<{ project_id: string }>; expect(rows).toEqual([{ project_id: "project-a" }]); } - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008"]); }); /** @@ -974,7 +1006,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0003', '0004', '0005', '0006', '0007'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0003', '0004', '0005', '0006', '0007', '0008'); DROP POLICY fusion_project_isolation ON project.deployments; DROP POLICY fusion_project_isolation ON project.incidents; DROP POLICY fusion_project_isolation ON project.approval_request_audit_events; @@ -1001,7 +1033,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ))) as unknown as Array<{ project_id: string }>; expect(rows).toEqual([{ project_id: "project-a" }]); } - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008"]); }); /* @@ -1012,7 +1044,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { ctx = await setupFreshDb(); await applySchemaBaseline(ctx.db, { pluginHooks: [] }); await ctx.db.execute(sql.raw(` - DELETE FROM public.fusion_schema_migrations WHERE version IN ('0004', '0005', '0006', '0007'); + DELETE FROM public.fusion_schema_migrations WHERE version IN ('0004', '0005', '0006', '0007', '0008'); DROP TABLE project.project_auth_sessions; DROP TABLE project.project_auth_providers; DROP TABLE project.project_auth_memberships; @@ -1039,7 +1071,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { "project_auth_users", "task_reviewer_runs", ]); - expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007"]); + expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008"]); }); }); diff --git a/packages/core/src/__tests__/session-advisor.test.ts b/packages/core/src/__tests__/session-advisor.test.ts new file mode 100644 index 0000000000..98b32c6d3f --- /dev/null +++ b/packages/core/src/__tests__/session-advisor.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { resolveTaskSessionAdvisorEnabled } from "../session-advisor.js"; + +describe("resolveTaskSessionAdvisorEnabled", () => { + it("defaults to off when nothing is set", () => { + expect(resolveTaskSessionAdvisorEnabled({})).toEqual({ + enabled: false, + source: "default", + }); + }); + + it("uses project default when task has no override", () => { + expect( + resolveTaskSessionAdvisorEnabled({}, { sessionAdvisorEnabledByDefault: true }), + ).toEqual({ enabled: true, source: "project" }); + expect( + resolveTaskSessionAdvisorEnabled({}, { sessionAdvisorEnabledByDefault: false }), + ).toEqual({ enabled: false, source: "default" }); + }); + + it("task override wins over project and workflow", () => { + expect( + resolveTaskSessionAdvisorEnabled( + { sessionAdvisorEnabled: false }, + { sessionAdvisorEnabledByDefault: true }, + true, + ), + ).toEqual({ enabled: false, source: "task" }); + expect( + resolveTaskSessionAdvisorEnabled( + { sessionAdvisorEnabled: true }, + { sessionAdvisorEnabledByDefault: false }, + false, + ), + ).toEqual({ enabled: true, source: "task" }); + }); + + it("falls back to workflow flag for backward compatibility", () => { + expect( + resolveTaskSessionAdvisorEnabled({}, { sessionAdvisorEnabledByDefault: false }, true), + ).toEqual({ enabled: true, source: "workflow" }); + }); + + it("project true wins over workflow false", () => { + expect( + resolveTaskSessionAdvisorEnabled({}, { sessionAdvisorEnabledByDefault: true }, false), + ).toEqual({ enabled: true, source: "project" }); + }); +}); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 37395ea5f4..e40860adc1 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -429,6 +429,7 @@ describe("settings key parity", () => { it("keeps github tracking keys in expected scopes with documented defaults", () => { expect(DEFAULT_PROJECT_SETTINGS.githubTrackingEnabledByDefault).toBe(false); + expect(DEFAULT_PROJECT_SETTINGS.sessionAdvisorEnabledByDefault).toBe(false); expect(DEFAULT_PROJECT_SETTINGS.githubLinkImportedIssuesToTracking).toBe(false); expect(DEFAULT_PROJECT_SETTINGS.githubTrackingDefaultRepo).toBeUndefined(); expect(DEFAULT_PROJECT_SETTINGS.githubAuthMode).toBe("gh-cli"); @@ -437,6 +438,8 @@ describe("settings key parity", () => { expect(isProjectSettingsKey("githubTrackingEnabledByDefault")).toBe(true); expect(isGlobalSettingsKey("githubTrackingEnabledByDefault")).toBe(false); + expect(isProjectSettingsKey("sessionAdvisorEnabledByDefault")).toBe(true); + expect(isGlobalSettingsKey("sessionAdvisorEnabledByDefault")).toBe(false); expect(isProjectSettingsKey("githubLinkImportedIssuesToTracking")).toBe(true); expect(isGlobalSettingsKey("githubLinkImportedIssuesToTracking")).toBe(false); expect(isGlobalOnlySettingsKey("githubLinkImportedIssuesToTracking")).toBe(false); diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 6ed43d2e18..05c58c0dde 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -540,6 +540,41 @@ export const BUILTIN_OVERSIGHT_SETTINGS: WorkflowSettingDefinition[] = [ description: "Milliseconds of executor-stage inactivity (no progress since the task's last column move/update) before the planner overseer reports the in-progress task as stuck, triggering bounded autonomous recovery (Autonomous level only). Default 7200000 (2 hours). Set higher to avoid nagging long-running steps; set lower to recover hung executors faster.", }, + /* + FNXC:PlannerOversight 2026-07-14-12:00: + Session-advisor (OMP parity) is OFF by default. Operators must flip + `plannerOverseerAdvisorEnabled` and set both provider + model id before any + second-model transcript review runs — even when plannerOversightLevel is + autonomous. Lifecycle supervisor (stall/retry/confirm) is unaffected. + + FNXC:PlannerOversight 2026-07-13-23:05: + Session-advisor model gate. Both provider + model id must be set for live + transcript advising when the feature is enabled. + */ + { + id: "plannerOverseerAdvisorEnabled", + name: "Session advisor (LLM)", + type: "boolean", + default: false, + description: + "Workflow-level enable for the planner overseer session advisor (live LLM transcript review). Prefer project Settings → General → Session advisor (and per-task / Quick Add eye toggle) for day-to-day control; this workflow flag still enables when the project default is off (backward compatible). When enabled, also set Session advisor model provider and model id. Does not change lifecycle stage watching, stall recovery, or merge confirmation.", + }, + { + id: "plannerOverseerAdvisorProvider", + name: "Session advisor model provider", + type: "string", + default: "", + description: + "Provider id for the planner overseer session advisor (live transcript review). Used only when Session advisor (LLM) is enabled. Must be set together with Session advisor model id.", + }, + { + id: "plannerOverseerAdvisorModelId", + name: "Session advisor model id", + type: "string", + default: "", + description: + "Model id for the planner overseer session advisor. Used only when Session advisor (LLM) is enabled. Must be set together with Session advisor model provider.", + }, ]; export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [ diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index eb4d019ce3..2fa3093b7a 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1025,6 +1025,8 @@ export { resolveTaskGithubTracking, } from "./github-tracking.js"; export type { RepoSlug, ResolvedTaskGithubTracking } from "./github-tracking.js"; +export { resolveTaskSessionAdvisorEnabled } from "./session-advisor.js"; +export type { ResolvedTaskSessionAdvisor } from "./session-advisor.js"; export { AUTOMATION_PRESETS, AUTOMATION_SELECTABLE_TOOLS, MAX_RUN_HISTORY } from "./automation.js"; export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult, AutomationSelectableTool } from "./automation.js"; export { AutomationStore } from "./automation-store.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 13a81dbda8..59112ac9be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,6 +69,13 @@ export { emitOverseerEscalation, } from "./planner-overseer-events.js"; export type { OverseerEventInput } from "./planner-overseer-events.js"; +/* +FNXC:PlannerOversight 2026-07-13-22:40: +Session-advisor (OMP advisor parity) vocabulary + emission guard. Pure +types/policy for severity-routed notes before they reach steering inject. +*/ +export * from "./overseer-advice.js"; +export * from "./overseer-emission-guard.js"; export * from "./frontend-ux-policy.js"; export * from "./file-scope-classification.js"; export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js"; @@ -1055,6 +1062,8 @@ export { resolveTaskGithubTracking, } from "./github-tracking.js"; export type { RepoSlug, ResolvedTaskGithubTracking } from "./github-tracking.js"; +export { resolveTaskSessionAdvisorEnabled } from "./session-advisor.js"; +export type { ResolvedTaskSessionAdvisor } from "./session-advisor.js"; export { AUTOMATION_PRESETS, AUTOMATION_SELECTABLE_TOOLS, MAX_RUN_HISTORY } from "./automation.js"; export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult, AutomationSelectableTool } from "./automation.js"; export { AutomationStore } from "./automation-store.js"; diff --git a/packages/core/src/overseer-advice.ts b/packages/core/src/overseer-advice.ts new file mode 100644 index 0000000000..43530060bc --- /dev/null +++ b/packages/core/src/overseer-advice.ts @@ -0,0 +1,72 @@ +/** + * FNXC:PlannerOversight 2026-07-13-22:40: + * Session-advisor (OMP advisor parity) vocabulary for severity-routed advice + * notes. The lifecycle planner overseer (FN-7511–7520) remains rule-based; + * the session advisor layer produces concrete notes that ride the existing + * steering-comment channel. Severity ranks enable escalation (nit → concern + * → blocker) while emission-guard dedupe treats equal-or-lower rank as a + * repeat. Pure, engine-free types only — no I/O. + */ + +/** How strongly the session advisor weighs a note for the driving agent. */ +export const OVERSEER_ADVICE_SEVERITIES = ["nit", "concern", "blocker"] as const; +export type OverseerAdviceSeverity = (typeof OVERSEER_ADVICE_SEVERITIES)[number]; + +/** + * Provenance of an overseer intervention or steering inject. Lets timeline + * and emission hygiene distinguish canned lifecycle recovery from live + * session-advisor notes and manual operator nudges. + */ +export const OVERSEER_ADVICE_SOURCES = ["lifecycle", "session-advisor", "manual"] as const; +export type OverseerAdviceSource = (typeof OVERSEER_ADVICE_SOURCES)[number]; + +/** + * Rank used for escalation-aware dedupe. Omitted severity is treated as nit + * (OMP advise-tool contract). Higher rank may re-emit the same note text. + */ +export const OVERSEER_ADVICE_SEVERITY_RANK: Record = { + nit: 1, + concern: 2, + blocker: 3, +}; + +/** One concrete piece of advice for the watched agent. */ +export interface OverseerAdviceNote { + note: string; + severity?: OverseerAdviceSeverity; + /** Which configured advisor produced this note (multi-advisor roster; optional in v1). */ + advisorSlug?: string; + source?: OverseerAdviceSource; +} + +/** + * FNXC:PlannerOversight 2026-07-13-22:40: + * Normalize free-text severity from tool args / metadata. Unknown values + * degrade to undefined (treated as nit at rank time) rather than throwing. + */ +export function normalizeOverseerAdviceSeverity(value: unknown): OverseerAdviceSeverity | undefined { + if (typeof value !== "string") return undefined; + const lowered = value.trim().toLowerCase(); + if ((OVERSEER_ADVICE_SEVERITIES as readonly string[]).includes(lowered)) { + return lowered as OverseerAdviceSeverity; + } + return undefined; +} + +/** Severity rank; omitted/unknown severity ranks as nit. */ +export function overseerAdviceSeverityRank(severity: OverseerAdviceSeverity | undefined): number { + return OVERSEER_ADVICE_SEVERITY_RANK[severity ?? "nit"]; +} + +/** + * Case-insensitive, punctuation-folded normalization for emission-guard + * keys. Collapses every run of non-letter / non-digit characters into a + * single space so `"Stop."`, `"*Stop*"`, and `" stop "` share one key. + */ +export function normalizeOverseerAdviceNote(note: string): string { + return note + .toLowerCase() + .normalize("NFKC") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} diff --git a/packages/core/src/overseer-emission-guard.ts b/packages/core/src/overseer-emission-guard.ts new file mode 100644 index 0000000000..2c8d061141 --- /dev/null +++ b/packages/core/src/overseer-emission-guard.ts @@ -0,0 +1,150 @@ +/** + * FNXC:PlannerOversight 2026-07-13-22:40: + * Session-advisor emission guard (OMP AdvisorEmissionGuard parity). + * Makes "prefer silence", "at most one advise per update", and "never + * repeat the same advice" load-bearing in code so a misbehaving model + * cannot flood the executor transcript with content-free or duplicate + * notes. Pure in-memory policy — never throws, never performs I/O. + * + * Accept order: empty → content-free phrase → exact-text+severity-rank + * dedupe → per-update rate limit. Suppressed calls do not consume the + * per-update budget so a noise call does not displace a later real note + * in the same update cycle. + */ + +import { + normalizeOverseerAdviceNote, + overseerAdviceSeverityRank, + type OverseerAdviceSeverity, +} from "./overseer-advice.js"; + +/** + * Normalized phrases that carry no concrete actionable content. Silence is + * the correct expression of "no concerns". Keys must be outputs of + * {@link normalizeOverseerAdviceNote}. + */ +const SUPPRESSED_NORMALIZED_PHRASES: ReadonlySet = new Set([ + "stop", + "stop here", + "stop now", + "halt", + "abort", + "done", + "task done", + "task complete", + "complete", + "finished", + "ok", + "okay", + "ok done", + "no issue", + "no issues", + "no issue continue", + "no concerns", + "no concern", + "nothing to add", + "nothing to flag", + "nothing to report", + "no notes", + "no further input", + "no further input needed", + "no further input required", + "no further watcher input", + "no further watcher input needed", + "no further advice", + "no further advice needed", + "lgtm", + "looks good", + "all good", + "agent is on track", + "agent on track", + "on track", + "continue", + "carry on", +]); + +/** Bounds dedupe history growth on long sessions (OMP default 4096). */ +const DEFAULT_HISTORY_CAPACITY = 4096; + +export interface OverseerEmissionGuardOptions { + capacity?: number; +} + +export interface OverseerEmissionGuardAcceptInput { + note: string; + severity?: OverseerAdviceSeverity; +} + +/** + * FNXC:PlannerOversight 2026-07-13-22:40: + * Per-session (or per-task) gate for session-advisor `advise()` results + * before they reach `addSteeringComment` / the intervention timeline. + */ +export class OverseerEmissionGuard { + /** Highest delivered severity rank per normalized note key. */ + #deliveredRanks = new Map(); + /** Insertion order for FIFO eviction. */ + #seenOrder: string[] = []; + #consumedThisUpdate = false; + readonly #capacity: number; + + constructor(opts: OverseerEmissionGuardOptions = {}) { + this.#capacity = opts.capacity ?? DEFAULT_HISTORY_CAPACITY; + } + + /** + * Drop all dedupe and per-update state. Call when the advisor runtime is + * reset (session switch, re-prime) so a re-primed reviewer can re-raise + * issues against a rewritten transcript. + */ + reset(): void { + this.#deliveredRanks.clear(); + this.#seenOrder.length = 0; + this.#consumedThisUpdate = false; + } + + /** + * Clear the per-update rate-limit gate. Call immediately before each + * advisor model `prompt()` cycle. + */ + beginUpdate(): void { + this.#consumedThisUpdate = false; + } + + /** + * Whether the proposed note should reach the executor. On true the gate + * has already recorded the note (consumed the per-update budget and + * stored the severity rank). On false the caller must drop it silently. + */ + accept(input: OverseerEmissionGuardAcceptInput | string): boolean { + try { + const note = typeof input === "string" ? input : input?.note; + const severity = typeof input === "string" ? undefined : input?.severity; + if (typeof note !== "string") return false; + + const key = normalizeOverseerAdviceNote(note); + if (!key) return false; + if (SUPPRESSED_NORMALIZED_PHRASES.has(key)) return false; + + const rank = overseerAdviceSeverityRank(severity); + const previousRank = this.#deliveredRanks.get(key) ?? 0; + if (rank <= previousRank) return false; + + if (this.#consumedThisUpdate) return false; + + this.#consumedThisUpdate = true; + const isNewKey = !this.#deliveredRanks.has(key); + this.#deliveredRanks.set(key, rank); + if (isNewKey) { + this.#seenOrder.push(key); + while (this.#seenOrder.length > this.#capacity) { + const stale = this.#seenOrder.shift(); + if (stale !== undefined) this.#deliveredRanks.delete(stale); + } + } + return true; + } catch { + return false; + } + } +} diff --git a/packages/core/src/planner-intervention.ts b/packages/core/src/planner-intervention.ts index 3f090c6dd8..736528664d 100644 --- a/packages/core/src/planner-intervention.ts +++ b/packages/core/src/planner-intervention.ts @@ -43,6 +43,15 @@ export interface RecordPlannerInterventionInput { agentId?: string; /** ISO-8601 timestamp override. Defaults to now. */ timestamp?: string; + /* + FNXC:PlannerOversight 2026-07-13-22:45: + Optional session-advisor metadata (severity/source/slug). Persisted in + run-audit metadata and rehydrated by parseInterventionEntry for the + task-detail Intervention Timeline. + */ + severity?: "nit" | "concern" | "blocker"; + source?: "lifecycle" | "session-advisor" | "manual"; + advisorSlug?: string; } const KNOWN_STAGES: readonly PlannerOversightStage[] = ["executor", "reviewer", "merger", "pull-request", "workflow-gate"]; @@ -84,6 +93,16 @@ export function recordPlannerIntervention( if (typeof input.attemptCount === "number") metadata.attemptCount = input.attemptCount; if (typeof input.attemptLimit === "number") metadata.attemptLimit = input.attemptLimit; if (input.sourceLinks && input.sourceLinks.length > 0) metadata.sourceLinks = input.sourceLinks; + // FNXC:PlannerOversight 2026-07-13-22:45: pass through optional severity/source for session-advisor parity. + if (input.severity === "nit" || input.severity === "concern" || input.severity === "blocker") { + metadata.severity = input.severity; + } + if (input.source === "lifecycle" || input.source === "session-advisor" || input.source === "manual") { + metadata.source = input.source; + } + if (typeof input.advisorSlug === "string" && input.advisorSlug.trim()) { + metadata.advisorSlug = input.advisorSlug.trim(); + } return store.recordRunAuditEvent({ timestamp: input.timestamp, @@ -149,6 +168,19 @@ export function parseInterventionEntry(event: RunAuditEvent): PlannerInterventio const attemptCount = typeof metadata.attemptCount === "number" ? metadata.attemptCount : undefined; const attemptLimit = typeof metadata.attemptLimit === "number" ? metadata.attemptLimit : undefined; + const severity = + metadata.severity === "nit" || metadata.severity === "concern" || metadata.severity === "blocker" + ? metadata.severity + : undefined; + const source = + metadata.source === "lifecycle" || metadata.source === "session-advisor" || metadata.source === "manual" + ? metadata.source + : undefined; + const advisorSlug = + typeof metadata.advisorSlug === "string" && metadata.advisorSlug.trim() + ? metadata.advisorSlug.trim() + : undefined; + return { id: event.id, taskId: event.taskId ?? event.target, @@ -162,6 +194,10 @@ export function parseInterventionEntry(event: RunAuditEvent): PlannerInterventio sourceLinks: toSafeSourceLinks(metadata.sourceLinks), runId: event.runId, agentId: event.agentId, + // FNXC:PlannerOversight 2026-07-13-22:45: rehydrate session-advisor metadata when present. + severity, + source, + advisorSlug, }; } diff --git a/packages/core/src/planner-overseer-events.ts b/packages/core/src/planner-overseer-events.ts index f208b68621..306068451b 100644 --- a/packages/core/src/planner-overseer-events.ts +++ b/packages/core/src/planner-overseer-events.ts @@ -44,6 +44,13 @@ export interface OverseerEventInput { sourceLinks?: PlannerInterventionSourceLink[]; /** ISO-8601 timestamp override. Defaults to now. */ timestamp?: string; + /* + FNXC:PlannerOversight 2026-07-13-22:45: + Optional session-advisor metadata threaded into recordPlannerIntervention. + */ + severity?: "nit" | "concern" | "blocker"; + source?: "lifecycle" | "session-advisor" | "manual"; + advisorSlug?: string; } /** @@ -70,6 +77,9 @@ function normalizeAndRecord( attemptLimit: input.attemptLimit, sourceLinks: input.sourceLinks, timestamp: input.timestamp, + severity: input.severity, + source: input.source, + advisorSlug: input.advisorSlug, }); } diff --git a/packages/core/src/planner-overseer-state.ts b/packages/core/src/planner-overseer-state.ts index 3076b4adb8..9457d30800 100644 --- a/packages/core/src/planner-overseer-state.ts +++ b/packages/core/src/planner-overseer-state.ts @@ -51,6 +51,14 @@ export interface PlannerOverseerRuntimeSnapshot { * has been dispatched/recorded yet for the current watched stage. */ lastAction?: string; + /* + FNXC:PlannerOversight 2026-07-13-23:05: + Session-advisor runtime enrichment (OMP parity). Optional; absent when the + session advisor is soft-disabled or not active for the task. + */ + advisorActive?: boolean; + advisorBacklog?: number; + lastAdviceSeverity?: "nit" | "concern" | "blocker"; } /** Pure input the state derivation reads — no engine types, no side effects. */ diff --git a/packages/core/src/postgres/index.ts b/packages/core/src/postgres/index.ts index 1cf0874084..a3292489e0 100644 --- a/packages/core/src/postgres/index.ts +++ b/packages/core/src/postgres/index.ts @@ -75,6 +75,7 @@ export { readBaselineMigrationSql, SCHEMA_BASELINE_VERSION, PROJECT_OWNERSHIP_SCHEMA_VERSION, + SESSION_ADVISOR_ENABLED_SCHEMA_VERSION, MIGRATION_BOOKKEEPING_TABLE, } from "./schema-applier.js"; export { diff --git a/packages/core/src/postgres/migrations/0006_project_ownership.sql b/packages/core/src/postgres/migrations/0006_project_ownership.sql index f4fd0f31c0..9a1bb6589d 100644 --- a/packages/core/src/postgres/migrations/0006_project_ownership.sql +++ b/packages/core/src/postgres/migrations/0006_project_ownership.sql @@ -27,6 +27,10 @@ CREATE TABLE IF NOT EXISTS project.agent_log_entries_legacy ( FNXC:ProjectDataIsolation 2026-07-14-12:45: Embedded PostgreSQL is administered by a superuser, which bypasses RLS. Application pools assume this deliberately non-superuser role; only the separate migration connection retains administrative bypass. */ +/* +FNXC:ProjectDataIsolation 2026-07-15-00:00: +Roles live in the CLUSTER-wide pg_authid, but the applier's pg_advisory_xact_lock('fusion:schema-applier') is per-DATABASE. Concurrent appliers on different databases of one cluster (the PG gate gives every test its own database; multi-project deployments give every project its own) therefore hold uncontended locks, all observe the role as absent, and all reach CREATE ROLE — the losers failed the migration with 23505 on pg_authid_rolname_index. No lock in this transaction can make the check-then-create atomic across databases, so tolerate losing the race instead: a concurrent creator produced exactly the role we wanted. Catch unique_violation (index-level, what the race actually raises) as well as duplicate_object (what a non-racing re-create raises). +*/ DO $$ DECLARE current_user_is_superuser boolean; diff --git a/packages/core/src/postgres/migrations/0008_session_advisor_enabled.sql b/packages/core/src/postgres/migrations/0008_session_advisor_enabled.sql new file mode 100644 index 0000000000..bfef74d86c --- /dev/null +++ b/packages/core/src/postgres/migrations/0008_session_advisor_enabled.sql @@ -0,0 +1,18 @@ +/* +FNXC:PlannerOversight 2026-07-14-18:49: +Per-task session advisor control (project default + task override + Quick Add) +persists as project.tasks.session_advisor_enabled (null = inherit, 0 = off, 1 = on). +Drizzle schema and EXPECTED_PROJECT_COLUMNS already declare the column, but +migrations 0000–0007 never created it — fresh DBs from the applier lacked the +column and boot-smoke SELECT * paths failed. Self-heal covers long-lived +embedded DBs; this versioned migration is the upgrade/fresh-install path so Gate +and multi-project boots cannot race ahead of ALTER TABLE health checks. +*/ +DO $$ +BEGIN + IF to_regclass('project.tasks') IS NOT NULL THEN + ALTER TABLE project.tasks + ADD COLUMN IF NOT EXISTS session_advisor_enabled integer; + END IF; +END +$$; diff --git a/packages/core/src/postgres/postgres-health.ts b/packages/core/src/postgres/postgres-health.ts index a4926a6316..68047b5513 100644 --- a/packages/core/src/postgres/postgres-health.ts +++ b/packages/core/src/postgres/postgres-health.ts @@ -200,6 +200,8 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ schema?: string; table: s // existing embedded-PG databases self-heal them on boot. { table: "tasks", column: "validator_thinking_level", type: "text" }, { table: "tasks", column: "planning_thinking_level", type: "text" }, + // FNXC:PlannerOversight 2026-07-14-18:11: per-task session advisor override (null/0/1). + { table: "tasks", column: "session_advisor_enabled", type: "integer" }, { table: "chat_rooms", column: "thinking_level", type: "text" }, ]; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 9247f4df35..17ed250ef2 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -27,7 +27,7 @@ import { sql } from "drizzle-orm"; import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js"; /** The latest PostgreSQL schema version known to this applier. */ -export const SCHEMA_BASELINE_VERSION = "0007"; +export const SCHEMA_BASELINE_VERSION = "0008"; const INITIAL_SCHEMA_VERSION = "0000"; const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001"; const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002"; @@ -40,6 +40,12 @@ export const LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION = "0004"; export const MULTI_PROJECT_CUTOVER_SCHEMA_VERSION = "0005"; export const PROJECT_OWNERSHIP_SCHEMA_VERSION = "0006"; export const SQLITE_SCHEMA_PARITY_VERSION = "0007"; +/** + * FNXC:PlannerOversight 2026-07-14-18:49: + * Version 0008 adds project.tasks.session_advisor_enabled for per-task session + * advisor overrides. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances. + */ +export const SESSION_ADVISOR_ENABLED_SCHEMA_VERSION = "0008"; /** Bookkeeping table for the fresh Drizzle migration history. */ export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; @@ -81,6 +87,11 @@ const SQLITE_SCHEMA_PARITY_MIGRATION_PATH = join( "migrations", "0007_sqlite_schema_parity.sql", ); +const SESSION_ADVISOR_ENABLED_MIGRATION_PATH = join( + __dirname, + "migrations", + "0008_session_advisor_enabled.sql", +); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -145,6 +156,7 @@ export async function applySchemaBaseline( const multiProjectCutoverAlreadyApplied = applied.includes(MULTI_PROJECT_CUTOVER_SCHEMA_VERSION); const projectOwnershipAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_SCHEMA_VERSION); const sqliteSchemaParityAlreadyApplied = applied.includes(SQLITE_SCHEMA_PARITY_VERSION); + const sessionAdvisorEnabledAlreadyApplied = applied.includes(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION); let schemaChanged = false; if (!baselineAlreadyApplied) { @@ -337,6 +349,21 @@ export async function applySchemaBaseline( schemaChanged = true; } + /* + FNXC:PlannerOversight 2026-07-14-18:49: + Apply session_advisor_enabled independently of 0007 so databases that already + recorded SQLite schema parity still gain the per-task session-advisor column + before TaskStore/Drizzle SELECT paths run on boot. + */ + if (!sessionAdvisorEnabledAlreadyApplied) { + const sessionAdvisorEnabledSql = await readFile(SESSION_ADVISOR_ENABLED_MIGRATION_PATH, "utf8"); + await tx.execute(sql.raw(sessionAdvisorEnabledSql)); + await tx.execute( + sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SESSION_ADVISOR_ENABLED_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`, + ); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 59bbccdadd..2dd802edbe 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -135,6 +135,16 @@ export const tasks = projectSchema.table("tasks", { validatorThinkingLevel: text("validator_thinking_level"), planningThinkingLevel: text("planning_thinking_level"), executionMode: text("execution_mode").default("standard"), + /* + FNXC:PlannerOversight 2026-07-14-18:11: + Per-task session advisor override (null = inherit project default, 0 = off, 1 = on). + Listed in EXPECTED_PROJECT_COLUMNS so existing embedded-PG DBs self-heal via ALTER TABLE. + + FNXC:PlannerOversight 2026-07-14-18:49: + Fresh installs also need migration 0008_session_advisor_enabled — self-heal alone + is not enough for Gate boot-smoke before health reconciliation runs. + */ + sessionAdvisorEnabled: integer("session_advisor_enabled"), tokenUsageInputTokens: integer("token_usage_input_tokens"), tokenUsageOutputTokens: integer("token_usage_output_tokens"), tokenUsageCachedTokens: integer("token_usage_cached_tokens"), diff --git a/packages/core/src/session-advisor.ts b/packages/core/src/session-advisor.ts new file mode 100644 index 0000000000..cec5f97035 --- /dev/null +++ b/packages/core/src/session-advisor.ts @@ -0,0 +1,38 @@ +/** + * FNXC:PlannerOversight 2026-07-14-18:11: + * Session advisor (LLM overseer agent) enable inheritance mirrors GitHub tracking: + * per-task override wins, else project default, else workflow flag (backward compat), + * else off. Pure resolver — no I/O — so UI, API, and engine share one contract. + */ +import type { ProjectSettings, Task } from "./types.js"; + +export interface ResolvedTaskSessionAdvisor { + enabled: boolean; + source: "task" | "project" | "workflow" | "default"; +} + +/** + * Resolve whether the session advisor (LLM overseer agent) is enabled for a task. + * + * Precedence: + * 1. `task.sessionAdvisorEnabled` when boolean (explicit on/off for this task) + * 2. `projectSettings.sessionAdvisorEnabledByDefault` when true + * 3. Workflow `plannerOverseerAdvisorEnabled` when true (legacy / workflow-settings path) + * 4. Default false + */ +export function resolveTaskSessionAdvisorEnabled( + task: Pick, + projectSettings?: Pick, + workflowAdvisorEnabled?: boolean, +): ResolvedTaskSessionAdvisor { + if (typeof task.sessionAdvisorEnabled === "boolean") { + return { enabled: task.sessionAdvisorEnabled, source: "task" }; + } + if (projectSettings?.sessionAdvisorEnabledByDefault === true) { + return { enabled: true, source: "project" }; + } + if (workflowAdvisorEnabled === true) { + return { enabled: true, source: "workflow" }; + } + return { enabled: false, source: "default" }; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 04e84c42be..e74f4f49d4 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -580,6 +580,8 @@ export const DEFAULT_PROJECT_SETTINGS = { githubCommentTemplate: undefined, githubCloseSourceIssueOnDone: false, githubTrackingEnabledByDefault: false, + // FNXC:PlannerOversight 2026-07-14-18:11: session advisor (LLM overseer agent) off by default; operators opt in per project / task. + sessionAdvisorEnabledByDefault: false, githubLinkImportedIssuesToTracking: false, githubTrackingDefaultRepo: undefined, gitlabEnabled: undefined, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 8ccb514ec8..63abcd5155 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1138,8 +1138,7 @@ export class TaskStore extends EventEmitter { } async updateTask( id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; approvedPlanFingerprint?: string | null }, - runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext, ): Promise { return updateTaskImpl(this, id, updates, runContext); } diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 9024bd98fd..74e13d9e81 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -71,6 +71,8 @@ export interface TaskRow { validatorThinkingLevel: string | null; planningThinkingLevel: string | null; executionMode: string | null; + /** FNXC:PlannerOversight 2026-07-14-18:11: null = inherit project; 0 = off; 1 = on (autoMerge pattern). */ + sessionAdvisorEnabled: number | null; tokenUsageInputTokens: number | null; tokenUsageOutputTokens: number | null; tokenUsageCachedTokens: number | null; @@ -182,6 +184,9 @@ export function defineTaskColumn( const serializeTaskAutoMerge: TaskColumnDescriptor["serialize"] = (task) => task.autoMerge === undefined ? null : (task.autoMerge ? 1 : 0); const serializeTaskAutoMergeProvenance: TaskColumnDescriptor["serialize"] = (task) => task.autoMergeProvenance ?? null; +// FNXC:PlannerOversight 2026-07-14-18:11: three-state like autoMerge — null inherits project default. +const serializeTaskSessionAdvisorEnabled: TaskColumnDescriptor["serialize"] = (task) => + task.sessionAdvisorEnabled === undefined ? null : (task.sessionAdvisorEnabled ? 1 : 0); // Keep this descriptor order in lockstep with the named-column INSERT/UPSERT // clauses we generate below. SQLite binds by the explicit column list we emit, @@ -248,6 +253,7 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("validatorThinkingLevel", (task) => task.validatorThinkingLevel ?? null), defineTaskColumn("planningThinkingLevel", (task) => task.planningThinkingLevel ?? null), defineTaskColumn("executionMode", (task) => task.executionMode ?? null), + defineTaskColumn("sessionAdvisorEnabled", serializeTaskSessionAdvisorEnabled), defineTaskColumn("tokenUsageInputTokens", (task) => task.tokenUsage?.inputTokens ?? null), defineTaskColumn("tokenUsageOutputTokens", (task) => task.tokenUsage?.outputTokens ?? null), defineTaskColumn("tokenUsageCachedTokens", (task) => task.tokenUsage?.cachedTokens ?? null), diff --git a/packages/core/src/task-store/remaining-ops-6.ts b/packages/core/src/task-store/remaining-ops-6.ts index 7b2c685963..7fbe7ab346 100644 --- a/packages/core/src/task-store/remaining-ops-6.ts +++ b/packages/core/src/task-store/remaining-ops-6.ts @@ -624,8 +624,7 @@ export async function resetPromptCheckboxesImpl(store: TaskStore, dir: string): export async function updateTaskImpl(store: TaskStore, id: string, - updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined }, - runContext?: RunMutationContext, + updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext, ): Promise { /* FNXC:StateMachine 2026-07-07-12:00: diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index fd3649c872..fe945a1575 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -125,6 +125,10 @@ export function rowToTask(row: TaskRow): Task { validatorThinkingLevel: (row.validatorThinkingLevel || undefined) as Task["validatorThinkingLevel"], planningThinkingLevel: (row.planningThinkingLevel || undefined) as Task["planningThinkingLevel"], executionMode: (row.executionMode || undefined) as Task["executionMode"], + // FNXC:PlannerOversight 2026-07-14-18:11: null → undefined (inherit); 0/1 → boolean. + sessionAdvisorEnabled: row.sessionAdvisorEnabled === null || row.sessionAdvisorEnabled === undefined + ? undefined + : row.sessionAdvisorEnabled === 1, createdAt: row.createdAt, updatedAt: row.updatedAt, columnMovedAt: row.columnMovedAt || undefined, diff --git a/packages/core/src/task-store/task-creation.ts b/packages/core/src/task-store/task-creation.ts index 2fe62faa43..c212e7ba3b 100644 --- a/packages/core/src/task-store/task-creation.ts +++ b/packages/core/src/task-store/task-creation.ts @@ -320,6 +320,8 @@ export async function _createTaskInternalBackendImpl(store: TaskStore, input: Ta planningThinkingLevel: input.planningThinkingLevel, reviewLevel: input.reviewLevel, executionMode: input.executionMode, + // FNXC:PlannerOversight 2026-07-14-18:11: only set when create input is explicit boolean. + sessionAdvisorEnabled: typeof input.sessionAdvisorEnabled === "boolean" ? input.sessionAdvisorEnabled : undefined, baseBranch: input.baseBranch, branch: input.branch, missionId: input.missionId, @@ -861,6 +863,8 @@ export async function _createTaskInternalImpl(store: TaskStore, input: TaskCreat planningThinkingLevel: input.planningThinkingLevel, reviewLevel: input.reviewLevel, executionMode: input.executionMode, + // FNXC:PlannerOversight 2026-07-14-18:11: only set when create input is explicit boolean. + sessionAdvisorEnabled: typeof input.sessionAdvisorEnabled === "boolean" ? input.sessionAdvisorEnabled : undefined, baseBranch: input.baseBranch, branch: input.branch, missionId: input.missionId, diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index fd7bf0c7d4..224d2f512e 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -519,6 +519,15 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } else if (updates.executionMode !== undefined) { task.executionMode = updates.executionMode as import("../types.js").ExecutionMode; } + /* + FNXC:PlannerOversight 2026-07-14-18:11: + sessionAdvisorEnabled: null clears to inherit project default; boolean sets override. + */ + if (updates.sessionAdvisorEnabled === null) { + task.sessionAdvisorEnabled = undefined; + } else if (updates.sessionAdvisorEnabled !== undefined) { + task.sessionAdvisorEnabled = updates.sessionAdvisorEnabled; + } if (updates.error === null) { task.error = undefined; } else if (updates.error !== undefined) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 090709addd..0f8e0a397e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2602,6 +2602,14 @@ export interface Task { * "inherit workflow default" — see `resolveEffectivePlannerOversightLevel` in * workflow-settings-resolver.ts for precedence. */ plannerOversightLevel?: PlannerOversightLevel; + /** + * FNXC:PlannerOversight 2026-07-14-18:11: + * Per-task override for the session advisor (LLM overseer agent). `true`/`false` force + * on/off for this task; unset inherits `sessionAdvisorEnabledByDefault` from project + * settings (then workflow `plannerOverseerAdvisorEnabled` for backward compat). + * See `resolveTaskSessionAdvisorEnabled` in session-advisor.ts. + */ + sessionAdvisorEnabled?: boolean; /** * FNXC:PlannerOversight 2026-07-04-00:00: * FN-7531 transient, engine-populated snapshot of the planner overseer's @@ -2900,6 +2908,11 @@ export interface TaskCreateInput { * When set, wins over the workflow's effective `plannerOversightLevel`. Unset means * "inherit workflow default". */ plannerOversightLevel?: PlannerOversightLevel; + /** + * FNXC:PlannerOversight 2026-07-14-18:11: + * Per-task session advisor override at create time. Unset inherits project default. + */ + sessionAdvisorEnabled?: boolean; } // ── Todo List Types ────────────────────────────────────────────────────── @@ -4629,6 +4642,14 @@ export interface ProjectSettings { /** When true, new tasks default GitHub tracking to enabled for this project (FN-3868). * Default: false. */ githubTrackingEnabledByDefault?: boolean; + /** + * FNXC:PlannerOversight 2026-07-14-18:11: + * When true, new tasks default the session advisor (LLM overseer agent) to enabled. + * Individual tasks can override via `sessionAdvisorEnabled`. Default: false (opt-in). + * Provider/model still come from workflow settings (`plannerOverseerAdvisorProvider` / + * `plannerOverseerAdvisorModelId`). + */ + sessionAdvisorEnabledByDefault?: boolean; /** * FNXC:GithubImportTracking 2026-07-01-00:00: * This project-scoped switch is intentionally narrower than githubTrackingEnabledByDefault: it only forces imported GitHub issues to become GitHub-tracked tasks so the source issue is adopted, while ordinary new tasks keep their existing default behavior. @@ -5205,6 +5226,8 @@ export interface ArchivedTaskEntry { executionMode?: ExecutionMode; /** Per-task override of the workflow-native planner oversight level at time of archival. */ plannerOversightLevel?: PlannerOversightLevel; + /** Per-task session advisor override at time of archival. */ + sessionAdvisorEnabled?: boolean; prInfo?: PrInfo; prInfos?: PrInfo[]; issueInfo?: IssueInfo; @@ -6801,6 +6824,16 @@ export interface PlannerInterventionEntry { runId?: string; /** Agent ID that produced this intervention, if applicable. */ agentId?: string; + /* + FNXC:PlannerOversight 2026-07-13-22:45: + Session-advisor parity: optional severity (nit/concern/blocker) and provenance + source so the intervention timeline distinguishes lifecycle canned guidance + from live session-advisor notes and manual operator nudges. Absent on + pre-existing rows — parsers must tolerate missing fields. + */ + severity?: "nit" | "concern" | "blocker"; + source?: "lifecycle" | "session-advisor" | "manual"; + advisorSlug?: string; } /** Canonical run-audit mutation type used to persist planner-intervention entries. Single writer: `recordPlannerIntervention` (see `packages/core/src/planner-intervention.ts`); FN-7520 reuses this helper rather than emitting `overseer:intervention` events directly. */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 30b28bad37..64116fa36f 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -476,6 +476,7 @@ export async function createTask( baseBranch, branchSelection, githubTracking, + sessionAdvisorEnabled, acknowledgedDuplicates, bypassDuplicateCheck, } = input; @@ -514,6 +515,7 @@ export async function createTask( baseBranch, branchSelection, githubTracking, + sessionAdvisorEnabled, acknowledgedDuplicates, bypassDuplicateCheck, }), @@ -572,6 +574,8 @@ export function updateTask( validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; plannerOversightLevel?: "off" | "observe" | "steer" | "autonomous" | null; + /** FNXC:PlannerOversight 2026-07-14-18:11: boolean override or null to inherit project default. */ + sessionAdvisorEnabled?: boolean | null; reviewLevel?: number | null; executionMode?: "standard" | "fast" | null; noCommitsExpected?: boolean; diff --git a/packages/dashboard/app/components/QuickEntryBox.tsx b/packages/dashboard/app/components/QuickEntryBox.tsx index fb0224509e..9d1b4ca138 100644 --- a/packages/dashboard/app/components/QuickEntryBox.tsx +++ b/packages/dashboard/app/components/QuickEntryBox.tsx @@ -8,7 +8,7 @@ import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep, Thinki import type { ModelInfo, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api"; import { checkDuplicateTasks, fetchModels, fetchSettings, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api"; import { DuplicateWarningModal } from "./DuplicateWarningModal"; -import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Zap } from "lucide-react"; +import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Zap, Eye, EyeOff } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { LoadingSpinner } from "./LoadingSpinner"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; @@ -228,6 +228,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, isFastModeRef.current = isFastMode; }, [isFastMode]); const [githubTrackingOverride, setGithubTrackingOverride] = useState(null); + // FNXC:PlannerOversight 2026-07-14-18:11: null = follow project sessionAdvisorEnabledByDefault. + const [sessionAdvisorOverride, setSessionAdvisorOverride] = useState(null); const [priority, setPriority] = useState(DEFAULT_TASK_PRIORITY); const [nodeId, setNodeId] = useState(undefined); const [duplicateMatches, setDuplicateMatches] = useState(null); @@ -615,6 +617,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, setEnabledOptionalStepIds(optionalSteps.filter((step) => step.defaultOn).map((step) => step.templateId)); setIsFastMode(false); setGithubTrackingOverride(null); + setSessionAdvisorOverride(null); setPriority(DEFAULT_TASK_PRIORITY); setNodeId(undefined); setShowDeps(false); @@ -743,6 +746,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, enabledWorkflowSteps: isFastMode || optionalSteps.length > 0 ? enabledOptionalStepIds : undefined, ...(isFastMode ? { executionMode: "fast" } : {}), githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined, + // FNXC:PlannerOversight 2026-07-14-18:11: only send when user toggled away from project default. + sessionAdvisorEnabled: sessionAdvisorOverride !== null ? sessionAdvisorOverride : undefined, priority, nodeId: effectiveNodeId, acknowledgedDuplicates: overrides?.acknowledgedDuplicates, @@ -790,6 +795,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, isFastMode, settings, githubTrackingOverride, + sessionAdvisorOverride, priority, effectiveNodeId, pendingImages, @@ -1637,6 +1643,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, const githubToggleLabel = effectiveGithubTracking ? t("tasks.githubTrackingOn", "GitHub tracking ON for next task (project default: {{default}})", { default: projectGithubTrackingDefault ? t("tasks.githubTrackingDefaultOn", "on") : t("tasks.githubTrackingDefaultOff", "off") }) : t("tasks.githubTrackingOff", "GitHub tracking OFF for next task (project default: {{default}})", { default: projectGithubTrackingDefault ? t("tasks.githubTrackingDefaultOn", "on") : t("tasks.githubTrackingDefaultOff", "off") }); + /* + FNXC:PlannerOversight 2026-07-14-18:11: + Quick Add eye toggle for session advisor. null override follows project default; + toggle flips effective on/off and stores an explicit override for the next create. + */ + const projectSessionAdvisorDefault = settings?.sessionAdvisorEnabledByDefault === true; + const effectiveSessionAdvisor = sessionAdvisorOverride ?? projectSessionAdvisorDefault; + const sessionAdvisorToggleLabel = effectiveSessionAdvisor + ? t("tasks.sessionAdvisorOn", "Session advisor ON for next task (project default: {{default}})", { default: projectSessionAdvisorDefault ? t("tasks.sessionAdvisorDefaultOn", "on") : t("tasks.sessionAdvisorDefaultOff", "off") }) + : t("tasks.sessionAdvisorOff", "Session advisor OFF for next task (project default: {{default}})", { default: projectSessionAdvisorDefault ? t("tasks.sessionAdvisorDefaultOn", "on") : t("tasks.sessionAdvisorDefaultOff", "off") }); const PriorityIcon = getPriorityIcon(priority); const priorityLabel = getPriorityLabel(priority); const priorityButtonLabel = t("tasks.quickEntryPriorityLabel", "Priority: {{priority}}", { priority: priorityLabel }); @@ -2197,6 +2213,35 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, + {/* + FNXC:PlannerOversight 2026-07-14-18:11: + Compact eye toggle next to GitHub for session advisor (overseer agent). + Default follows project setting; press stores an explicit per-create override. + + FNXC:PlannerOversight 2026-07-14-19:34: + CodeRabbit: when the flipped effective value matches the project default, + clear override to null (inherit) — same as TaskDetailModal — instead of + permanently hardcoding true/false after a double-click. + */} + +
{!oversightIsOff && ( {t("taskDetail.oversight.controlsLabel", "Overseer controls")} diff --git a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx index be94189b0f..5f8d3edda9 100644 --- a/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx @@ -232,6 +232,8 @@ vi.mock("lucide-react", () => { Flag: MockIcon("lucide-flag"), TriangleAlert: MockIcon("lucide-triangle-alert"), Zap: MockIcon("lucide-zap"), + Eye: MockIcon("lucide-eye"), + EyeOff: MockIcon("lucide-eye-off"), Github: MockIcon("lucide-github"), Maximize2: MockIcon("lucide-maximize-2"), Minimize2: MockIcon("lucide-minimize-2"), @@ -2552,6 +2554,86 @@ describe("QuickEntryBox", () => { expect(screen.getByTestId("quick-entry-github-toggle").getAttribute("aria-pressed")).toBe("false"); }); + /* + FNXC:PlannerOversight 2026-07-14-19:34: + Session advisor eye must clear override to null when the flipped effective + value matches the project default (TaskDetailModal parity) — double-click + from default-off must not leave a hard-coded false that is still sent on create. + */ + it("clears session advisor override to inherit after double-click on default-off", async () => { + vi.mocked(fetchSettings).mockResolvedValueOnce({ + sessionAdvisorEnabledByDefault: false, + } as any); + const { props } = renderQuickEntryBox({ availableModels: undefined }); + expandQuickEntry(); + const textarea = screen.getByTestId("quick-entry-input"); + + const advisorToggle = await screen.findByTestId("quick-entry-session-advisor-toggle"); + expect(advisorToggle).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(advisorToggle); + expect(advisorToggle).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(advisorToggle); + expect(advisorToggle).toHaveAttribute("aria-pressed", "false"); + + fireEvent.change(textarea, { target: { value: "Inherit session advisor after double-click" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => { + expect(props.onCreate).toHaveBeenCalledTimes(1); + }); + // null override → field omitted (undefined), not hard-coded false + expect(props.onCreate.mock.calls[0]?.[0].sessionAdvisorEnabled).toBeUndefined(); + }); + + it("clears session advisor override to inherit after double-click on default-on", async () => { + vi.mocked(fetchSettings).mockResolvedValueOnce({ + sessionAdvisorEnabledByDefault: true, + } as any); + const { props } = renderQuickEntryBox({ availableModels: undefined }); + expandQuickEntry(); + const textarea = screen.getByTestId("quick-entry-input"); + + const advisorToggle = await screen.findByTestId("quick-entry-session-advisor-toggle"); + await waitFor(() => { + expect(advisorToggle).toHaveAttribute("aria-pressed", "true"); + }); + + fireEvent.click(advisorToggle); + expect(advisorToggle).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(advisorToggle); + expect(advisorToggle).toHaveAttribute("aria-pressed", "true"); + + fireEvent.change(textarea, { target: { value: "Inherit session advisor default-on" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => { + expect(props.onCreate).toHaveBeenCalledTimes(1); + }); + expect(props.onCreate.mock.calls[0]?.[0].sessionAdvisorEnabled).toBeUndefined(); + }); + + it("sends explicit session advisor override when effective differs from project default", async () => { + vi.mocked(fetchSettings).mockResolvedValueOnce({ + sessionAdvisorEnabledByDefault: false, + } as any); + const { props } = renderQuickEntryBox({ availableModels: undefined }); + expandQuickEntry(); + const textarea = screen.getByTestId("quick-entry-input"); + + const advisorToggle = await screen.findByTestId("quick-entry-session-advisor-toggle"); + fireEvent.click(advisorToggle); + expect(advisorToggle).toHaveAttribute("aria-pressed", "true"); + + fireEvent.change(textarea, { target: { value: "Explicit session advisor on" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => { + expect(props.onCreate).toHaveBeenCalledTimes(1); + }); + expect(props.onCreate.mock.calls[0]?.[0].sessionAdvisorEnabled).toBe(true); + }); + it("resets Fast toggle to standard after successful task creation", async () => { const { props } = renderQuickEntryBox({}); expandQuickEntry(); diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts index 7737c97e16..ee8c74a904 100644 --- a/packages/dashboard/app/components/settings/section-keys.ts +++ b/packages/dashboard/app/components/settings/section-keys.ts @@ -64,6 +64,7 @@ const PROJECT_SECTION_KEYS: Record = { "githubTrackingDedupEnabled", "githubTrackingDefaultRepo", "githubTrackingEnabledByDefault", + "sessionAdvisorEnabledByDefault", // gitlabEnabled/gitlabInstanceUrl/gitlabApiBaseUrl's enable+URL fields are // owned here; gitlabAuthToken/gitlabAuthTokenType are owned by "merge". "gitlabApiBaseUrl", diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index f2b9d85eba..7fbca5c01f 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -297,6 +297,34 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast }))}/> {t("settings.general.bannerFiresWhenTodoCountIsStrictlyGreater", "Banner fires when todo count is strictly greater than this value (default 20). Applies when the banner is enabled.")}
+ {/* + FNXC:PlannerOversight 2026-07-14-18:11: + Project default for the session advisor (LLM overseer agent). Per-task overrides + come from Quick Add (eye icon) and task detail. Provider/model stay under workflow settings. + */} +

{t("settings.general.sessionAdvisor", "Session advisor (overseer agent)")}

+
+ + + + {t( + "settings.general.sessionAdvisorHelp", + "Controls whether newly created tasks enable the session advisor (live LLM overseer of the executor). Individual tasks can override this from Quick Add or task detail. Also set Session advisor model provider and model id under workflow settings before the advisor can run.", + )} + +

{t("settings.general.gitHubTracking", "GitHub Tracking")}

diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 72fcbbeb03..d4375bb353 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -236,6 +236,7 @@ const SETTING_DESCRIPTION_KEYS: Record = { githubLinkImportedIssuesToTracking: "general.whenEnabledImportedGitHubIssuesUseTheirSource", githubTrackingDedupEnabled: "general.whenEnabledFusionChecksOpenAndClosedIssues", githubTrackingEnabledByDefault: "general.offDefault", + sessionAdvisorEnabledByDefault: "general.offDefault", mailAutoCleanupDays: "general.deleteInboxOutboxMessagesOlderThanThisMany", operationalLogRetentionDays: "general.loweringThisWindowMeansReliabilityMetricsChartsAnd", quickChatButtonMode: "general.quickChatLauncherHint", diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 4c0ac6f45b..33ce85bdad 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1094,6 +1094,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork branchSelection, nodeId, githubTracking, + sessionAdvisorEnabled, acknowledgedDuplicates, bypassDuplicateCheck, } = req.body; @@ -1490,6 +1491,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork baseBranch: normalizedBaseBranch, ...(typeof nodeId === "string" && nodeId.trim().length > 0 ? { nodeId: nodeId.trim() } : {}), ...(validatedGithubTracking ? { githubTracking: validatedGithubTracking } : {}), + // FNXC:PlannerOversight 2026-07-14-18:11: only persist when client sent an explicit boolean override. + ...(typeof sessionAdvisorEnabled === "boolean" ? { sessionAdvisorEnabled } : {}), }; const task = await scopedStore.createTask( @@ -4287,7 +4290,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork router.patch("/tasks/:id", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); - const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, validatorThinkingLevel, planningThinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, gitlabTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status, dismissNearDuplicate } = req.body; + const { title, description, prompt, priority, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, validatorThinkingLevel, planningThinkingLevel, assigneeUserId, reviewLevel, executionMode, sourceIssue, nodeId, branch, baseBranch, githubTracking, gitlabTracking, noCommitsExpected, autoMerge, overlapBlockedBy, status, dismissNearDuplicate, sessionAdvisorEnabled } = req.body; const hasBodyField = (field: string) => Object.prototype.hasOwnProperty.call(req.body, field); // Validate model fields are strings or undefined/null @@ -4605,6 +4608,19 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (hasBodyField("assigneeUserId")) updates.assigneeUserId = validatedAssigneeUserId; if (hasBodyField("reviewLevel")) updates.reviewLevel = reviewLevel; if (hasBodyField("executionMode")) updates.executionMode = executionMode === null ? null : executionMode; + /* + FNXC:PlannerOversight 2026-07-14-18:11: + sessionAdvisorEnabled: boolean override, or null to clear back to project default. + */ + if (hasBodyField("sessionAdvisorEnabled")) { + if (sessionAdvisorEnabled === null) { + updates.sessionAdvisorEnabled = null; + } else if (typeof sessionAdvisorEnabled === "boolean") { + updates.sessionAdvisorEnabled = sessionAdvisorEnabled; + } else { + throw new Error("sessionAdvisorEnabled must be a boolean or null"); + } + } if (hasBodyField("sourceIssue")) updates.sourceIssue = validatedSourceIssue === undefined ? undefined : validatedSourceIssue; if (hasBodyField("nodeId")) updates.nodeId = validatedNodeId; if (hasBodyField("branch")) updates.branch = normalizedBranch; diff --git a/packages/engine/src/__tests__/overseer-advise-tool.test.ts b/packages/engine/src/__tests__/overseer-advise-tool.test.ts new file mode 100644 index 0000000000..b7456f7fc2 --- /dev/null +++ b/packages/engine/src/__tests__/overseer-advise-tool.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { + OVERSEER_ADVISOR_REPLY_CONTRACT, + OVERSEER_ADVISOR_SYSTEM_PROMPT, + OverseerAdviseRecorder, + extractAdvisorAssistantText, + parseAdvisorReplyForAdvice, +} from "../overseer-advise-tool.js"; + +describe("OVERSEER_ADVISOR_SYSTEM_PROMPT", () => { + it("keeps the JSON reply contract and OMP-style critical silence policy", () => { + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain('{"silence":true}'); + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain('"severity":"nit"|"concern"|"blocker"'); + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain("Look where the agent is NOT"); + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain("NEVER police scope or ambition"); + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain("NEVER advise on intent or process"); + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain("File Scope"); + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain("PROMPT.md"); + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain(""); + expect(OVERSEER_ADVISOR_SYSTEM_PROMPT).toContain(""); + // Alias still points at the full expanded prompt. + expect(OVERSEER_ADVISOR_REPLY_CONTRACT).toBe(OVERSEER_ADVISOR_SYSTEM_PROMPT); + }); +}); + +describe("parseAdvisorReplyForAdvice", () => { + it("parses fenced JSON advice", () => { + const reply = 'Here:\n```json\n{"note":"Wrong package — File Scope is engine only.","severity":"concern"}\n```'; + expect(parseAdvisorReplyForAdvice(reply)).toEqual({ + note: "Wrong package — File Scope is engine only.", + severity: "concern", + }); + }); + + it("parses silence object", () => { + expect(parseAdvisorReplyForAdvice('{"silence":true}')).toBeNull(); + }); + + it("parses ADVISE: lines", () => { + expect(parseAdvisorReplyForAdvice("ADVISE(blocker): Missing regression test for the stall path.")).toEqual({ + note: "Missing regression test for the stall path.", + severity: "blocker", + }); + }); + + it("returns null for LGTM-style silence", () => { + expect(parseAdvisorReplyForAdvice("LGTM")).toBeNull(); + expect(parseAdvisorReplyForAdvice("none")).toBeNull(); + }); +}); + +describe("OverseerAdviseRecorder", () => { + it("records first note and ignores equal-severity duplicate", async () => { + const onAdvice = vi.fn(); + const rec = new OverseerAdviseRecorder(onAdvice); + const first = await rec.execute({ note: "Use the pure decidePlannerRecovery path.", severity: "nit" }); + const second = await rec.execute({ note: "Use the pure decidePlannerRecovery path.", severity: "nit" }); + expect(first.recorded).toBe(true); + expect(second.recorded).toBe(false); + expect(onAdvice).toHaveBeenCalledTimes(1); + }); +}); + +describe("extractAdvisorAssistantText", () => { + it("reads nested state.messages assistant content", () => { + const session = { + state: { + messages: [ + { role: "user", content: "delta" }, + { role: "assistant", content: '{"note":"Scope drift","severity":"concern"}' }, + ], + }, + }; + expect(extractAdvisorAssistantText(session)).toContain("Scope drift"); + }); + + it("returns empty string for unknown session shapes", () => { + expect(extractAdvisorAssistantText(null)).toBe(""); + expect(extractAdvisorAssistantText({})).toBe(""); + }); +}); diff --git a/packages/engine/src/__tests__/overseer-advisor-runtime.test.ts b/packages/engine/src/__tests__/overseer-advisor-runtime.test.ts new file mode 100644 index 0000000000..970ee92c75 --- /dev/null +++ b/packages/engine/src/__tests__/overseer-advisor-runtime.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { OverseerAdvisorRuntime, type OverseerAdvisorAgent } from "../overseer-advisor-runtime.js"; + +function createFakeAgent(opts?: { + failTimes?: number; + onPrompt?: (input: string) => void; +}): OverseerAdvisorAgent & { prompts: string[]; failRemaining: number } { + const state = { prompts: [] as string[], failRemaining: opts?.failTimes ?? 0 }; + return { + prompts: state.prompts, + get failRemaining() { + return state.failRemaining; + }, + async prompt(input: string) { + if (state.failRemaining > 0) { + state.failRemaining -= 1; + throw new Error("synthetic advisor failure"); + } + state.prompts.push(input); + opts?.onPrompt?.(input); + }, + abort: vi.fn(), + reset: vi.fn(), + }; +} + +describe("OverseerAdvisorRuntime", () => { + it("drains a log snapshot into one advisor prompt", async () => { + const agent = createFakeAgent(); + const host = { + enqueueAdvice: vi.fn(), + beginAdvisorUpdate: vi.fn(), + }; + const runtime = new OverseerAdvisorRuntime({ + agent, + host, + sleep: async () => {}, + }); + + runtime.onLogSnapshot([ + { type: "text", text: "Starting work on the executor.", agent: "executor" }, + ]); + + // Allow microtask drain + await vi.waitFor(() => expect(agent.prompts.length).toBe(1)); + expect(agent.prompts[0]).toContain("Starting work on the executor."); + expect(host.beginAdvisorUpdate).toHaveBeenCalled(); + expect(runtime.backlog).toBe(0); + }); + + it("seedTo skips replaying earlier history", async () => { + const agent = createFakeAgent(); + const runtime = new OverseerAdvisorRuntime({ + agent, + host: { enqueueAdvice: vi.fn() }, + sleep: async () => {}, + }); + + const history = [ + { type: "text", text: "old turn one", agent: "executor" }, + { type: "text", text: "old turn two", agent: "executor" }, + ]; + runtime.seedTo(history.length); + runtime.onLogSnapshot([ + ...history, + { type: "text", text: "new turn three", agent: "executor" }, + ]); + + await vi.waitFor(() => expect(agent.prompts.length).toBe(1)); + expect(agent.prompts[0]).toContain("new turn three"); + expect(agent.prompts[0]).not.toContain("old turn one"); + }); + + it("drops backlog after three consecutive failures and notifies once", async () => { + const agent = createFakeAgent({ failTimes: 3 }); + const notifyFailure = vi.fn(); + const runtime = new OverseerAdvisorRuntime({ + agent, + host: { enqueueAdvice: vi.fn(), notifyFailure }, + retryDelayMs: 0, + sleep: async () => {}, + }); + + runtime.onLogDelta([{ type: "text", text: "will fail thrice", agent: "executor" }]); + + await vi.waitFor(() => expect(notifyFailure).toHaveBeenCalledTimes(1)); + expect(runtime.backlog).toBe(0); + expect(agent.prompts.length).toBe(0); + }); + + it("notifies again on a second three-failure streak after a drop", async () => { + // Six failures: first streak notifies, second streak notifies again (failureNotified reset on drop). + const agent = createFakeAgent({ failTimes: 6 }); + const notifyFailure = vi.fn(); + const runtime = new OverseerAdvisorRuntime({ + agent, + host: { enqueueAdvice: vi.fn(), notifyFailure }, + retryDelayMs: 0, + sleep: async () => {}, + }); + + runtime.onLogDelta([{ type: "text", text: "streak one", agent: "executor" }]); + await vi.waitFor(() => expect(notifyFailure).toHaveBeenCalledTimes(1)); + + runtime.onLogDelta([{ type: "text", text: "streak two", agent: "executor" }]); + await vi.waitFor(() => expect(notifyFailure).toHaveBeenCalledTimes(2)); + }); + + it("reset invalidates in-flight work via epoch", async () => { + const agent = createFakeAgent(); + const runtime = new OverseerAdvisorRuntime({ + agent, + host: { enqueueAdvice: vi.fn() }, + sleep: async () => {}, + }); + runtime.onLogDelta([{ type: "text", text: "before reset", agent: "executor" }]); + runtime.reset(); + expect(runtime.backlog).toBe(0); + expect(agent.reset).toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/__tests__/overseer-advisor-service.test.ts b/packages/engine/src/__tests__/overseer-advisor-service.test.ts new file mode 100644 index 0000000000..e5f7a5a393 --- /dev/null +++ b/packages/engine/src/__tests__/overseer-advisor-service.test.ts @@ -0,0 +1,431 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Task } from "@fusion/core"; +import { OverseerAdvisorService, createParsingOverseerAgent } from "../overseer-advisor-service.js"; + +function baseTask(overrides: Partial = {}): Task { + return { + id: "FN-9001", + title: "t", + column: "in-progress", + status: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Task; +} + +describe("OverseerAdvisorService", () => { + it("soft-disables when no model and no agent factory", async () => { + const store = { + addSteeringComment: vi.fn(), + }; + const service = new OverseerAdvisorService({ + store, + resolveLevel: () => "autonomous", + resolveModel: () => null, + }); + const ok = await service.ensureTask(baseTask()); + expect(ok).toBe(false); + expect(store.addSteeringComment).not.toHaveBeenCalled(); + }); + + it("stays off when resolveEnabled is false even with model and agent factory", async () => { + const addSteeringComment = vi.fn(async () => ({})); + const agentFactory = vi.fn(async () => + createParsingOverseerAgent({ + systemPrompt: "x", + onAdvice: async () => {}, + complete: async () => JSON.stringify({ note: "should not run", severity: "nit" }), + }), + ); + const service = new OverseerAdvisorService({ + store: { addSteeringComment }, + resolveEnabled: () => false, + resolveLevel: () => "autonomous", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory, + }); + expect(await service.ensureTask(baseTask())).toBe(false); + expect(agentFactory).not.toHaveBeenCalled(); + expect(addSteeringComment).not.toHaveBeenCalled(); + }); + + it("injects concern notes at autonomous and skips content-free phrases", async () => { + const addSteeringComment = vi.fn(async () => ({})); + const recordRunAuditEvent = vi.fn((input) => ({ + id: "e1", + timestamp: new Date().toISOString(), + domain: "database", + mutationType: "overseer:intervention", + target: "FN-9001", + ...input, + })); + const store = { + addSteeringComment, + recordRunAuditEvent, + getRunAuditEvents: () => [], + getTask: async () => baseTask(), + }; + + const service = new OverseerAdvisorService({ + store, + resolveLevel: () => "autonomous", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async (_sys, user) => { + if (user.includes("noise-only")) return '{"silence":true}'; + return JSON.stringify({ + note: "You are editing dashboard; File Scope is engine only.", + severity: "concern", + }); + }, + }), + }); + + const task = baseTask(); + expect(await service.ensureTask(task)).toBe(true); + await service.onExecutorLogDelta(task.id, [ + { type: "text", text: "Opening packages/dashboard/app/foo.tsx", agent: "executor" }, + ]); + + await vi.waitFor(() => expect(addSteeringComment).toHaveBeenCalled()); + const text = addSteeringComment.mock.calls[0][1] as string; + expect(text).toContain("[session-advisor]"); + expect(text).toContain("File Scope is engine only"); + expect(text).toContain('severity="concern"'); + }); + + it("observe level does not inject steering comments", async () => { + const addSteeringComment = vi.fn(async () => ({})); + const store = { + addSteeringComment, + recordRunAuditEvent: vi.fn((input) => ({ + id: "e1", + timestamp: new Date().toISOString(), + domain: "database", + mutationType: "overseer:intervention", + target: "FN-9001", + ...input, + })), + getRunAuditEvents: () => [], + getTask: async () => baseTask(), + }; + + const service = new OverseerAdvisorService({ + store, + resolveLevel: () => "observe", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async () => + JSON.stringify({ note: "Consider extracting a helper.", severity: "nit" }), + }), + }); + + const task = baseTask(); + await service.ensureTask(task); + await service.onExecutorLogDelta(task.id, [{ type: "text", text: "writing helper", agent: "executor" }]); + await new Promise((r) => setTimeout(r, 50)); + expect(addSteeringComment).not.toHaveBeenCalled(); + }); + + it("withholds inject when task is user-paused", async () => { + const addSteeringComment = vi.fn(async () => ({})); + const paused = baseTask({ userPaused: true, paused: true }); + const store = { + addSteeringComment, + getTask: async () => paused, + }; + + const service = new OverseerAdvisorService({ + store, + resolveLevel: () => "autonomous", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async () => JSON.stringify({ note: "Should not inject", severity: "blocker" }), + }), + }); + + // ensureTask itself refuses paused tasks + expect(await service.ensureTask(paused)).toBe(false); + }); + + it("re-resolves level at inject time so mid-session observe flip does not inject", async () => { + const addSteeringComment = vi.fn(async () => ({})); + let level: "autonomous" | "observe" = "autonomous"; + const task = baseTask(); + const store = { + addSteeringComment, + recordRunAuditEvent: vi.fn((input) => ({ + id: "e1", + timestamp: new Date().toISOString(), + domain: "database", + mutationType: "overseer:intervention", + target: "FN-9001", + ...input, + })), + getRunAuditEvents: () => [], + getTask: async () => task, + }; + + const service = new OverseerAdvisorService({ + store, + resolveLevel: () => level, + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async () => + JSON.stringify({ note: "Concrete File Scope concern for level flip.", severity: "concern" }), + }), + }); + + expect(await service.ensureTask(task)).toBe(true); + // Operator flips to observe after ensure but before the first advice lands. + level = "observe"; + await service.onExecutorLogDelta(task.id, [ + { type: "text", text: "Editing wrong package after flip", agent: "executor" }, + ]); + await vi.waitFor(() => expect(store.recordRunAuditEvent).toHaveBeenCalled()); + expect(addSteeringComment).not.toHaveBeenCalled(); + }); + + it("re-resolves resolveEnabled at inject time so mid-session disable does not inject", async () => { + /* + FNXC:PlannerOversight 2026-07-14-19:34: + Greptile P1: if session advisor is toggled off after ensureTask, deliverAdvice + must clear the runtime and withhold inject (enablement is not frozen at ensure). + */ + const addSteeringComment = vi.fn(async () => ({})); + const task = baseTask({ sessionAdvisorEnabled: true }); + let enabled = true; + const store = { + addSteeringComment, + getTask: async () => task, + getSettings: async () => ({ autoMerge: true }), + recordRunAuditEvent: vi.fn((input) => ({ + id: "e1", + timestamp: new Date().toISOString(), + domain: "database", + mutationType: "overseer:intervention", + target: "FN-9001", + ...input, + })), + getRunAuditEvents: () => [], + }; + + const service = new OverseerAdvisorService({ + store, + settings: { autoMerge: true }, + resolveEnabled: () => enabled, + resolveLevel: () => "autonomous", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async () => + JSON.stringify({ note: "Must not inject after enablement flip.", severity: "blocker" }), + }), + }); + + expect(await service.ensureTask(task)).toBe(true); + expect(service.getTaskAdvisorSnapshot(task.id).active).toBe(true); + // Operator disables advisor mid-session (task override / project default / eye). + enabled = false; + Object.assign(task, { sessionAdvisorEnabled: false }); + + await service.onExecutorLogDelta(task.id, [ + { type: "text", text: "still working after advisor disabled", agent: "executor" }, + ]); + await vi.waitFor(() => expect(service.getTaskAdvisorSnapshot(task.id).active).toBe(false)); + expect(addSteeringComment).not.toHaveBeenCalled(); + }); + + it("withholds inject when settings autoMerge is false", async () => { + const addSteeringComment = vi.fn(async () => ({})); + // autoMerge:false on task is enough for allowsAutoMergeProcessing to withhold. + const task = baseTask({ autoMerge: false }); + const store = { + addSteeringComment, + getTask: async () => task, + }; + + const service = new OverseerAdvisorService({ + store, + settings: { autoMerge: false }, + resolveLevel: () => "autonomous", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async () => + JSON.stringify({ note: "Should not inject under autoMerge false.", severity: "blocker" }), + }), + }); + + // ensureTask consults human-control with settings — withhold before runtime starts. + expect(await service.ensureTask(task)).toBe(false); + expect(addSteeringComment).not.toHaveBeenCalled(); + }); + + it("re-fetches settings at inject time so a live autoMerge flip withholds", async () => { + const addSteeringComment = vi.fn(async () => ({})); + const task = baseTask({ autoMerge: undefined }); + let settings = { autoMerge: true as boolean | undefined }; + /* + FNXC:PlannerOversight 2026-07-14-18:16: + Prefer vi.waitFor over a fixed setTimeout so the inject-time withhold settles + without flaky fixed sleeps (CodeRabbit on #2082). Withhold returns before + emitSteeringSafe, so wait on getSettings (the inject-time re-fetch) rather + than recordRunAuditEvent. + */ + const getSettings = vi.fn(async () => settings); + const store = { + addSteeringComment, + getTask: async () => task, + getSettings, + recordRunAuditEvent: vi.fn((input) => ({ + id: "e1", + timestamp: new Date().toISOString(), + domain: "database", + mutationType: "overseer:intervention", + target: "FN-9001", + ...input, + })), + getRunAuditEvents: () => [], + }; + + const service = new OverseerAdvisorService({ + store, + settings: { autoMerge: true }, + resolveLevel: () => "autonomous", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async () => + JSON.stringify({ note: "Concrete note after settings flip.", severity: "concern" }), + }), + }); + + expect(await service.ensureTask(task)).toBe(true); + // Flip project autoMerge off and mark task so allowsAutoMergeProcessing withholds. + settings = { autoMerge: false }; + Object.assign(task, { autoMerge: false }); + + await service.onExecutorLogDelta(task.id, [ + { type: "text", text: "still working after autoMerge flip", agent: "executor" }, + ]); + await vi.waitFor(() => expect(getSettings).toHaveBeenCalled()); + expect(addSteeringComment).not.toHaveBeenCalled(); + }); + + it("withholds inject when getSettings fails (fail closed)", async () => { + /* + FNXC:PlannerOversight 2026-07-14-18:16: + Greptile P1 security: a getSettings() throw must not fall back to a stale + autoMerge:true cache — withhold inject instead. + */ + const addSteeringComment = vi.fn(async () => ({})); + const task = baseTask({ autoMerge: undefined }); + const getSettings = vi.fn(async () => { + throw new Error("settings store unavailable"); + }); + const store = { + addSteeringComment, + getTask: async () => task, + getSettings, + recordRunAuditEvent: vi.fn((input) => ({ + id: "e1", + timestamp: new Date().toISOString(), + domain: "database", + mutationType: "overseer:intervention", + target: "FN-9001", + ...input, + })), + getRunAuditEvents: () => [], + }; + + const service = new OverseerAdvisorService({ + store, + settings: { autoMerge: true }, + resolveLevel: () => "autonomous", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async () => + JSON.stringify({ note: "Must not inject when settings read fails.", severity: "blocker" }), + }), + }); + + expect(await service.ensureTask(task)).toBe(true); + await service.onExecutorLogDelta(task.id, [ + { type: "text", text: "working while settings store is down", agent: "executor" }, + ]); + // Wait until inject-time getSettings ran (then failed closed); no steering inject. + await vi.waitFor(() => expect(getSettings).toHaveBeenCalled()); + expect(addSteeringComment).not.toHaveBeenCalled(); + expect(store.recordRunAuditEvent).not.toHaveBeenCalled(); + }); + + it("withholds inject when getSettings resolves to undefined (fail closed)", async () => { + /* + FNXC:PlannerOversight 2026-07-14-18:25: + Greptile P1: undefined live settings must not fall back to a stale + autoMerge:true cache — withhold inject the same as a throw. + */ + const addSteeringComment = vi.fn(async () => ({})); + const task = baseTask({ autoMerge: undefined }); + const getSettings = vi.fn(async () => undefined); + const store = { + addSteeringComment, + getTask: async () => task, + getSettings, + recordRunAuditEvent: vi.fn((input) => ({ + id: "e1", + timestamp: new Date().toISOString(), + domain: "database", + mutationType: "overseer:intervention", + target: "FN-9001", + ...input, + })), + getRunAuditEvents: () => [], + }; + + const service = new OverseerAdvisorService({ + store, + settings: { autoMerge: true }, + resolveLevel: () => "autonomous", + resolveModel: () => ({ provider: "mock", modelId: "scripted" }), + agentFactory: async ({ systemPrompt, onAdvice }) => + createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async () => + JSON.stringify({ note: "Must not inject when settings are missing.", severity: "blocker" }), + }), + }); + + expect(await service.ensureTask(task)).toBe(true); + await service.onExecutorLogDelta(task.id, [ + { type: "text", text: "working while settings are missing", agent: "executor" }, + ]); + await vi.waitFor(() => expect(getSettings).toHaveBeenCalled()); + expect(addSteeringComment).not.toHaveBeenCalled(); + expect(store.recordRunAuditEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/__tests__/overseer-session-delta.test.ts b/packages/engine/src/__tests__/overseer-session-delta.test.ts new file mode 100644 index 0000000000..5ec2d9938f --- /dev/null +++ b/packages/engine/src/__tests__/overseer-session-delta.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { formatOverseerSessionDelta, isOverseerSelfAdvisoryText } from "../overseer-session-delta.js"; + +describe("isOverseerSelfAdvisoryText", () => { + it("detects planner-oversight and advisory markers", () => { + expect(isOverseerSelfAdvisoryText("[planner-oversight] stuck")).toBe(true); + expect(isOverseerSelfAdvisoryText('[session-advisor] severity="concern" note')).toBe(true); + expect(isOverseerSelfAdvisoryText("stop")).toBe(true); + expect(isOverseerSelfAdvisoryText("Edited packages/engine/src/foo.ts")).toBe(false); + }); +}); + +describe("formatOverseerSessionDelta", () => { + it("returns null for empty input", () => { + expect(formatOverseerSessionDelta([])).toBeNull(); + }); + + it("renders text and tool entries and filters self-advisories", () => { + const md = formatOverseerSessionDelta([ + { type: "text", text: "I will edit the dashboard now.", agent: "executor" }, + { type: "text", text: "[planner-oversight] Stage stuck", agent: "agent" }, + { type: "tool", text: "read", detail: "packages/engine/src/foo.ts", agent: "executor" }, + { type: "text", text: "done", agent: "overseer" }, + ]); + expect(md).toContain("### Session update"); + expect(md).toContain("I will edit the dashboard now."); + expect(md).toContain("read"); + expect(md).not.toContain("[planner-oversight]"); + expect(md).not.toMatch(/#### overseer/); + }); + + it("returns null when every entry is filtered", () => { + expect( + formatOverseerSessionDelta([{ type: "text", text: "[session-advisor] note", agent: "executor" }]), + ).toBeNull(); + }); +}); diff --git a/packages/engine/src/__tests__/overseer-watchdog.test.ts b/packages/engine/src/__tests__/overseer-watchdog.test.ts new file mode 100644 index 0000000000..8278512dfc --- /dev/null +++ b/packages/engine/src/__tests__/overseer-watchdog.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { discoverOverseerWatchdogFiles, formatOverseerWatchdogPromptBlocks } from "../overseer-watchdog.js"; + +describe("discoverOverseerWatchdogFiles", () => { + it("returns empty when nothing is readable", () => { + const items = discoverOverseerWatchdogFiles({ + cwd: "/tmp/project-a", + repoRoot: "/tmp/project-a", + readText: () => null, + }); + expect(items).toEqual([]); + }); + + it("loads user and project files; leaf is last among project", () => { + const files: Record = { + "/user/agent/WATCHDOG.md": "user watch", + "/repo/OVERSEER.md": "root overseer", + "/repo/pkg/WATCHDOG.md": "pkg watch", + }; + const items = discoverOverseerWatchdogFiles({ + cwd: "/repo/pkg", + repoRoot: "/repo", + agentDir: "/user/agent", + readText: (p) => files[p] ?? null, + }); + expect(items.map((i) => i.content)).toEqual(["user watch", "root overseer", "pkg watch"]); + expect(items[0].level).toBe("user"); + expect(items[items.length - 1].content).toBe("pkg watch"); + }); + + it("never throws on reader errors", () => { + const items = discoverOverseerWatchdogFiles({ + cwd: "/x", + repoRoot: "/x", + readText: () => { + throw new Error("boom"); + }, + }); + expect(items).toEqual([]); + }); +}); + +describe("formatOverseerWatchdogPromptBlocks", () => { + it("wraps content in attention blocks", () => { + const blocks = formatOverseerWatchdogPromptBlocks([ + { path: "/r/OVERSEER.md", content: "Watch merge trait", level: "project", depth: 0 }, + ]); + expect(blocks[0]).toContain(" void; /** Optional callback invoked alongside tool logging (e.g. for SSE streaming). */ onAgentTool?: (taskId: string, toolName: string) => void; + /* + FNXC:PlannerOversight 2026-07-13-23:00: + Session-advisor seam: after durable entries flush, notify the overseer + advisor runtime with the batch. Must be fail-soft (never throw into flush). + */ + onEntriesFlushed?: (taskId: string, entries: AgentLogEntry[]) => void | Promise; /** Byte threshold for automatic flush. Defaults to 1024. */ flushSizeBytes?: number; /** Timer interval (ms) for periodic flush. Defaults to 500. */ @@ -217,6 +223,7 @@ export class AgentLogger { private readonly agent?: AgentRole; private readonly externalTextCb?: (taskId: string, delta: string) => void; private readonly externalToolCb?: (taskId: string, toolName: string) => void; + private readonly onEntriesFlushedCb?: (taskId: string, entries: AgentLogEntry[]) => void | Promise; private readonly log = createLogger("agent-logger"); private readonly persistAgentToolOutput: boolean; private readonly persistAgentThinkingLog: boolean; @@ -242,6 +249,7 @@ export class AgentLogger { this.agent = options.agent; this.externalTextCb = options.onAgentText; this.externalToolCb = options.onAgentTool; + this.onEntriesFlushedCb = options.onEntriesFlushed; this.flushSizeBytes = options.flushSizeBytes ?? FLUSH_SIZE_BYTES; this.flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS; /* @@ -582,5 +590,20 @@ export class AgentLogger { ), ); } + + // FNXC:PlannerOversight 2026-07-13-23:00: best-effort session-advisor notify after durable flush. + if (this.onEntriesFlushedCb && this.taskId && entries.length > 0) { + try { + await Promise.resolve(this.onEntriesFlushedCb(this.taskId, entries)).catch((err) => { + this.log.warn( + `onEntriesFlushed callback failed for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } catch (err) { + this.log.warn( + `onEntriesFlushed callback threw for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } } } diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 0e7192d89b..079bd82185 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -1699,6 +1699,15 @@ export interface TaskExecutorOptions { workflowAuthoritativeDispatch?: (task: Task) => Promise; onAgentText?: (taskId: string, delta: string) => void; onAgentTool?: (taskId: string, toolName: string) => void; + /* + FNXC:PlannerOversight 2026-07-13-23:05: + Session-advisor live delta path — AgentLogger invokes this after durable + log flushes. Fail-soft; must not throw. + */ + onExecutorLogFlushed?: ( + taskId: string, + entries: Array<{ type?: string; text?: string; detail?: string; agent?: string }>, + ) => void; autoRecoveryDispatcher?: AutoRecoveryDispatcher; /** PR-entity node deps (U3): assembled `PrNodeDeps` (store + injected GitHub * callbacks) for the `pr-create`/`pr-respond`/`pr-merge` workflow nodes. The @@ -2989,6 +2998,15 @@ export class TaskExecutor { return assertMcpResolutionSucceeded(resolved); } + /** + * FNXC:PlannerOversight 2026-07-13-23:05: + * Wire session-advisor live log flush after ProjectEngine starts (options are + * captured at TaskExecutor construction time; this setter updates the callback). + */ + setOnExecutorLogFlushed(cb: TaskExecutorOptions["onExecutorLogFlushed"]): void { + this.options = { ...this.options, onExecutorLogFlushed: cb }; + } + constructor( private store: TaskStore, private rootDir: string, @@ -10967,6 +10985,14 @@ export class TaskExecutor { stuckDetector?.recordActivity(taskId); this.options.onAgentTool?.(taskId, toolName); }, + // FNXC:PlannerOversight 2026-07-13-23:05: live session-advisor delta path (fail-soft). + onEntriesFlushed: (taskId, entries) => { + try { + this.options.onExecutorLogFlushed?.(taskId, entries); + } catch { + /* ignore */ + } + }, }); const agentWork = async () => { diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e082a3610f..64781fcd3b 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -752,6 +752,31 @@ export { type OverseerHumanControlTask, type OverseerHumanControlSettings, } from "./overseer-human-control-policy.js"; +// FNXC:PlannerOversight 2026-07-13-23:05: session-advisor (OMP advisor parity) public surface. +export { + OverseerAdvisorRuntime, + type OverseerAdvisorAgent, + type OverseerAdvisorRuntimeHost, + type OverseerAdvisorRuntimeOptions, +} from "./overseer-advisor-runtime.js"; +export { + OverseerAdvisorService, + createParsingOverseerAgent, + type OverseerAdvisorServiceOptions, + type OverseerAdvisorModelConfig, +} from "./overseer-advisor-service.js"; +export { + OverseerAdviseRecorder, + parseAdvisorReplyForAdvice, + extractAdvisorAssistantText, + OVERSEER_ADVISOR_SYSTEM_PROMPT, + OVERSEER_ADVISOR_REPLY_CONTRACT, +} from "./overseer-advise-tool.js"; +export { + discoverOverseerWatchdogFiles, + formatOverseerWatchdogPromptBlocks, +} from "./overseer-watchdog.js"; +export { formatOverseerSessionDelta, isOverseerSelfAdvisoryText } from "./overseer-session-delta.js"; export { decidePlannerRecovery, PLANNER_RECOVERY_MAX_ATTEMPTS, diff --git a/packages/engine/src/overseer-advise-tool.ts b/packages/engine/src/overseer-advise-tool.ts new file mode 100644 index 0000000000..aa782a6bd0 --- /dev/null +++ b/packages/engine/src/overseer-advise-tool.ts @@ -0,0 +1,300 @@ +/** + * FNXC:PlannerOversight 2026-07-13-22:55: + * Session-advisor `advise` tool contract (OMP AdviseTool parity). Accepts + * one note + optional severity; applies severity-rank dedupe at the tool + * layer, then forwards to the host callback. The host still runs + * OverseerEmissionGuard before inject. Pure execute path for unit tests — + * no pi tool registry dependency required for the controller to work. + */ + +import { + normalizeOverseerAdviceNote, + overseerAdviceSeverityRank, + type OverseerAdviceSeverity, +} from "@fusion/core"; + +export interface OverseerAdviseParams { + note: string; + severity?: OverseerAdviceSeverity; +} + +export interface OverseerAdviseResult { + recorded: boolean; + message: string; + details: { note: string; severity?: OverseerAdviceSeverity }; +} + +/** + * In-memory advise recorder with OMP-style severity-rank dedupe on the tool + * itself (defense in depth alongside OverseerEmissionGuard). + */ +export class OverseerAdviseRecorder { + #deliveredNoteSeverities = new Map(); + + constructor( + private readonly onAdvice: (note: string, severity?: OverseerAdviceSeverity) => void | Promise, + ) {} + + resetDeliveredNotes(): void { + this.#deliveredNoteSeverities.clear(); + } + + async execute(args: OverseerAdviseParams): Promise { + const note = typeof args?.note === "string" ? args.note : ""; + const severity = args?.severity; + const key = normalizeOverseerAdviceNote(note) || note.trim().replace(/\s+/g, " "); + const rank = overseerAdviceSeverityRank(severity); + const previousRank = this.#deliveredNoteSeverities.get(key) ?? 0; + if (!key || rank <= previousRank) { + return { + recorded: false, + message: "Duplicate advice ignored.", + details: { note, severity }, + }; + } + this.#deliveredNoteSeverities.set(key, rank); + await this.onAdvice(note, severity); + return { + recorded: true, + message: "Recorded.", + details: { note, severity }, + }; + } +} + +/** + * Parse a free-form advisor model reply for a single ADVISE payload. + * Accepts: + * - ```json { "note": "...", "severity": "concern" } ``` + * - ADVISE: note text + * - bare JSON object + * Returns null for silence / unparseable content. + */ +export function parseAdvisorReplyForAdvice(text: string): OverseerAdviseParams | null { + try { + if (typeof text !== "string" || !text.trim()) return null; + const trimmed = text.trim(); + + // Fenced JSON + const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + const jsonCandidate = fence?.[1]?.trim() ?? (trimmed.startsWith("{") ? trimmed : null); + if (jsonCandidate) { + try { + const parsed = JSON.parse(jsonCandidate) as { note?: unknown; severity?: unknown; silence?: unknown }; + if (parsed.silence === true || parsed.note === null) return null; + if (typeof parsed.note === "string" && parsed.note.trim()) { + const severity = + parsed.severity === "nit" || parsed.severity === "concern" || parsed.severity === "blocker" + ? parsed.severity + : undefined; + return { note: parsed.note.trim(), severity }; + } + } catch { + /* fall through */ + } + } + + const adviseLine = trimmed.match(/^ADVISE(?:\s*\((nit|concern|blocker)\))?\s*:\s*(.+)$/im); + if (adviseLine?.[2]?.trim()) { + const severity = + adviseLine[1] === "nit" || adviseLine[1] === "concern" || adviseLine[1] === "blocker" + ? adviseLine[1] + : undefined; + return { note: adviseLine[2].trim(), severity }; + } + + // Explicit silence tokens + if (/^(silence|none|no advice|ok|lgtm)\.?$/i.test(trimmed)) return null; + + return null; + } catch { + return null; + } +} + +/* +FNXC:PlannerOversight 2026-07-14-12:30: +Session-advisor system prompt expanded from oh-my-pi advisor system.md +(peer-programmer judgment policy) while keeping Fusion's JSON reply channel +(no first-class advise tool on this loop yet). OVERSEER.md / WATCHDOG.md +blocks are appended by OverseerAdvisorService after this constant. +*/ + +/** + * Full system prompt for the LLM session advisor: OMP-style persona + critical + * silence rules + severity criteria + Fusion domain anchors + JSON reply contract. + */ +export const OVERSEER_ADVISOR_SYSTEM_PROMPT = `You bring a different angle, advocating for the operator and for code quality & robustness. +You shadow the Fusion executor agent on a task as a peer programmer: +- Sharpen their strategy, problem-solving, and judgment; point to the cleaner approach when one exists. +- Push back on a premature "done", thin verification, and reasoning that skipped a step. +- Hold them to what the task actually requires (PROMPT.md, File Scope, verification, standing project rules); flag drift the moment it starts. +- Pull them out of rabbit holes, overthinking, and edge cases before they get baked in. + +Look where the agent is NOT — bring the angle they skipped. NEVER re-run reasoning they already have. +Offer that view before they sink work into the wrong direction. + + +You are reviewing a Fusion task executor in a worktree (or workspace sub-repo). +Binding constraints when present in the transcript or project context: +- The task PROMPT.md / acceptance criteria and any ## Symptom Verification / ## File Scope sections. +- Declared File Scope: do not cheer edits outside it; flag scope drift early. +- Verification expectations (file-scoped tests, gate commands): thin or missing verification on a risky change is a concern/blocker. +- Operator steering comments and explicit user instructions in the transcript are binding. +- Port 4040 is reserved; unbounded temp-root scans are forbidden; flaky-test appeasement is forbidden. +When OVERSEER.md / WATCHDOG.md attention blocks are appended below, treat them as high-priority review priorities for this project. + + + +You receive the executor's transcript incrementally (session updates), including tool calls and results when present. +If this session grants investigative tools (read/grep/glob or similar), use them sparingly to verify suspicions before raising a concern or blocker. +Keep exploration lean: prefer 2–3 lookups per update; go deeper only before a blocker on a critical bug. +If you have no tools, reason only from the rendered transcript — do not invent file contents or hidden tool arguments. +Advising is your primary channel; do not try to approve merges, change task column, or mutate lifecycle state. + + + +- Prefer silence when the agent is on track. +- At most one piece of advice per update (one JSON object). +- Address the agent directly. +- Offer alternatives, not lectures. +- NEVER restate information the agent already has, including errors they have seen (type errors, failed builds, failing tests, lint, tool errors already in the transcript). +- NEVER repeat advice you already gave; give the agent room to act on prior advice before raising the same theme again. +- NEVER nitpick about things the operator or PROMPT already accepted. +- You are operator-aligned: treat stated requirements as binding and justified corrections as signal. + + + +A low-confidence bar applies ONLY to concrete technical risk: +- Generic uncertainty, vague unease, or requirement ambiguity → stay SILENT. + +NEVER advise just to second-guess decisions the agent understands and is committed to, if you are not certain. + +NEVER advise on intent or process: +- Do not push the agent to ask for clarification, confirm scope, or summarize input before acting. +- Do not question whether the task ask is clear enough. +- Intent is the executor's domain; it defaults to informed action. +- Your lane: correctness, edge cases, design, and process that is already specified. + +NEVER police scope or ambition: +- A large diff, wholesale rewrite, or expanding plan is NOT a problem by itself — often it is exactly what the task needs. +- Object to size or reach ONLY when it contradicts an explicit instruction in the transcript or File Scope / PROMPT — and cite that instruction. + +NEVER raise backwards compatibility unless the task, PROMPT, or standing project rule explicitly requires it: +- No unsolicited concerns about breaking changes, deprecation shims, migration paths, or API stability. +- Absent such a requirement, clean cutover is the correct default. + +Cite only transcript evidence or tool output you personally inspected. +Arguments absent from the rendered transcript are UNKNOWN: +- NEVER assert concrete values, array indexes, serialization shapes, or caller mistakes for hidden arguments. +- Hidden/omitted arguments + failure? Say what is observable; suggest inspecting the missing field. +Cite the exact instruction or risk. + + + +**nit** +- Non-urgent cleanup, refactor, style, missed opportunity. +- Agent can keep working; fold later. +- Examples: non-blocking edge cases, simplifications, a better approach to consider. + +**concern** +- Agent might be heading wrong or missed something material; they decide. +- Use when: wrong code path; fragile approach when better exists; missing constraint; edge case about to be baked in; churning (repeating failed attempts without progress); operator keeps correcting and the agent is not adjusting; verification too thin for the risk just taken. + +**blocker** +- Stop and reconsider. Use ONLY when continuing will clearly: + - Contradict an explicit instruction in the transcript / PROMPT / File Scope — cite it; size or rewrite breadth alone is NEVER the trigger. + - Require the operator to interrupt later because the agent is going in circles without a solution. + - Be fundamentally unsound. + - Hand off as "done" work never exercised against the real ask or Symptom Verification. + - Ship on verification too thin to catch the risk just taken on. + - Be lost in overthinking or a rabbit hole that is plainly stalling the goal. +- Verify thoroughly before raising a blocker. + +You MAY suggest an approach or fix when you are confident. +Offer the better design, not just the warning. +Never emit content-free notes such as only "stop", "done", "LGTM", or "no issues". + + + +When you must advise, reply with ONLY one JSON object (no prose outside it): +{"note":"","severity":"nit"|"concern"|"blocker"} + +If you have nothing to add, reply with exactly: +{"silence":true} + +The note must be specific (what/where/why). Prefer citing File Scope, PROMPT, a failing check, or a transcript step. +`; + +/** + * @deprecated Prefer {@link OVERSEER_ADVISOR_SYSTEM_PROMPT}. Kept as an alias for + * existing imports; value is the full expanded system prompt. + */ +export const OVERSEER_ADVISOR_REPLY_CONTRACT = OVERSEER_ADVISOR_SYSTEM_PROMPT; + +/** + * FNXC:PlannerOversight 2026-07-14-14:00: + * Extract the last assistant text from a heterogeneous agent session shape. + * createResolvedAgentSession may resolve pi or plugin runtimes; do not assume + * a single messages layout. Never throws — returns "" when nothing usable is found. + */ +export function extractAdvisorAssistantText(session: unknown): string { + try { + if (!session || typeof session !== "object") return ""; + const s = session as Record; + const candidates: unknown[] = []; + + if (Array.isArray(s.messages)) candidates.push(s.messages); + const state = s.state; + if (state && typeof state === "object") { + const st = state as Record; + if (Array.isArray(st.messages)) candidates.push(st.messages); + } + const agent = s.agent; + if (agent && typeof agent === "object") { + const ag = agent as Record; + if (Array.isArray(ag.messages)) candidates.push(ag.messages); + const agState = ag.state; + if (agState && typeof agState === "object") { + const ast = agState as Record; + if (Array.isArray(ast.messages)) candidates.push(ast.messages); + } + } + if (typeof s.getMessages === "function") { + try { + const got = (s.getMessages as () => unknown)(); + if (Array.isArray(got)) candidates.push(got); + } catch { + /* ignore */ + } + } + + for (const list of candidates) { + if (!Array.isArray(list) || list.length === 0) continue; + for (let i = list.length - 1; i >= 0; i--) { + const msg = list[i]; + if (!msg || typeof msg !== "object") continue; + const m = msg as Record; + if (m.role !== "assistant") continue; + const content = m.content; + if (typeof content === "string" && content.trim()) return content; + if (Array.isArray(content)) { + const text = content + .map((block) => { + if (typeof block === "string") return block; + if (block && typeof block === "object" && "text" in block) { + return String((block as { text: unknown }).text ?? ""); + } + return ""; + }) + .join("\n") + .trim(); + if (text) return text; + } + } + } + return ""; + } catch { + return ""; + } +} diff --git a/packages/engine/src/overseer-advisor-runtime.ts b/packages/engine/src/overseer-advisor-runtime.ts new file mode 100644 index 0000000000..27cc892343 --- /dev/null +++ b/packages/engine/src/overseer-advisor-runtime.ts @@ -0,0 +1,250 @@ +/** + * 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; + 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; + /** Clear per-update emission-guard budget before each prompt cycle. */ + beginAdvisorUpdate?(): void; + onTurnError?(error: unknown): void | Promise; + 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; +} + +function defaultSleep(ms: number): Promise { + 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; + + #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): 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): 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): 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 { + 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; + } + } +} diff --git a/packages/engine/src/overseer-advisor-service.ts b/packages/engine/src/overseer-advisor-service.ts new file mode 100644 index 0000000000..5481833672 --- /dev/null +++ b/packages/engine/src/overseer-advisor-service.ts @@ -0,0 +1,425 @@ +/** + * FNXC:PlannerOversight 2026-07-13-22:55: + * Per-project session-advisor service: owns per-task emission guards, + * advisor runtimes, and delivery through addSteeringComment + intervention + * façade. Model gate: no runtime is started without a configured overseer + * model (or an injected agent factory for tests). Human-control and + * effective oversight level are re-checked at inject time. + */ + +import { + OverseerEmissionGuard, + emitOverseerSteering, + type OverseerAdviceSeverity, + type PlannerOversightLevel, + type Task, + type Settings, +} from "@fusion/core"; +import { createLogger } from "./logger.js"; +import { evaluateOverseerHumanControl } from "./overseer-human-control-policy.js"; +import { + OverseerAdvisorRuntime, + type OverseerAdvisorAgent, + type OverseerAdvisorRuntimeHost, +} from "./overseer-advisor-runtime.js"; +import { + OVERSEER_ADVISOR_SYSTEM_PROMPT, + OverseerAdviseRecorder, + parseAdvisorReplyForAdvice, +} from "./overseer-advise-tool.js"; +import { discoverOverseerWatchdogFiles, formatOverseerWatchdogPromptBlocks } from "./overseer-watchdog.js"; +import type { OverseerLogEntry } from "./overseer-session-delta.js"; + +const log = createLogger("overseer-advisor-service"); + +export interface OverseerAdvisorModelConfig { + provider: string; + modelId: string; +} + +export interface OverseerAdvisorServiceStore { + addSteeringComment(taskId: string, text: string, author: "agent" | "user"): Promise; + getTask?(taskId: string): Promise; + getSettings?(): Promise; + recordRunAuditEvent?(input: unknown): unknown; + getRunAuditEvents?(options?: unknown): unknown[]; + getAgentLogs?(taskId: string, opts?: { limit?: number }): Promise; +} + +export type OverseerAdvisorAgentFactory = (ctx: { + taskId: string; + model: OverseerAdvisorModelConfig; + systemPrompt: string; + onAdvice: (note: string, severity?: OverseerAdviceSeverity) => void | Promise; +}) => Promise; + +export interface OverseerAdvisorServiceOptions { + store: OverseerAdvisorServiceStore; + /* + FNXC:PlannerOversight 2026-07-14-12:00: + Explicit enable gate (task/project/workflow inheritance via resolveEnabled, default + false). When provided and false, no session advisor runtime is created even + if a model is configured. Tests may omit this and rely on model/agentFactory. + */ + resolveEnabled?: (task: Task) => boolean | Promise; + /** Resolve model for the overseer; return null to leave session AI soft-disabled. */ + resolveModel?: (task: Task) => OverseerAdvisorModelConfig | null | Promise; + /** Resolve effective planner oversight level for a task. */ + resolveLevel: (task: Task) => PlannerOversightLevel | Promise; + /** Optional worktree/cwd for WATCHDOG discovery. */ + resolveCwd?: (task: Task) => string | undefined; + agentFactory?: OverseerAdvisorAgentFactory; + settings?: Pick; +} + +interface TaskAdvisorState { + guard: OverseerEmissionGuard; + runtime: OverseerAdvisorRuntime; + advise: OverseerAdviseRecorder; + level: PlannerOversightLevel; + lastAdviceSeverity?: OverseerAdviceSeverity; + backlog: number; +} + +/** + * Builds a simple agent that prompts an injectable `complete(system, user)` + * function and parses the reply — used when no full pi session factory is wired. + */ +export function createParsingOverseerAgent(opts: { + complete: (systemPrompt: string, userBatch: string) => Promise; + systemPrompt: string; + onAdvice: (note: string, severity?: OverseerAdviceSeverity) => void | Promise; +}): OverseerAdvisorAgent { + const advise = new OverseerAdviseRecorder(opts.onAdvice); + return { + async prompt(input: string) { + const reply = await opts.complete(opts.systemPrompt, input); + const parsed = parseAdvisorReplyForAdvice(reply); + if (parsed) { + await advise.execute(parsed); + } + }, + reset() { + advise.resetDeliveredNotes(); + }, + }; +} + +export class OverseerAdvisorService { + private readonly store: OverseerAdvisorServiceStore; + private readonly resolveEnabled?: OverseerAdvisorServiceOptions["resolveEnabled"]; + private readonly resolveModel?: OverseerAdvisorServiceOptions["resolveModel"]; + private readonly resolveLevel: OverseerAdvisorServiceOptions["resolveLevel"]; + private readonly resolveCwd?: OverseerAdvisorServiceOptions["resolveCwd"]; + private readonly agentFactory?: OverseerAdvisorAgentFactory; + private settings: Pick | undefined; + private readonly tasks = new Map(); + + constructor(options: OverseerAdvisorServiceOptions) { + this.store = options.store; + this.resolveEnabled = options.resolveEnabled; + this.resolveModel = options.resolveModel; + this.resolveLevel = options.resolveLevel; + this.resolveCwd = options.resolveCwd; + this.agentFactory = options.agentFactory; + this.settings = options.settings; + } + + setSettings(settings: Pick | undefined): void { + this.settings = settings; + } + + /** Snapshot fields for PlannerOverseerRuntimeSnapshot enrichment. */ + getTaskAdvisorSnapshot(taskId: string): { + backlog?: number; + lastAdviceSeverity?: OverseerAdviceSeverity; + active: boolean; + } { + const state = this.tasks.get(taskId); + if (!state) return { active: false }; + return { + active: true, + backlog: state.runtime.backlog, + lastAdviceSeverity: state.lastAdviceSeverity, + }; + } + + async ensureTask(task: Task): Promise { + try { + if (this.tasks.has(task.id)) return true; + + /* + FNXC:PlannerOversight 2026-07-14-12:00: + LLM session advisor is opt-in (default off). resolveEnabled false → no + runtime, no model spend. Lifecycle overseer is unrelated. + */ + if (this.resolveEnabled) { + const enabled = await this.resolveEnabled(task); + if (!enabled) return false; + } + + const level = await this.resolveLevel(task); + if (level === "off") return false; + + const model = this.resolveModel ? await this.resolveModel(task) : null; + if (!model && !this.agentFactory) { + // Soft-disable: no model configured (A1). + return false; + } + + const human = evaluateOverseerHumanControl(task, this.settings); + if (human.withhold) return false; + + const guard = new OverseerEmissionGuard(); + /* + FNXC:PlannerOversight 2026-07-14-00:10: + Greptile P1: do NOT capture level into the advise callback closure. + deliverAdvice re-resolves effective oversight level at inject time so an + operator flip to observe/off mid-session cannot keep injecting. + */ + const advise = new OverseerAdviseRecorder(async (note, severity) => { + await this.deliverAdvice(task.id, note, severity); + }); + + const cwd = this.resolveCwd?.(task); + const watchdogBlocks = + cwd != null + ? formatOverseerWatchdogPromptBlocks(discoverOverseerWatchdogFiles({ cwd })) + : []; + // FNXC:PlannerOversight 2026-07-14-12:30: OMP-expanded system prompt + project OVERSEER/WATCHDOG blocks. + const systemPrompt = [OVERSEER_ADVISOR_SYSTEM_PROMPT, ...watchdogBlocks].join("\n\n"); + + const onAdvice = async (note: string, severity?: OverseerAdviceSeverity) => { + await advise.execute({ note, severity }); + }; + + let agent: OverseerAdvisorAgent | null = null; + if (this.agentFactory && model) { + agent = await this.agentFactory({ + taskId: task.id, + model, + systemPrompt, + onAdvice, + }); + } else if (this.agentFactory) { + agent = await this.agentFactory({ + taskId: task.id, + model: model ?? { provider: "mock", modelId: "scripted" }, + systemPrompt, + onAdvice, + }); + } + + if (!agent) return false; + + const host: OverseerAdvisorRuntimeHost = { + beginAdvisorUpdate: () => guard.beginUpdate(), + enqueueAdvice: async (note, severity) => { + // Runtime path when agent calls host directly; prefer advise recorder. + await advise.execute({ note, severity }); + }, + notifyFailure: (err) => { + log.warn(`session advisor failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + }, + }; + + const runtime = new OverseerAdvisorRuntime({ agent, host }); + // Seed to "now" so enabling mid-task does not replay full history. + // Host may immediately push a snapshot afterward if desired. + runtime.seedTo(0); + + this.tasks.set(task.id, { + guard, + runtime, + advise, + level, + backlog: 0, + }); + return true; + } catch (err) { + log.warn(`ensureTask failed for ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + } + + /** + * Feed new executor log entries into the task's advisor runtime. + * Creates the runtime on demand when `task` is provided. + */ + async onExecutorLogDelta( + taskId: string, + entries: ReadonlyArray, + task?: Task, + ): Promise { + try { + let state = this.tasks.get(taskId); + if (!state && task) { + const ok = await this.ensureTask(task); + if (!ok) return; + state = this.tasks.get(taskId); + } + if (!state || entries.length === 0) return; + state.runtime.onLogDelta(entries); + } catch (err) { + log.warn(`onExecutorLogDelta failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + clear(taskId: string): void { + const state = this.tasks.get(taskId); + if (!state) return; + state.runtime.dispose(); + this.tasks.delete(taskId); + } + + clearAll(): void { + for (const id of [...this.tasks.keys()]) { + this.clear(id); + } + } + + /** + * Test/helper: run emission guard against a note without a full runtime. + */ + acceptNoteForTest(taskId: string, note: string, severity?: OverseerAdviceSeverity): boolean { + const state = this.tasks.get(taskId); + if (!state) return false; + state.guard.beginUpdate(); + return state.guard.accept({ note, severity }); + } + + /** + * FNXC:PlannerOversight 2026-07-14-00:10: + * Inject-time policy: re-resolve oversight level + human-control from the + * live task/settings so mid-session flips (level→observe/off, autoMerge:false) + * cannot be bypassed by a level captured at ensureTask time. + * + * FNXC:PlannerOversight 2026-07-14-19:34: + * Also re-check resolveEnabled at inject time. Operator can disable the session + * advisor (task/project/Quick Add) while a runtime is still live; without this + * re-check, ensure-time enablement would still inject [session-advisor] comments. + */ + private async deliverAdvice( + taskId: string, + note: string, + severity: OverseerAdviceSeverity | undefined, + ): Promise { + try { + const state = this.tasks.get(taskId); + if (!state) return; + + // Emission guard — load-bearing silence/dedupe. + if (!state.guard.accept({ note, severity })) { + return; + } + + const task = this.store.getTask ? await this.store.getTask(taskId) : undefined; + if (!task) { + // Cannot verify level/human-control without a task — refuse inject. + return; + } + + /* + FNXC:PlannerOversight 2026-07-14-19:34: + Greptile P1: re-resolve advisor enablement on the live task before inject. + If the operator turned session advisor off mid-session, tear down the + runtime and withhold — same shape as level→off. + */ + if (this.resolveEnabled) { + const enabled = await this.resolveEnabled(task); + if (!enabled) { + this.clear(taskId); + return; + } + } + + /* + FNXC:PlannerOversight 2026-07-14-14:00: + CodeRabbit: do not rely solely on poll-refreshed this.settings — the live + log-flush inject path can race a project autoMerge flip. Prefer a fresh + getSettings() at inject time when the store exposes it. + + FNXC:PlannerOversight 2026-07-14-18:16: + Greptile P1 security: when store.getSettings() fails, do NOT fall back to + the cached settings for inject-time human-control evaluation. A stale + cache can still say autoMerge:true after the operator flipped the project + to autoMerge:false, which would bypass the human-review withhold and inject + a [session-advisor] steering comment. Fail closed: withhold delivery when + current settings cannot be loaded. + + FNXC:PlannerOversight 2026-07-14-18:25: + Greptile P1 follow-up: getSettings() resolving to undefined is the same + class of uncertainty as a throw — do not keep using the cached settings. + Fail closed and withhold inject until a live settings object is available. + */ + let settingsForControl = this.settings; + if (this.store.getSettings) { + try { + const live = await this.store.getSettings(); + if (!live) { + // Fail closed — cannot prove autoMerge is still allowed. + return; + } + settingsForControl = live; + this.settings = live; + } catch { + // Fail closed — cannot prove autoMerge is still allowed. + return; + } + } + + const human = evaluateOverseerHumanControl(task, settingsForControl); + if (human.withhold) return; + + const level = await this.resolveLevel(task); + state.level = level; + + if (level === "off") { + // Tear down so we stop spending model turns until re-enabled. + this.clear(taskId); + return; + } + + // observe: record timeline only when store supports audit; no inject. + if (level === "observe") { + this.emitSteeringSafe(taskId, note, severity, "pending"); + state.lastAdviceSeverity = severity; + return; + } + + if (level !== "steer" && level !== "autonomous") { + return; + } + + const severityAttr = severity ? ` severity="${severity}"` : ""; + const text = `[session-advisor]${severityAttr} ${note}`; + await this.store.addSteeringComment(taskId, text, "agent"); + this.emitSteeringSafe(taskId, note, severity, "pending"); + state.lastAdviceSeverity = severity; + } catch (err) { + log.warn(`deliverAdvice failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + private emitSteeringSafe( + taskId: string, + reason: string, + severity: OverseerAdviceSeverity | undefined, + outcome: "pending" | "succeeded", + ): void { + if (!this.store.recordRunAuditEvent || !this.store.getRunAuditEvents) return; + try { + emitOverseerSteering({ + store: this.store as Parameters[0]["store"], + taskId, + stage: "executor", + reason, + outcome, + severity, + source: "session-advisor", + }); + } catch (err) { + log.warn(`emitOverseerSteering failed: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/packages/engine/src/overseer-session-delta.ts b/packages/engine/src/overseer-session-delta.ts new file mode 100644 index 0000000000..481db640c2 --- /dev/null +++ b/packages/engine/src/overseer-session-delta.ts @@ -0,0 +1,64 @@ +/** + * FNXC:PlannerOversight 2026-07-13-22:50: + * Render agent-log entries into a compact markdown batch for the session + * advisor (OMP AdvisorRuntime delta parity). Filters previously injected + * advisory/overseer steering lines so the advisor does not recursively + * review its own advice. Pure, never throws. + */ + +/** Minimal agent-log entry shape the delta renderer needs. */ +export interface OverseerLogEntry { + type?: string; + text?: string; + detail?: string; + agent?: string; + timestamp?: string | number; +} + +const ADVISORY_MARKERS = [ + "[planner-oversight]", + "[session-advisor]", + " lower.includes(marker.toLowerCase())); +} + +/** + * Render a slice of agent-log entries as a session-update markdown block. + * Returns null when the slice is empty after filtering. + */ +export function formatOverseerSessionDelta(entries: ReadonlyArray): string | null { + try { + if (!entries || entries.length === 0) return null; + + const lines: string[] = []; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const text = typeof entry.text === "string" ? entry.text : ""; + const detail = typeof entry.detail === "string" ? entry.detail : ""; + const combined = [text, detail].filter(Boolean).join("\n"); + if (!combined.trim()) continue; + if (isOverseerSelfAdvisoryText(combined)) continue; + if (entry.agent === "overseer" || entry.agent === "advisor") continue; + + const type = typeof entry.type === "string" && entry.type ? entry.type : "text"; + const agent = typeof entry.agent === "string" && entry.agent ? entry.agent : "agent"; + lines.push(`#### ${agent} · ${type}\n\n${combined.trim()}`); + } + + if (lines.length === 0) return null; + return `### Session update\n\n${lines.join("\n\n")}`; + } catch { + return null; + } +} diff --git a/packages/engine/src/overseer-watchdog.ts b/packages/engine/src/overseer-watchdog.ts new file mode 100644 index 0000000000..e6b05f11a5 --- /dev/null +++ b/packages/engine/src/overseer-watchdog.ts @@ -0,0 +1,114 @@ +/** + * FNXC:PlannerOversight 2026-07-13-22:55: + * Discover OVERSEER.md / WATCHDOG.md review-priority files for the session + * advisor system prompt (OMP WATCHDOG.md parity). Walks user agent dir + + * project ancestors to repo root; never throws — missing/unreadable files + * are skipped so a bad project config cannot kill the engine. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { homedir } from "node:os"; +import { createLogger } from "./logger.js"; + +const log = createLogger("overseer-watchdog"); + +const WATCHDOG_FILENAMES = ["OVERSEER.md", "WATCHDOG.md"] as const; + +export interface OverseerWatchdogCandidate { + path: string; + content: string; + level: "user" | "project"; + /** Depth from cwd (0 = cwd). Higher depth = farther ancestor. */ + depth: number; +} + +export interface DiscoverOverseerWatchdogOptions { + cwd: string; + /** User-level agent config dir (e.g. ~/.fusion or ~/.omp/agent). */ + agentDir?: string; + /** Optional git root; when omitted, walks until home or filesystem root. */ + repoRoot?: string | null; + /** Injected read for tests. */ + readText?: (path: string) => string | null; +} + +function defaultReadText(path: string): string | null { + try { + if (!existsSync(path)) return null; + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** + * Collect readable OVERSEER.md / WATCHDOG.md candidates, sorted user-first + * then project ancestor→leaf (leaf last / most prominent). + */ +export function discoverOverseerWatchdogFiles(options: DiscoverOverseerWatchdogOptions): OverseerWatchdogCandidate[] { + try { + const cwd = resolve(options.cwd); + const home = homedir(); + const agentDir = options.agentDir ? resolve(options.agentDir) : undefined; + const repoRoot = options.repoRoot ? resolve(options.repoRoot) : null; + const readText = options.readText ?? defaultReadText; + + const items: OverseerWatchdogCandidate[] = []; + const seen = new Set(); + + const tryAdd = (filePath: string, level: "user" | "project", depth: number) => { + const resolved = resolve(filePath); + if (seen.has(resolved)) return; + seen.add(resolved); + const content = readText(resolved); + if (content == null || !content.trim()) return; + items.push({ path: resolved, content, level, depth }); + }; + + if (agentDir) { + for (const name of WATCHDOG_FILENAMES) { + tryAdd(join(agentDir, name), "user", 999); + } + } + + let current = cwd; + const stopAt = repoRoot ?? home; + // Safety bound: max 64 parent hops + for (let hop = 0; hop < 64; hop++) { + const depth = + relative(cwd, current) === "" + ? 0 + : relative(cwd, current).split(sep).filter(Boolean).length; + for (const name of WATCHDOG_FILENAMES) { + tryAdd(join(current, name), "project", depth); + tryAdd(join(current, ".fusion", name), "project", depth); + tryAdd(join(current, ".omp", name), "project", depth); + } + if (current === stopAt) break; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + + items.sort((a, b) => { + if (a.level !== b.level) return a.level === "user" ? -1 : 1; + return b.depth - a.depth; // ancestor first, leaf last + }); + + return items; + } catch (err) { + log.warn(`discoverOverseerWatchdogFiles failed: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +/** + * Format discovered files as prompt blocks appended to the advisor system prompt. + */ +export function formatOverseerWatchdogPromptBlocks(candidates: ReadonlyArray): string[] { + return candidates.map( + (item) => + `Especially pay attention to:\n\n${item.content.trim()}\n`, + ); +} diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 529540fc87..4dfa9f032e 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -33,6 +33,7 @@ import { resolveEffectivePlannerOversightLevel, resolveEffectiveSettings, resolveMaxAutoMergeRetries, + resolveTaskSessionAdvisorEnabled, sortTasksByPriorityThenAgeAndId, } from "@fusion/core"; import { assemblePlannerOverseerRuntimeSnapshot } from "./planner-overseer-runtime-snapshot.js"; @@ -45,6 +46,12 @@ import { PrMonitor } from "./pr-monitor.js"; import { PlannerOverseerMonitor, resolveExecutorStuckAfterMs } from "./planner-overseer.js"; import { PlannerRecoveryController, type PlannerRecoveryHandlers } from "./planner-recovery-controller.js"; import { evaluateOverseerHumanControl } from "./overseer-human-control-policy.js"; +import { + OverseerAdvisorService, + createParsingOverseerAgent, +} from "./overseer-advisor-service.js"; +import { extractAdvisorAssistantText } from "./overseer-advise-tool.js"; +import { createResolvedAgentSession } from "./agent-session-helpers.js"; import type { PrNodeGithubOps } from "./pr-nodes.js"; import { PrReconciler, type PrReconcileGithubOps } from "./pr-reconcile.js"; import { PrCommentHandler } from "./pr-comment-handler.js"; @@ -380,6 +387,14 @@ export class ProjectEngine { * safeguards beyond the userPaused skip are FN-7514's responsibility. */ private plannerRecoveryController?: PlannerRecoveryController; + /* + FNXC:PlannerOversight 2026-07-13-23:05: + Session-advisor service (OMP advisor parity). Soft-disabled until workflow + plannerOverseerAdvisorProvider + plannerOverseerAdvisorModelId are both set. + */ + private sessionAdvisor?: OverseerAdvisorService; + /** Per-task agent-log cursor for poll-fed session-advisor deltas. */ + private readonly sessionAdvisorLogCursor = new Map(); /** * FNXC:PlannerOversight 2026-07-04-19:45: * FN-7551 requirement: real overseer decision points (observation, @@ -730,6 +745,15 @@ export class ProjectEngine { snapshotProvider: this.plannerOverseer, handlers: this.buildPlannerRecoveryHandlers(store), }); + // FNXC:PlannerOversight 2026-07-13-23:05: session advisor (transcript review) alongside lifecycle supervisor. + this.sessionAdvisor = this.buildSessionAdvisorService(store); + try { + this.runtime.getExecutor?.()?.setOnExecutorLogFlushed?.((taskId, entries) => { + this.notifySessionAdvisorLogDelta(taskId, entries); + }); + } catch { + /* executor may not expose the setter on older shims */ + } this.startPlannerOverseerPoll(store); // 2. Initialize PrMonitor + PrCommentHandler @@ -1188,7 +1212,34 @@ export class ProjectEngine { * task (nothing to show on the card). */ getPlannerOverseerRuntimeSnapshot(taskId: string): PlannerOverseerRuntimeSnapshot | null { - return assemblePlannerOverseerRuntimeSnapshot(taskId, this.plannerOverseer, this.plannerRecoveryController); + const base = assemblePlannerOverseerRuntimeSnapshot(taskId, this.plannerOverseer, this.plannerRecoveryController); + if (!base) return null; + const advisor = this.sessionAdvisor?.getTaskAdvisorSnapshot(taskId); + if (!advisor?.active) return base; + return { + ...base, + advisorActive: true, + advisorBacklog: advisor.backlog, + lastAdviceSeverity: advisor.lastAdviceSeverity, + }; + } + + /** + * FNXC:PlannerOversight 2026-07-13-23:05: + * Feed executor agent-log entries into the session advisor (AgentLogger hook). + * Fail-soft; never throws into the logger. + */ + notifySessionAdvisorLogDelta(taskId: string, entries: Array<{ type?: string; text?: string; detail?: string; agent?: string }>): void { + try { + // FNXC:PlannerOversight 2026-07-14-14:00: CodeRabbit — attach .catch so async rejections cannot become unhandled rejections (sync try/catch is not enough). + void this.sessionAdvisor?.onExecutorLogDelta(taskId, entries)?.catch((err) => { + runtimeLog.warn( + `session advisor log-delta notification failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } catch { + /* ignore */ + } } /** @@ -1431,6 +1482,7 @@ export class ProjectEngine { // FN-7551: emit the steering intervention entry AFTER the steering // comment succeeds, through the real store, so the timeline reflects // the same guidance the agent actually saw. + // FNXC:PlannerOversight 2026-07-13-23:05: tag lifecycle source for timeline vs session-advisor. this.emitOverseerInterventionSafe(() => emitOverseerSteering({ store, @@ -1438,6 +1490,7 @@ export class ProjectEngine { stage: (decision.watchedStage ?? "executor") as PlannerOversightStage, reason: decision.reason, sourceLinks: this.toInterventionSourceLinks(decision.sourceLinks), + source: "lifecycle", }), ); }, @@ -2460,6 +2513,8 @@ export class ProjectEngine { // consult `allowsAutoMergeProcessing(task, settings)` — the same // FN-5147 predicate `self-healing.ts` gates lifecycle mutation on. const engineSettings = await store.getSettings().catch(() => undefined); + // FNXC:PlannerOversight 2026-07-14-00:10: keep session-advisor human-control on live settings. + this.sessionAdvisor?.setSettings(engineSettings); for (const task of inFlight) { try { @@ -2496,6 +2551,26 @@ export class ProjectEngine { this.emitOverseerEscalationDeduped(store, task.id, decision); } } + + /* + FNXC:PlannerOversight 2026-07-14-18:11: + Session-advisor log feed when effective enable resolves true for the task + (task override → project default → workflow flag → off). Still needs model. + */ + if (task.column === "in-progress" && this.sessionAdvisor) { + const advisorEnabled = resolveTaskSessionAdvisorEnabled( + task, + engineSettings, + workflowEffective.plannerOverseerAdvisorEnabled === true, + ).enabled; + if (advisorEnabled) { + await this.feedSessionAdvisorFromAgentLogs(store, task); + } else if (this.sessionAdvisor.getTaskAdvisorSnapshot(task.id).active) { + // Operator turned it off mid-flight — drop runtime so no further model spend. + this.sessionAdvisor.clear(task.id); + this.sessionAdvisorLogCursor.delete(task.id); + } + } } catch { // Best-effort per-task — never let one task's failure block the poll. } @@ -2507,6 +2582,8 @@ export class ProjectEngine { if (!inFlightIds.has(taskId)) { overseer.clear(taskId); this.plannerRecoveryController?.clear(taskId); + this.sessionAdvisor?.clear(taskId); + this.sessionAdvisorLogCursor.delete(taskId); this.plannerObservationEmitDedup.delete(taskId); this.clearPlannerEscalationDedup(taskId); } @@ -2521,6 +2598,165 @@ export class ProjectEngine { clearInterval(this.plannerOverseerPollTimer); this.plannerOverseerPollTimer = null; } + this.sessionAdvisor?.clearAll(); + this.sessionAdvisorLogCursor.clear(); + } + + /** + * FNXC:PlannerOversight 2026-07-13-23:05: + * Construct the session-advisor service with model gate + LLM complete path + * via createResolvedAgentSession (mock-safe under testMode). + */ + private buildSessionAdvisorService(store: TaskStore): OverseerAdvisorService { + /* + FNXC:PlannerOversight 2026-07-14-00:10: + Greptile P1: pass live engine settings into the advisor so + evaluateOverseerHumanControl honors autoMerge:false / human-review + (undefined settings previously defaulted autoMerge:true). + */ + /* + FNXC:PlannerOversight 2026-07-14-14:00: + Shared per-task workflow settings loader for session-advisor resolve* + callbacks (avoids three independent resolveEffectiveSettings shapes with + diverging fallbacks). Still one store round-trip per callback invocation. + */ + const loadWorkflowForTask = async (task: Task): Promise> => + resolveEffectiveSettings(store, { id: task.id }).catch(() => ({}) as Record); + + const resolveAdvisorCwd = (task: Task | undefined): string => { + if (task && typeof task.worktree === "string" && task.worktree.length > 0) return task.worktree; + return this.config?.workingDirectory ?? process.cwd(); + }; + + const service = new OverseerAdvisorService({ + store: store as ConstructorParameters[0]["store"], + /* + FNXC:PlannerOversight 2026-07-14-18:11: + Session LLM advisor enable: task.sessionAdvisorEnabled → project + sessionAdvisorEnabledByDefault → workflow plannerOverseerAdvisorEnabled → false. + Model still requires provider + model id from workflow settings. + */ + resolveEnabled: async (task) => { + const workflowEffective = await loadWorkflowForTask(task); + const projectSettings = await store.getSettings().catch(() => undefined); + return resolveTaskSessionAdvisorEnabled( + task, + projectSettings, + workflowEffective.plannerOverseerAdvisorEnabled === true, + ).enabled; + }, + resolveLevel: async (task) => { + const workflowEffective = await loadWorkflowForTask(task); + return resolveEffectivePlannerOversightLevel( + task.plannerOversightLevel, + workflowEffective.plannerOversightLevel as string | undefined, + ); + }, + resolveModel: async (task) => { + const workflowEffective = await loadWorkflowForTask(task); + const projectSettings = await store.getSettings().catch(() => undefined); + const enabled = resolveTaskSessionAdvisorEnabled( + task, + projectSettings, + workflowEffective.plannerOverseerAdvisorEnabled === true, + ).enabled; + if (!enabled) return null; + const provider = String(workflowEffective.plannerOverseerAdvisorProvider ?? "").trim(); + const modelId = String(workflowEffective.plannerOverseerAdvisorModelId ?? "").trim(); + if (!provider || !modelId) return null; + return { provider, modelId }; + }, + resolveCwd: (task) => resolveAdvisorCwd(task), + agentFactory: async ({ taskId, model, systemPrompt, onAdvice }) => { + const task = await store.getTask(taskId).catch(() => undefined); + const cwd = resolveAdvisorCwd(task); + return createParsingOverseerAgent({ + systemPrompt, + onAdvice, + complete: async (sys, user) => { + try { + const settings = await store.getSettings().catch(() => undefined); + /* + FNXC:PlannerOversight 2026-07-14-00:10 / 2026-07-14-14:00: + CodeRabbit critical: do not use sessionPurpose "executor" (coding + tool surface). Use "reviewer" + tools:"readonly" so the advisor is + investigative-only; systemPrompt is the advisor contract; user + batch is the session-update delta only. + */ + const { session } = await createResolvedAgentSession({ + sessionPurpose: "reviewer", + cwd: String(cwd), + systemPrompt: sys, + tools: "readonly", + defaultProvider: model.provider, + defaultModelId: model.modelId, + settings, + }); + try { + await session.prompt(user); + // FNXC:PlannerOversight 2026-07-14-14:00: runtime-agnostic assistant text extraction. + return extractAdvisorAssistantText(session); + } finally { + try { + (session as { dispose?: () => void }).dispose?.(); + } catch { + /* ignore */ + } + } + } catch (err) { + runtimeLog.warn( + `session advisor complete failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`, + ); + return '{"silence":true}'; + } + }, + }); + }, + }); + + // Best-effort initial settings; poll refreshes each cycle. + void store.getSettings().then((s) => service.setSettings(s)).catch(() => undefined); + return service; + } + + /** + * FNXC:PlannerOversight 2026-07-13-23:05: + * Poll-backed log cursor: push only new agent-log rows into the session advisor. + */ + private async feedSessionAdvisorFromAgentLogs(store: TaskStore, task: Task): Promise { + if (!this.sessionAdvisor) return; + if (typeof store.getAgentLogs !== "function") return; + try { + await this.sessionAdvisor.ensureTask(task); + // FNXC:PlannerOversight 2026-07-13-23:20: + // getAgentLogs returns chronological entries (oldest→newest within the + // trailing window). Cursor tracks durable log count so we only feed + // new rows and never reverse/replay a sliding window incorrectly. + const total = + typeof store.getAgentLogCount === "function" + ? await store.getAgentLogCount(task.id).catch(() => 0) + : 0; + if (!Number.isFinite(total) || total <= 0) return; + + const cursor = this.sessionAdvisorLogCursor.get(task.id); + if (cursor === undefined) { + // First observation: seed without replaying history (OMP seedTo parity). + this.sessionAdvisorLogCursor.set(task.id, total); + return; + } + if (total <= cursor) return; + + const need = Math.min(80, total - cursor); + const logs = await store.getAgentLogs(task.id, { limit: need }).catch(() => [] as Array<{ type?: string; text?: string; detail?: string; agent?: string }>); + if (!Array.isArray(logs) || logs.length === 0) { + this.sessionAdvisorLogCursor.set(task.id, total); + return; + } + this.sessionAdvisorLogCursor.set(task.id, total); + await this.sessionAdvisor.onExecutorLogDelta(task.id, logs, task); + } catch { + /* best-effort */ + } } private scheduleMergeActiveReconciliation(intervalMs: number): void { diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index f2affcaa52..b55fe4725e 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -1593,6 +1593,14 @@ export class InProcessRuntime return this.taskStore; } + /** + * FNXC:PlannerOversight 2026-07-13-23:05: + * Expose TaskExecutor so ProjectEngine can wire session-advisor log flush. + */ + getExecutor(): TaskExecutor | undefined { + return this.executor; + } + /** * Get the AgentStore instance (if initialized). * Returns undefined before start() or if init fails.