Просмотр исходного кода

fix(continuation): make idle nudges opt-in beta

Alvin Unreal 1 неделя назад
Родитель
Сommit
4440c2741e

+ 8 - 7
docs/agents/build-agent-empty-input-diagnosis.md

@@ -9,7 +9,7 @@
 
 The `build` agent turn with empty input is **the same class of bug** as the original `/preset` issue fixed in #818: a plugin hook calls `sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } })` **without specifying an `agent` field**. opencode then resolves the agent via `agents.defaultInfo()`, which falls back to the built-in `build` agent whenever `default_agent` is unset, user-overridden, or not effectively applied. The `synthetic: true` flag hides the injected text from the TUI, so the user perceives the `build` turn as having "empty input."
 
-**Update (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is enabled by default via `backgroundJobs.continueOnIdle` (opt out with `false`), and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below).
+**Update (Issue #854):** the incomplete-todo continuation path now passes `agent: 'orchestrator'`, is an opt-in beta via `backgroundJobs.continueOnIdle: true`, and uses a process-local one-attempt gate. Remaining agent-less `promptAsync` call sites are interview/smartfetch (below).
 
 ## Root cause (causal chain, cross-validated)
 
@@ -40,7 +40,7 @@ The `build` agent turn with empty input is **the same class of bug** as the orig
 
 | File:line | Trigger | Body omits `agent`? | Gate |
 |---|---|---|---|
-| `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when `backgroundJobs.continueOnIdle` is `true` (default **on**) | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits |
+| `src/hooks/task-session-manager/continuation-evaluator.ts` (`promptAsync`) | `session.idle` / `session.status(idle)` on orchestrator session with incomplete todos when the opt-in beta `backgroundJobs.continueOnIdle` is `true` | **No** (`agent: 'orchestrator'`) | `continueOnIdle`, process-local one-attempt gate (reserve→commit), `hasInputWait`, `isCurrentContinuation`, `isFallbackInProgress`, `backgroundJobBoard.hasTerminalUnreconciled`, malformed/active SDK short-circuits |
 | `src/interview/service.ts:622` | User submits interview dashboard input | **Yes** | `sessionBusy` lock, interview active state |
 | `src/interview/service.ts:871` | User submits interview chat | **Yes** | same |
 | `src/interview/service.ts:933` | User submits interview answer | **Yes** | same |
@@ -65,10 +65,11 @@ This is the pattern every `promptAsync` caller in omos should follow.
 The gate exists and works in the common case (see `continuation-evaluator.ts` and
 `task-session-manager/index.test.ts` continuation cases). Notes:
 
-1. **Continuation is on by default.** `backgroundJobs.continueOnIdle` defaults
-   to `true`; set `false` to keep idle reconciliation without continuation SDK
-   calls. When enabled, a process-local reserve/commit gate allows at most one
-   `promptAsync` per session epoch between real user messages.
+1. **Continuation is opt-in beta.** `backgroundJobs.continueOnIdle` defaults
+   to `false`; set it to `true` to enable continuation SDK calls. Idle
+   reconciliation remains active either way. When enabled, a process-local
+   reserve/commit gate allows at most one `promptAsync` per session epoch
+   between real user messages.
 
 2. **Documented race window (when enabled).** `IDLE_RECONCILE_DELAY_MS = 2_000`.
    The idle-reconciliation comment admits late completions can still race the
@@ -111,7 +112,7 @@ opencode's `default_agent` resolution, eliminating the path to `build`.
 ## Evidence index
 
 ### omos source
-- **Continuation nudge (fixed agent + default-on + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts`
+- **Continuation nudge (fixed agent + opt-in beta + one-attempt gate):** `src/hooks/task-session-manager/continuation-evaluator.ts`, `continuation-attempt-gate.ts`, `backgroundJobs.continueOnIdle` in `src/config/schema.ts`
 - **Missing `agent` field (skill flow):** `src/interview/service.ts:622, 871, 933, 1007`
 - **Correct pattern for comparison:** `src/hooks/foreground-fallback/index.ts:635-639`
 - **omos sets `default_agent` only when absent:** `src/index.ts:546-551`

+ 8 - 8
docs/background-orchestration.md

@@ -321,22 +321,22 @@ multiplexer panes attached while the parent orchestrator continues scheduling.
 
 ### Incomplete-todo continuation nudge
 
-Automatic incomplete-todo continuation is **enabled by default**. Idle
-reconciliation and background-job orchestration always run; set
-`continueOnIdle` to `false` to keep those without hidden continuation prompts:
+Automatic incomplete-todo continuation is an **opt-in beta feature**. Idle
+reconciliation and background-job orchestration always run without it. Enable
+the beta only when you want hidden continuation prompts:
 
 ```jsonc
 {
   "backgroundJobs": {
-    "continueOnIdle": false
+    "continueOnIdle": true
   }
 }
 ```
 
-When `backgroundJobs.continueOnIdle` is `true` (the default), after an
-orchestrator session becomes idle the plugin may send **at most one** internal,
-delayed continuation prompt when OpenCode reports incomplete todos. That limit
-is per session between real external user messages (text/file/image).
+When `backgroundJobs.continueOnIdle` is `true`, after an orchestrator session
+becomes idle the plugin may send **at most one** internal, delayed continuation
+prompt when OpenCode reports incomplete todos. That limit is per session between
+real external user messages (text/file/image).
 Synthetic/internal inputs and subsequent idle/busy events do not rearm it. A
 real user message rearms the one-shot nudge once per message identity
 (`chat.message` `messageID` / `message.id`), shared across hook instances in the

+ 14 - 3
docs/configuration.md

@@ -149,7 +149,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) |
 | `backgroundJobs.maxRetainedSnapshots` | integer | `20` | Maximum board snapshots retained per checkpoint cache epoch (1–100). Adding a snapshot beyond the limit starts a new epoch with only the current snapshot, intentionally creating one cache miss |
 | `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` appends only when the formatted board changes and uses `backgroundJobs.maxRetainedSnapshots` per cache epoch. Cache state resets on compaction/session boundaries and is lost on plugin restart |
-| `backgroundJobs.continueOnIdle` | boolean | `true` | When `true` (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set `false` to keep idle reconciliation and background-job orchestration without automatic continuation prompts. See [Background Orchestration](background-orchestration.md#incomplete-todo-continuation-nudge) |
+| `backgroundJobs.continueOnIdle` | boolean | `false` | **Beta opt-in.** Set `true` to let idle orchestrator sessions with incomplete todos receive one automatic hidden continuation prompt. When omitted or `false`, idle reconciliation and background-job orchestration remain active without automatic continuation prompts. See [Background Orchestration](background-orchestration.md#incomplete-todo-continuation-nudge) |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `true` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
@@ -261,8 +261,19 @@ major is available, the plugin shows a migration command instead.
 Background job management is enabled by default and does not need to be present
 in the starter config. Add `backgroundJobs` only if you want to tune how many
 completed/reconciled child-agent sessions are reusable, how much read context is
-shown, how board snapshots are injected, or to disable automatic incomplete-todo
-continuation prompts on idle (`continueOnIdle`, default `true`). See the
+shown, how board snapshots are injected, or to opt into beta automatic
+incomplete-todo continuation prompts on idle:
+
+```jsonc
+{
+  "backgroundJobs": {
+    "continueOnIdle": true
+  }
+}
+```
+
+Without that opt-in, idle reconciliation and background-job orchestration remain
+enabled but no hidden continuation prompts are sent. See the
 [Background Orchestration](background-orchestration.md) guide for the concept,
 defaults, and examples.
 

+ 2 - 2
oh-my-opencode-slim.schema.json

@@ -1055,8 +1055,8 @@
           "maximum": 100
         },
         "continueOnIdle": {
-          "default": true,
-          "description": "When true (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set false to keep idle reconciliation and background-job orchestration without automatic continuation prompts.",
+          "default": false,
+          "description": "Beta opt-in. When true, idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Disabled by default; idle reconciliation and background-job orchestration continue without automatic continuation prompts.",
           "type": "boolean"
         }
       }

+ 2 - 2
src/config/schema.test.ts

@@ -51,12 +51,12 @@ describe('PluginConfigSchema backgroundJobs', () => {
     }
   });
 
-  it('defaults continueOnIdle to true', () => {
+  it('defaults continueOnIdle to false', () => {
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
 
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(result.data.backgroundJobs?.continueOnIdle).toBe(true);
+      expect(result.data.backgroundJobs?.continueOnIdle).toBe(false);
     }
   });
 

+ 2 - 2
src/config/schema.ts

@@ -218,9 +218,9 @@ export const BackgroundJobsConfigSchema = z.object({
     ),
   continueOnIdle: z
     .boolean()
-    .default(true)
+    .default(false)
     .describe(
-      'When true (default), idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Set false to keep idle reconciliation and background-job orchestration without automatic continuation prompts.',
+      'Beta opt-in. When true, idle orchestrator sessions with incomplete todos may receive one automatic hidden continuation prompt. Disabled by default; idle reconciliation and background-job orchestration continue without automatic continuation prompts.',
     ),
 });
 

+ 8 - 4
src/hooks/task-session-manager/index.test.ts

@@ -42,7 +42,10 @@ function createHook(options?: {
   readContextMaxFiles?: number;
   strategy?: 'latest' | 'checkpoint-compatible';
   maxRetainedSnapshots?: number;
-  /** Matches production default true; set false to exercise opt-out. */
+  /**
+   * Keeps continuation behavior tests focused on the opt-in beta path;
+   * production defaults are exercised by the direct factory test below.
+   */
   continueOnIdle?: boolean;
   backgroundJobBoard?: BackgroundJobBoard;
   sessionStatus?: unknown;
@@ -3471,7 +3474,7 @@ describe('task-session-manager hook', () => {
     ).toHaveLength(1);
   });
 
-  test('defaults continueOnIdle on: continuation SDK calls run', async () => {
+  test('defaults continueOnIdle off: continuation SDK calls do not run', async () => {
     const promptAsync = mock(async () => ({}));
     const todo = mock(async () => ({ data: [{ status: 'in_progress' }] }));
     const hook = createTaskSessionManagerHook(
@@ -3500,8 +3503,8 @@ describe('task-session-manager hook', () => {
     });
     await flushContinuation();
 
-    expect(todo).toHaveBeenCalled();
-    expect(promptAsync).toHaveBeenCalledTimes(1);
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
   });
 
   test('explicit continueOnIdle false reconciles parent terminal job without continuation', async () => {
@@ -3553,6 +3556,7 @@ describe('task-session-manager hook', () => {
   test('nudges once for incomplete todos when parent and children are inactive', async () => {
     const promptAsync = mock(async () => ({}));
     const { hook } = createHook({
+      continueOnIdle: true,
       idleReconcileDelayMs: 0,
       sessionClient: {
         todo: mock(async () => ({ data: [{ status: 'in_progress' }] })),

+ 4 - 4
src/hooks/task-session-manager/index.ts

@@ -47,9 +47,9 @@ export function createTaskSessionManagerHook(
     readContextMinLines?: number;
     readContextMaxFiles?: number;
     /**
-     * When true (default), idle orchestrator sessions with incomplete todos may
-     * receive one automatic continuation promptAsync. Set false to keep idle
-     * reconciliation without continuation SDK calls.
+     * Beta opt-in. When true, idle orchestrator sessions with incomplete todos
+     * may receive one automatic continuation promptAsync. Disabled by default;
+     * idle reconciliation continues without continuation SDK calls.
      */
     continueOnIdle?: boolean;
     backgroundJobBoard?: BackgroundJobStore;
@@ -68,7 +68,7 @@ export function createTaskSessionManagerHook(
     idleReconcileDelayMs?: number;
   },
 ) {
-  const continueOnIdle = options.continueOnIdle !== false;
+  const continueOnIdle = options.continueOnIdle === true;
   const backgroundJobBoard =
     options.backgroundJobBoard ??
     new BackgroundJobBoard({

+ 1 - 1
src/index.ts

@@ -352,7 +352,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       readContextMaxFiles:
         config.backgroundJobs?.readContextMaxFiles ??
         DEFAULT_READ_CONTEXT_MAX_FILES,
-      continueOnIdle: config.backgroundJobs?.continueOnIdle !== false,
+      continueOnIdle: config.backgroundJobs?.continueOnIdle === true,
       backgroundJobBoard: backgroundJobCoordinator,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',