diff --git a/README.md b/README.md index ea381db59..0d1f06246 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Forge provides these tool groups: - **Plan tools** — `plan-write`, `plan-edit`, `plan-read`, `section-read`, `plan-adjust` - **Review tools** — `review-write`, `review-read`, `review-delete` -- **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status` +- **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status`, `loop-migrate` - **Sandbox routing** — native `bash`, `glob`, and `grep` tools route into msb for sandboxed sessions Loops always run in an isolated git worktree; msb is used when enabled, configured, and available. @@ -170,6 +170,7 @@ Loops always run in an isolated git worktree; msb is used when enabled, configur | `execute-goal` | Execute a free-text goal in rotating dedicated code and auditor sessions inside an isolated git worktree. Args: `goal` required; `title`, `loopName`, `maxIterations` optional. | | `loop-cancel` | Cancel an active loop by worktree name | | `loop-status` | List active/recent loops or get detailed status by worktree name, including cumulative token usage when available. Supports `restart=true` to restart any non-completed loop (`running`, `cancelled`, `errored`, `stalled`). Completed loops are history-only and cannot be restarted. | +| `loop-migrate` | Move a loop to a configured remote opencode server, preserving phase, section progress, section summaries, and review findings. | `execute-plan` reads the current session's captured plan when `plan` is omitted. `maxIterations`, execution model, auditor model, and sandbox behavior come from configuration or the TUI execution dialog, not direct `execute-plan` tool arguments. @@ -183,6 +184,7 @@ Loops always run in an isolated git worktree; msb is used when enabled, configur | `/execute-goal` | Execute a free-text goal in dedicated worktree sessions until an audit leaves no findings | code | | `/loop-status` | Check status of all active loops | code | | `/loop-cancel` | Cancel the active loop | code | +| `/loop-migrate` | Migrate a loop to a remote opencode server | code | | `/launch-group` | Decompose a PRD or feature list into features and launch them as parallel planning + development loops | code | ## Configuration @@ -473,8 +475,8 @@ On model errors during execution, automatic fallback to the default model kicks ### Management -- **Slash commands**: `/execute-plan` to start, `/loop-cancel` to cancel -- **Tools**: `execute-plan` to start with parameters, `loop-status` for checking progress (with restart capability), `loop-cancel` to cancel +- **Slash commands**: `/execute-plan` to start, `/loop-cancel` to cancel, `/loop-migrate` to move a loop to a remote server +- **Tools**: `execute-plan` to start with parameters, `loop-status` for checking progress (with restart capability), `loop-cancel` to cancel, `loop-migrate` to move a loop to a configured remote ### Loop termination diff --git a/docs/agents-and-commands.md b/docs/agents-and-commands.md index f83ace4b1..bf5f85759 100644 --- a/docs/agents-and-commands.md +++ b/docs/agents-and-commands.md @@ -39,6 +39,7 @@ Excluded tools: - `execute-goal` - `loop-cancel` - `loop-status` +- `loop-migrate` Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). @@ -52,5 +53,6 @@ Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). | `/execute-goal` | Execute a goal in rotating dedicated code and auditor sessions inside an isolated worktree. | `code` | no | | `/loop-status` | Check status of all active loops. | `code` | no | | `/loop-cancel` | Cancel the active loop. | `code` | no | +| `/loop-migrate` | Migrate a loop to a configured remote opencode server. | `code` | no | Source: [`buildPluginCommands()`](../src/config.ts). diff --git a/docs/api/README.md b/docs/api/README.md index 46c8b23b5..d84d62300 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -161,7 +161,7 @@ Forge provides these tool groups: - **Plan tools** — `plan-write`, `plan-edit`, `plan-read`, `section-read`, `plan-adjust` - **Review tools** — `review-write`, `review-read`, `review-delete` -- **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status` +- **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status`, `loop-migrate` - **Sandbox routing** — native `bash`, `glob`, and `grep` tools route into msb for sandboxed sessions Loops always run in an isolated git worktree; msb is used when enabled, configured, and available. @@ -172,6 +172,7 @@ Loops always run in an isolated git worktree; msb is used when enabled, configur | `execute-goal` | Execute a free-text goal in rotating dedicated code and auditor sessions inside an isolated git worktree. Args: `goal` required; `title`, `loopName`, `maxIterations` optional. | | `loop-cancel` | Cancel an active loop by worktree name | | `loop-status` | List active/recent loops or get detailed status by worktree name, including cumulative token usage when available. Supports `restart=true` to restart any non-completed loop (`running`, `cancelled`, `errored`, `stalled`). Completed loops are history-only and cannot be restarted. | +| `loop-migrate` | Move a loop to a configured remote opencode server, preserving phase, section progress, section summaries, and review findings. | `execute-plan` reads the current session's captured plan when `plan` is omitted. `maxIterations`, execution model, auditor model, and sandbox behavior come from configuration or the TUI execution dialog, not direct `execute-plan` tool arguments. @@ -185,6 +186,7 @@ Loops always run in an isolated git worktree; msb is used when enabled, configur | `/execute-goal` | Execute a free-text goal in dedicated worktree sessions until an audit leaves no findings | code | | `/loop-status` | Check status of all active loops | code | | `/loop-cancel` | Cancel the active loop | code | +| `/loop-migrate` | Migrate a loop to a remote opencode server | code | | `/launch-group` | Decompose a PRD or feature list into features and launch them as parallel planning + development loops | code | ## Configuration @@ -475,8 +477,8 @@ On model errors during execution, automatic fallback to the default model kicks ### Management -- **Slash commands**: `/execute-plan` to start, `/loop-cancel` to cancel -- **Tools**: `execute-plan` to start with parameters, `loop-status` for checking progress (with restart capability), `loop-cancel` to cancel +- **Slash commands**: `/execute-plan` to start, `/loop-cancel` to cancel, `/loop-migrate` to move a loop to a remote server +- **Tools**: `execute-plan` to start with parameters, `loop-status` for checking progress (with restart capability), `loop-cancel` to cancel, `loop-migrate` to move a loop to a configured remote ### Loop termination diff --git a/docs/api/_media/agents-and-commands.md b/docs/api/_media/agents-and-commands.md index f83ace4b1..bf5f85759 100644 --- a/docs/api/_media/agents-and-commands.md +++ b/docs/api/_media/agents-and-commands.md @@ -39,6 +39,7 @@ Excluded tools: - `execute-goal` - `loop-cancel` - `loop-status` +- `loop-migrate` Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). @@ -52,5 +53,6 @@ Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). | `/execute-goal` | Execute a goal in rotating dedicated code and auditor sessions inside an isolated worktree. | `code` | no | | `/loop-status` | Check status of all active loops. | `code` | no | | `/loop-cancel` | Cancel the active loop. | `code` | no | +| `/loop-migrate` | Migrate a loop to a configured remote opencode server. | `code` | no | Source: [`buildPluginCommands()`](../src/config.ts). diff --git a/docs/api/_media/architecture.md b/docs/api/_media/architecture.md index 7d3578f5f..99c1b056d 100644 --- a/docs/api/_media/architecture.md +++ b/docs/api/_media/architecture.md @@ -157,7 +157,7 @@ OpenCode Forge integrates with OpenCode through several hook points. The plugin Loops are autonomous and cannot answer permission prompts, but OpenCode's default subagent ruleset falls back to `ask` for most tools. To prevent deadlocks, `createLoopPermissionRejectHook` listens for `session.created` events. When the new session resolves to an active loop, the hook calls `v2.session.update()` to overwrite the child session's `permission` ruleset: - If the parent session has an allow-all ruleset (e.g. an auditor subagent), the parent's ruleset is inherited so the child stays under the same constraints. -- Otherwise the default loop ruleset from `buildLoopPermissionRuleset()` (`src/constants/loop.ts`) is applied — blanket allow-all inside the worktree, with explicit structural denies for `external_directory`, `review-write`, `review-delete`, `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `launch-group`, `group-status`, `group-cancel`. User-configured `loop.permissions` rules are layered in after the external-directory allows and before these structural denies (via `resolveLoopPermissionOptions`), so they can tailor user tools without overriding a structural deny. +- Otherwise the default loop ruleset from `buildLoopPermissionRuleset()` (`src/constants/loop.ts`) is applied — blanket allow-all inside the worktree, with explicit structural denies for `external_directory`, `review-write`, `review-delete`, `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `loop-migrate`, `launch-group`, `group-status`, `group-cancel`. User-configured `loop.permissions` rules are layered in after the external-directory allows and before these structural denies (via `resolveLoopPermissionOptions`), so they can tailor user tools without overriding a structural deny. A `PATCHED_SESSIONS` set deduplicates retries. Audit-only subagents use the stricter `buildAuditSessionPermissionRuleset()` (blanket allow-all with structural denies for the direct mutation tools `edit`/`write`/`multiedit`/`apply_patch`, plus the shared plan/loop structural denies). diff --git a/docs/api/_media/configuration.md b/docs/api/_media/configuration.md index 58a12edf6..442e449e4 100644 --- a/docs/api/_media/configuration.md +++ b/docs/api/_media/configuration.md @@ -81,7 +81,7 @@ Configured rules are layered into the ruleset in this order: Because configured rules sit between the external-directory allows and Forge's structural denies, they can deny user tools but can never override a structural deny. -**Forge-managed permissions are rejected** (ignored with a warning at plugin load, surfaced in the log and a one-time TUI toast). These include `*`, `external_directory`, and the structural denies: `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `launch-group`, `group-status`, `group-cancel`, `review-write`, `review-delete`, `edit`, `write`, `multiedit`, and `apply_patch`. An unattended loop that can call `question` would hang forever; allowing `plan-write`/`review-write` would corrupt the plan-of-record and audit channels; and `execute-plan`/`loop-*`/`group-*` would let a loop recurse into itself or manage other loops. +**Forge-managed permissions are rejected** (ignored with a warning at plugin load, surfaced in the log and a one-time TUI toast). These include `*`, `external_directory`, and the structural denies: `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `loop-migrate`, `launch-group`, `group-status`, `group-cancel`, `review-write`, `review-delete`, `edit`, `write`, `multiedit`, and `apply_patch`. An unattended loop that can call `question` would hang forever; allowing `plan-write`/`review-write` would corrupt the plan-of-record and audit channels; and `execute-plan`/`loop-*`/`group-*` would let a loop recurse into itself or manage other loops. **Blanket denies of Forge-required permissions are rejected too**: `review-read`, `plan-read`, `section-read`, `plan-adjust`, `bash`, and `read` may not be denied outright. A loop that cannot read its findings, section plan, or plan-of-record — or cannot run `bash` or `read` at all — cannot do its job and would silently burn iterations to `maxIterations` with nothing pointing at the config. Only the blanket form (a bare tool name, or pattern `*`) is rejected; a scoped deny such as `{ "permission": "bash", "pattern": "git push *" }` is honoured. @@ -232,9 +232,18 @@ Example: 3. The remote server creates the loop worktree pinned to that exact SHA, fetching the sync ref when the commit is not yet in its clone. 4. On final loop teardown, the remote deletes the sync ref from the shared git remote (restart-preserving teardowns keep it). If a loop is deleted outside normal teardown, remove leftovers manually with `git push --delete refs/forge/`. +### Migrating a running loop + +The `loop-migrate` tool (and `/loop-migrate`) moves an existing local loop to a remote server. It reuses the same remote discovery and sync-ref mechanics as the TUI launch above, with two differences: + +- The pushed sync ref `refs/forge/` points at the **loop branch tip** (`refs/heads/`), not local `HEAD` — the work done inside the loop travels with the migration even though it was never merged. +- The remote worktree is pinned to that loop-branch SHA via `startRef`, and the loop resumes from a snapshot of its phase, section progress, and review findings. + +The local loop is terminated as `migrated: ` and further management (including `loop-status` and `loop-cancel`) happens on the remote server; see [Tools → `loop-migrate`](tools.md#loop-migrate) for eligibility, rollback, and local force-restart. The observability caveat below applies — the migrated loop disappears from the local sidebar, `loop-status`, and dashboard. + ### Caveats -- **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. +- **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. `loop-migrate` additionally requires the remote to understand the `forgeLoop.resume` snapshot (the release that introduced `loop-migrate`, or newer); an older remote ignores it and starts the loop from section 0 while the first prompt already targets the migrated phase. - **Sandbox mirroring**: `remotes[].sandbox` is a local assertion about the remote's capability. The launch bakes the session's shell permission ruleset from it; if it does not match the remote's real sandbox state, loop shell commands can be denied. - **Observability**: remote loops run entirely on the remote server. They do not appear in the local sidebar, `loop-status`, or dashboard. Results land on the `forge/` branch in the remote machine's clone; fetch or push that branch from the remote to retrieve them. diff --git a/docs/api/_media/loop-system.md b/docs/api/_media/loop-system.md index cde0e4f03..b8f600b92 100644 --- a/docs/api/_media/loop-system.md +++ b/docs/api/_media/loop-system.md @@ -15,13 +15,14 @@ The loop system provides autonomous iterative development with automatic code au - **Any non-completed loop is restartable** via explicit restart when the worktree is available. - Restartable statuses: `running`, `cancelled`, `errored`, `stalled`. - **Completed loops are history-only** and cannot be restarted. +- **`migrated` loops require force** — a loop migrated to a remote server with `loop-migrate` is terminated with the terminal reason `migrated: `; it restarts locally only with `force=true`, since a copy may be running on the remote. See [Tools → `loop-migrate`](tools.md#loop-migrate). - **Missing worktree blocks restart** — the worktree directory must exist for restart to proceed. ### Restart Semantics - Restart preserves loop identity, plan, worktree path, section progress, and review findings. - Restart resets iteration count and error budget. -- Restart creates a fresh session and resumes from the persisted phase and section index. +- Restart creates a fresh session and resumes from the persisted phase and section index. The phase/prompt selection for the resumed session is shared with the remote migration path: a loop migrated with `loop-migrate` starts on the remote from the same phase-appropriate prompt plan a local restart would build. ### Stale Workspace Sweep diff --git a/docs/api/_media/tools.md b/docs/api/_media/tools.md index 885e9683f..5478fe103 100644 --- a/docs/api/_media/tools.md +++ b/docs/api/_media/tools.md @@ -20,6 +20,7 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c | `execute-goal` | Start a managed goal loop in a dedicated code session inside an isolated Forge worktree. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-cancel` | Cancel an active loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-status` | List loops, inspect one loop, or restart a restartable loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | +| `loop-migrate` | Move a loop to a configured remote opencode server, preserving its progress. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `launch-group` | Launch a group of features (from a PRD or a pre-split list), each planned and run as its own loop, scheduled with a concurrency cap. | [`src/tools/group.ts`](../src/tools/group.ts) | | `group-status` | List groups, inspect one group's per-feature stages, or restart a non-completed group. | [`src/tools/group.ts`](../src/tools/group.ts) | | `group-cancel` | Cancel a group, optionally cancelling its running loops. | [`src/tools/group.ts`](../src/tools/group.ts) | @@ -187,6 +188,28 @@ Arguments: Completed loops are history-only and cannot be restarted. See [Loop System](loop-system.md#restartability). +### `loop-migrate` + +Moves a loop to a configured remote opencode server (see [Configuration → Remotes](configuration.md#remotes)). The local loop is terminated with the terminal reason `migrated: ` and the loop continues on the remote from the pushed loop branch tip. The local worktree and branch stay in place; a migrated loop can still be restarted locally, but only with `loop-status restart=true force=true`, because a copy may be running on the remote. + +Eligibility: only worktree-backed loops that are not part of a feature group can be migrated. Project-directory loops (`execute-plan mode: new-session`) and feature-group loops are refused before any side effect, as are completed loops and loops already marked `migrated`. + +What is carried over: + +- The loop's phase, section pointers (`currentSectionIndex`/`totalSections`/`finalAuditDone`), section plan rows, section summaries, and review findings travel as a resume snapshot in the remote workspace's `forgeLoop` extra. The snapshot is validated on the remote; an invalid or incompatible snapshot removes the remote workspace instead of starting a fresh loop, and the local loop can then be force-restarted. +- The loop's original plan text is forwarded, so restartability display and legacy non-sectioned resume keep working on the remote. +- The execution/auditor models and variants are forwarded unchanged. +- The remote loop name is reserved (the local name is kept when available). The remote session's portable permission rules are resolved from the local loop's workspace — configured `loop.permissions` merged with any portable rules the loop itself arrived with — without host-specific external directories. + +Failure semantics: every pre-freeze failure (unknown remote, no matching project, unreachable server, ineligible loop) leaves the local loop untouched. The freeze re-checks the loop under its state lock and refuses if the loop changed state meanwhile. After the freeze, the loop's worktree must be clean (the teardown commit must have landed); a dirty worktree, a failed snapshot, branch-tip resolve, push, or remote launch rolls back: the sync-ref push is deleted (best effort) and the local loop is restored to its previous terminal status (`cancelled`, `errored`, or `stalled`) or relabelled `cancelled` when it was running, so it stays restartable with `loop-status restart=true`. Only the success path leaves the loop marked `migrated`. + +Arguments: + +| Argument | Description | +|---|---| +| `name` | Required loop name (or branch) to migrate. | +| `remote` | Required configured `remotes[].name` to migrate to. | + > Group, loop, and plan tools are denied inside loop and audit sessions so an in-flight loop cannot recursively spawn more work. ## Group Tools diff --git a/docs/api/functions/createForgePlugin.md b/docs/api/functions/createForgePlugin.md index 05a867284..3d5302c9c 100644 --- a/docs/api/functions/createForgePlugin.md +++ b/docs/api/functions/createForgePlugin.md @@ -8,7 +8,7 @@ > **createForgePlugin**(`config`): `Plugin` -Defined in: [index.ts:290](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L290) +Defined in: [index.ts:290](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L290) Creates an OpenCode plugin instance with loop management and sandboxing. diff --git a/docs/api/functions/createParentSessionLookup.md b/docs/api/functions/createParentSessionLookup.md index 920287076..c4f29aba9 100644 --- a/docs/api/functions/createParentSessionLookup.md +++ b/docs/api/functions/createParentSessionLookup.md @@ -8,7 +8,7 @@ > **createParentSessionLookup**(`__namedParameters`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:91](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L91) +Defined in: [index.ts:91](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L91) ## Parameters diff --git a/docs/api/functions/createSessionDirectoryLookup.md b/docs/api/functions/createSessionDirectoryLookup.md index b7ed41736..d206725f4 100644 --- a/docs/api/functions/createSessionDirectoryLookup.md +++ b/docs/api/functions/createSessionDirectoryLookup.md @@ -8,7 +8,7 @@ > **createSessionDirectoryLookup**(`options`): (`sessionId`) => `Promise`\<`string` \| `null`\> -Defined in: [index.ts:193](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L193) +Defined in: [index.ts:193](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L193) ## Parameters diff --git a/docs/api/interfaces/CompactionConfig.md b/docs/api/interfaces/CompactionConfig.md index 1ec68ef75..9ce7ddc6d 100644 --- a/docs/api/interfaces/CompactionConfig.md +++ b/docs/api/interfaces/CompactionConfig.md @@ -6,7 +6,7 @@ # Interface: CompactionConfig -Defined in: [types.ts:189](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L189) +Defined in: [types.ts:189](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L189) Configuration for session compaction behavior. @@ -16,7 +16,7 @@ Configuration for session compaction behavior. > `optional` **customPrompt?**: `boolean` -Defined in: [types.ts:191](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L191) +Defined in: [types.ts:191](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L191) Use a custom compaction prompt. @@ -26,6 +26,6 @@ Use a custom compaction prompt. > `optional` **maxContextTokens?**: `number` -Defined in: [types.ts:193](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L193) +Defined in: [types.ts:193](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L193) Maximum context tokens for compaction. diff --git a/docs/api/interfaces/CreateParentSessionLookupOptions.md b/docs/api/interfaces/CreateParentSessionLookupOptions.md index aca48782c..6e37fd36e 100644 --- a/docs/api/interfaces/CreateParentSessionLookupOptions.md +++ b/docs/api/interfaces/CreateParentSessionLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateParentSessionLookupOptions -Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L53) +Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L53) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:53](https://github.com/chriswritescode-dev/opencode-forge/ > **client**: `ForgeClient` -Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L54) +Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L54) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:54](https://github.com/chriswritescode-dev/opencode-forge/ > **directory**: `string` -Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L55) +Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L55) *** @@ -30,7 +30,7 @@ Defined in: [index.ts:55](https://github.com/chriswritescode-dev/opencode-forge/ > **logger**: `object` -Defined in: [index.ts:57](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L57) +Defined in: [index.ts:57](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L57) #### debug @@ -92,4 +92,4 @@ Defined in: [index.ts:57](https://github.com/chriswritescode-dev/opencode-forge/ > **loop**: `Loop` -Defined in: [index.ts:56](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L56) +Defined in: [index.ts:56](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L56) diff --git a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md index d96a55c67..0673d1ad1 100644 --- a/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md +++ b/docs/api/interfaces/CreateSessionDirectoryLookupOptions.md @@ -6,7 +6,7 @@ # Interface: CreateSessionDirectoryLookupOptions -Defined in: [index.ts:139](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L139) +Defined in: [index.ts:139](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L139) ## Properties @@ -14,7 +14,7 @@ Defined in: [index.ts:139](https://github.com/chriswritescode-dev/opencode-forge > **client**: `ForgeClient` -Defined in: [index.ts:140](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L140) +Defined in: [index.ts:140](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L140) *** @@ -22,7 +22,7 @@ Defined in: [index.ts:140](https://github.com/chriswritescode-dev/opencode-forge > **directory**: `string` -Defined in: [index.ts:141](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L141) +Defined in: [index.ts:141](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L141) *** @@ -30,7 +30,7 @@ Defined in: [index.ts:141](https://github.com/chriswritescode-dev/opencode-forge > **loop**: `Loop` -Defined in: [index.ts:142](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L142) +Defined in: [index.ts:142](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L142) *** @@ -38,4 +38,4 @@ Defined in: [index.ts:142](https://github.com/chriswritescode-dev/opencode-forge > `optional` **negativeTtlMs?**: `number` -Defined in: [index.ts:143](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L143) +Defined in: [index.ts:143](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L143) diff --git a/docs/api/interfaces/DashboardConfig.md b/docs/api/interfaces/DashboardConfig.md index 47d6ae3d4..43b94dafa 100644 --- a/docs/api/interfaces/DashboardConfig.md +++ b/docs/api/interfaces/DashboardConfig.md @@ -6,7 +6,7 @@ # Interface: DashboardConfig -Defined in: [types.ts:225](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L225) +Defined in: [types.ts:225](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L225) Configuration for the read-only observability dashboard HTTP server. The dashboard is unauthenticated: binding to a non-loopback address exposes @@ -20,7 +20,7 @@ for the canonical warning text rendered by launch surfaces. > `optional` **host?**: `string` -Defined in: [types.ts:227](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L227) +Defined in: [types.ts:227](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L227) Bind hostname or IP. Defaults to "localhost". Use "0.0.0.0" to listen on all interfaces. @@ -30,6 +30,6 @@ Bind hostname or IP. Defaults to "localhost". Use "0.0.0.0" to listen on all int > `optional` **port?**: `number` -Defined in: [types.ts:229](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L229) +Defined in: [types.ts:229](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L229) Base bind port. Defaults to 4747. Consecutive ports are tried when busy. diff --git a/docs/api/interfaces/PluginConfig.md b/docs/api/interfaces/PluginConfig.md index afef143b8..2055940bb 100644 --- a/docs/api/interfaces/PluginConfig.md +++ b/docs/api/interfaces/PluginConfig.md @@ -6,7 +6,7 @@ # Interface: PluginConfig -Defined in: [types.ts:279](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L279) +Defined in: [types.ts:279](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L279) Complete plugin configuration for opencode-forge. @@ -16,7 +16,7 @@ Complete plugin configuration for opencode-forge. > `optional` **agents?**: `Record`\<`string`, `AgentOverrideConfig`\> -Defined in: [types.ts:311](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L311) +Defined in: [types.ts:311](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L311) Per-agent configuration overrides. @@ -26,7 +26,7 @@ Per-agent configuration overrides. > `optional` **auditorFallbackModels?**: (`string` \| `AuditorFallbackModel`)[] -Defined in: [types.ts:297](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L297) +Defined in: [types.ts:297](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L297) Ordered entries tried, in order, when the current auditor model hits a provider usage/auth limit mid-loop. Use a `"provider/model"` string, or `{ model, variant }` to pin a variant to that fallback; the primary `auditorVariant` is **not** inherited by fallback entries. @@ -36,7 +36,7 @@ Ordered entries tried, in order, when the current auditor model hits a provider > `optional` **auditorModel?**: `string` -Defined in: [types.ts:291](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L291) +Defined in: [types.ts:291](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L291) Model to use for code auditing. @@ -46,7 +46,7 @@ Model to use for code auditing. > `optional` **auditorVariant?**: `string` -Defined in: [types.ts:295](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L295) +Defined in: [types.ts:295](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L295) Default reasoning/thinking variant for the auditor model. @@ -56,7 +56,7 @@ Default reasoning/thinking variant for the auditor model. > `optional` **compaction?**: [`CompactionConfig`](CompactionConfig.md) -Defined in: [types.ts:285](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L285) +Defined in: [types.ts:285](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L285) Compaction behavior configuration. @@ -66,7 +66,7 @@ Compaction behavior configuration. > `optional` **completedLoopTtlMs?**: `number` -Defined in: [types.ts:305](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L305) +Defined in: [types.ts:305](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L305) TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. @@ -76,7 +76,7 @@ TTL for completed/cancelled/errored/stalled loops before sweep. Default 7 days. > `optional` **dashboard?**: [`DashboardConfig`](DashboardConfig.md) -Defined in: [types.ts:309](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L309) +Defined in: [types.ts:309](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L309) Dashboard HTTP server bind configuration. @@ -86,7 +86,7 @@ Dashboard HTTP server bind configuration. > `optional` **dataDir?**: `string` -Defined in: [types.ts:281](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L281) +Defined in: [types.ts:281](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L281) Custom data directory for plugin storage. Defaults to platform data dir. @@ -96,7 +96,7 @@ Custom data directory for plugin storage. Defaults to platform data dir. > `optional` **executionModel?**: `string` -Defined in: [types.ts:289](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L289) +Defined in: [types.ts:289](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L289) Model to use for code execution. @@ -106,7 +106,7 @@ Model to use for code execution. > `optional` **executionVariant?**: `string` -Defined in: [types.ts:293](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L293) +Defined in: [types.ts:293](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L293) Default reasoning/thinking variant for the execution model. @@ -116,7 +116,7 @@ Default reasoning/thinking variant for the execution model. > `optional` **groupLaunch?**: `GroupLaunchConfig` -Defined in: [types.ts:301](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L301) +Defined in: [types.ts:301](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L301) Group launch configuration. @@ -126,7 +126,7 @@ Group launch configuration. > `optional` **logging?**: `LoggingConfig` -Defined in: [types.ts:283](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L283) +Defined in: [types.ts:283](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L283) Logging configuration. @@ -136,7 +136,7 @@ Logging configuration. > `optional` **loop?**: `LoopConfig` -Defined in: [types.ts:299](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L299) +Defined in: [types.ts:299](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L299) Loop behavior configuration. @@ -146,7 +146,7 @@ Loop behavior configuration. > `optional` **messagesTransform?**: `MessagesTransformConfig` -Defined in: [types.ts:287](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L287) +Defined in: [types.ts:287](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L287) Message transformation for architect agent. @@ -156,7 +156,7 @@ Message transformation for architect agent. > `optional` **remotes?**: `RemoteServerConfig`[] -Defined in: [types.ts:303](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L303) +Defined in: [types.ts:303](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L303) Remote opencode servers available as loop launch targets. @@ -166,7 +166,7 @@ Remote opencode servers available as loop launch targets. > `optional` **sandbox?**: `SandboxConfig` -Defined in: [types.ts:313](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L313) +Defined in: [types.ts:313](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L313) Sandbox execution configuration. @@ -176,6 +176,6 @@ Sandbox execution configuration. > `optional` **tui?**: `TuiConfig` -Defined in: [types.ts:307](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/types.ts#L307) +Defined in: [types.ts:307](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/types.ts#L307) TUI display configuration. diff --git a/docs/api/variables/VERSION.md b/docs/api/variables/VERSION.md index 3f410e8e4..33b58d732 100644 --- a/docs/api/variables/VERSION.md +++ b/docs/api/variables/VERSION.md @@ -8,4 +8,4 @@ > `const` **VERSION**: `"0.9.3"` = `'0.9.3'` -Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/version.ts#L1) +Defined in: [version.ts:1](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/version.ts#L1) diff --git a/docs/api/variables/default.md b/docs/api/variables/default.md index c80a54acb..c9510b506 100644 --- a/docs/api/variables/default.md +++ b/docs/api/variables/default.md @@ -8,7 +8,7 @@ > `const` **default**: `object` -Defined in: [index.ts:1021](https://github.com/chriswritescode-dev/opencode-forge/blob/dc0dfab121ce8e8b3901dbd0f2cb4632f5f3e52c/src/index.ts#L1021) +Defined in: [index.ts:1021](https://github.com/chriswritescode-dev/opencode-forge/blob/de5f48e9cdfb630dd2d5275361e34f2dbbea5aa1/src/index.ts#L1021) ## Type Declaration diff --git a/docs/architecture.md b/docs/architecture.md index 7d3578f5f..99c1b056d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -157,7 +157,7 @@ OpenCode Forge integrates with OpenCode through several hook points. The plugin Loops are autonomous and cannot answer permission prompts, but OpenCode's default subagent ruleset falls back to `ask` for most tools. To prevent deadlocks, `createLoopPermissionRejectHook` listens for `session.created` events. When the new session resolves to an active loop, the hook calls `v2.session.update()` to overwrite the child session's `permission` ruleset: - If the parent session has an allow-all ruleset (e.g. an auditor subagent), the parent's ruleset is inherited so the child stays under the same constraints. -- Otherwise the default loop ruleset from `buildLoopPermissionRuleset()` (`src/constants/loop.ts`) is applied — blanket allow-all inside the worktree, with explicit structural denies for `external_directory`, `review-write`, `review-delete`, `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `launch-group`, `group-status`, `group-cancel`. User-configured `loop.permissions` rules are layered in after the external-directory allows and before these structural denies (via `resolveLoopPermissionOptions`), so they can tailor user tools without overriding a structural deny. +- Otherwise the default loop ruleset from `buildLoopPermissionRuleset()` (`src/constants/loop.ts`) is applied — blanket allow-all inside the worktree, with explicit structural denies for `external_directory`, `review-write`, `review-delete`, `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `loop-migrate`, `launch-group`, `group-status`, `group-cancel`. User-configured `loop.permissions` rules are layered in after the external-directory allows and before these structural denies (via `resolveLoopPermissionOptions`), so they can tailor user tools without overriding a structural deny. A `PATCHED_SESSIONS` set deduplicates retries. Audit-only subagents use the stricter `buildAuditSessionPermissionRuleset()` (blanket allow-all with structural denies for the direct mutation tools `edit`/`write`/`multiedit`/`apply_patch`, plus the shared plan/loop structural denies). diff --git a/docs/configuration.md b/docs/configuration.md index 58a12edf6..442e449e4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,7 +81,7 @@ Configured rules are layered into the ruleset in this order: Because configured rules sit between the external-directory allows and Forge's structural denies, they can deny user tools but can never override a structural deny. -**Forge-managed permissions are rejected** (ignored with a warning at plugin load, surfaced in the log and a one-time TUI toast). These include `*`, `external_directory`, and the structural denies: `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `launch-group`, `group-status`, `group-cancel`, `review-write`, `review-delete`, `edit`, `write`, `multiedit`, and `apply_patch`. An unattended loop that can call `question` would hang forever; allowing `plan-write`/`review-write` would corrupt the plan-of-record and audit channels; and `execute-plan`/`loop-*`/`group-*` would let a loop recurse into itself or manage other loops. +**Forge-managed permissions are rejected** (ignored with a warning at plugin load, surfaced in the log and a one-time TUI toast). These include `*`, `external_directory`, and the structural denies: `plan`, `plan_enter`, `plan_exit`, `plan-write`, `plan-edit`, `execute-plan`, `execute-goal`, `question`, `loop-cancel`, `loop-status`, `loop-migrate`, `launch-group`, `group-status`, `group-cancel`, `review-write`, `review-delete`, `edit`, `write`, `multiedit`, and `apply_patch`. An unattended loop that can call `question` would hang forever; allowing `plan-write`/`review-write` would corrupt the plan-of-record and audit channels; and `execute-plan`/`loop-*`/`group-*` would let a loop recurse into itself or manage other loops. **Blanket denies of Forge-required permissions are rejected too**: `review-read`, `plan-read`, `section-read`, `plan-adjust`, `bash`, and `read` may not be denied outright. A loop that cannot read its findings, section plan, or plan-of-record — or cannot run `bash` or `read` at all — cannot do its job and would silently burn iterations to `maxIterations` with nothing pointing at the config. Only the blanket form (a bare tool name, or pattern `*`) is rejected; a scoped deny such as `{ "permission": "bash", "pattern": "git push *" }` is honoured. @@ -232,9 +232,18 @@ Example: 3. The remote server creates the loop worktree pinned to that exact SHA, fetching the sync ref when the commit is not yet in its clone. 4. On final loop teardown, the remote deletes the sync ref from the shared git remote (restart-preserving teardowns keep it). If a loop is deleted outside normal teardown, remove leftovers manually with `git push --delete refs/forge/`. +### Migrating a running loop + +The `loop-migrate` tool (and `/loop-migrate`) moves an existing local loop to a remote server. It reuses the same remote discovery and sync-ref mechanics as the TUI launch above, with two differences: + +- The pushed sync ref `refs/forge/` points at the **loop branch tip** (`refs/heads/`), not local `HEAD` — the work done inside the loop travels with the migration even though it was never merged. +- The remote worktree is pinned to that loop-branch SHA via `startRef`, and the loop resumes from a snapshot of its phase, section progress, and review findings. + +The local loop is terminated as `migrated: ` and further management (including `loop-status` and `loop-cancel`) happens on the remote server; see [Tools → `loop-migrate`](tools.md#loop-migrate) for eligibility, rollback, and local force-restart. The observability caveat below applies — the migrated loop disappears from the local sidebar, `loop-status`, and dashboard. + ### Caveats -- **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. +- **Version skew**: the remote server must run a forge version with SHA-pin support (`startRef`/`syncRef` handling — the same release that introduced `remotes`, or newer). An older remote silently ignores the pin and runs the loop from its clone's current `HEAD` with no error on either side. `loop-migrate` additionally requires the remote to understand the `forgeLoop.resume` snapshot (the release that introduced `loop-migrate`, or newer); an older remote ignores it and starts the loop from section 0 while the first prompt already targets the migrated phase. - **Sandbox mirroring**: `remotes[].sandbox` is a local assertion about the remote's capability. The launch bakes the session's shell permission ruleset from it; if it does not match the remote's real sandbox state, loop shell commands can be denied. - **Observability**: remote loops run entirely on the remote server. They do not appear in the local sidebar, `loop-status`, or dashboard. Results land on the `forge/` branch in the remote machine's clone; fetch or push that branch from the remote to retrieve them. diff --git a/docs/loop-system.md b/docs/loop-system.md index cde0e4f03..b8f600b92 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -15,13 +15,14 @@ The loop system provides autonomous iterative development with automatic code au - **Any non-completed loop is restartable** via explicit restart when the worktree is available. - Restartable statuses: `running`, `cancelled`, `errored`, `stalled`. - **Completed loops are history-only** and cannot be restarted. +- **`migrated` loops require force** — a loop migrated to a remote server with `loop-migrate` is terminated with the terminal reason `migrated: `; it restarts locally only with `force=true`, since a copy may be running on the remote. See [Tools → `loop-migrate`](tools.md#loop-migrate). - **Missing worktree blocks restart** — the worktree directory must exist for restart to proceed. ### Restart Semantics - Restart preserves loop identity, plan, worktree path, section progress, and review findings. - Restart resets iteration count and error budget. -- Restart creates a fresh session and resumes from the persisted phase and section index. +- Restart creates a fresh session and resumes from the persisted phase and section index. The phase/prompt selection for the resumed session is shared with the remote migration path: a loop migrated with `loop-migrate` starts on the remote from the same phase-appropriate prompt plan a local restart would build. ### Stale Workspace Sweep diff --git a/docs/modules.md b/docs/modules.md index 4bfc76e17..08571d102 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -145,6 +145,8 @@ The heart of Forge. Implements autonomous iterative development with phases: `co | `idle-gate.ts` | Session busy detection and timeout tracking | | `in-flight-guard.ts` | Single-flight guard for concurrent loop start attempts | | `restartability.ts` | `getRestartability()` — decides whether a non-completed loop can restart, blocked, or requires force | +| `resume-prompt.ts` | `buildResumePromptPlan()` — single owner of restart/migration prompt selection (post_action → goal → final_audit_fix → sectioned/legacy chain) | +| `resume-snapshot.ts` | `captureLoopResumeSnapshot()` / `restoreLoopResumeRows()` — portable snapshot of loop progress carried across restarts and remote migration | | `token-usage.ts` | Extract and normalize per-message usage from session output | | `name-uniqueness.ts` | Reserve a unique loop identity before any side effects | | `session-output.ts` | Fetch session output for loop display | @@ -174,6 +176,8 @@ type TerminationReason = | { kind: 'coding_no_assistant' } | { kind: 'worktree_failed'; message: string } | { kind: 'error_max_retries'; message: string } + | { kind: 'provider_limit'; message: string } + | { kind: 'migrated'; message: string } ``` ### Public API @@ -240,6 +244,8 @@ Higher-level orchestration services coordinating between hooks, loop runtime, an | File | Purpose | |------|---------| | `execution.ts` | Unified command bus for plan execution (`createForgeExecutionService()`) | +| `execution-response.ts` | Shared `ok`/`fail` response builders and error/warning shapes for the command bus | +| `loop-migration.ts` | `migrateLoopToRemote()` — `loop.migrate` command handler: freeze as migrated, snapshot, push loop branch tip to the sync ref, launch on the remote | | `session-loop-resolver.ts` | Resolve which loop owns a given session | | `deterministic-decomposer.ts` | Slice a plan into milestones (`section_plans` rows) deterministically — called once at loop start by `execution.ts`, not a runtime loop phase | | `plan-capture.ts` | The single write path into a session-scoped `plans` row (`writeSessionPlanContent`), marked-plan capture from messages, and `resolveSessionPlanOfRecord` — the one implementation of "stored plan wins, chat capture is the fallback" | @@ -385,6 +391,7 @@ Implements tools callable by AI agents during conversations. | `execute-goal` | `loop.ts` | Execute a non-empty goal in a dedicated session inside a managed worktree. Args: `goal` required; `title`, `loopName`, `maxIterations`, `hostSessionId` optional. | | `loop-status` | `loop.ts` | List active/recent loops, show cumulative usage for detailed status, or restart loops with `restart`/`force` arguments | | `loop-cancel` | `loop.ts` | Cancel an active loop by worktree name | +| `loop-migrate` | `loop.ts` | Move a worktree-backed, non-group loop to a configured remote server, carrying phase, section rows, and findings as a resume snapshot; the local loop is marked `migrated` (force-restartable) | ### ToolContext @@ -450,7 +457,7 @@ Cross-cutting helpers (~25 files) organized by concern: | Loop | `loop-helpers.ts`, `loop-format.ts`, `loop-session.ts` | Loop model/format/session helpers | | Sessions | `audit-session.ts`, `session-titles.ts` | Session naming | | TUI | `tui-client.ts`, `tui-plan-store.ts`, `tui-loop-store.ts`, `tui-execution-preferences.ts`, `tui-execution-context-cache.ts`, `tui-models.ts` | TUI RPC, storage, preferences, models | -| Remote | `remote-config.ts`, `tui-remote-launch.ts` | Remote server config resolution and remote loop launch (see also `createRemoteForgeClient` in `client/sdk-adapter.ts`) | +| Remote | `remote-config.ts`, `tui-remote-launch.ts` | Remote server config resolution and remote loop launch: `connectRemoteProject`, `prepareRemoteLoopLaunch`, `pushAndLaunchRemoteLoop`, and the async `pushForgeSyncRef`/`deleteForgeSyncRef` sync-ref owners shared by TUI launch, `loop-migrate`, and the forge adapter's teardown (see also `createRemoteForgeClient` in `client/sdk-adapter.ts`) | | Workspace | `worktree-cleanup.ts`, `workspace-listing.ts`, `workspace-status-registry.ts` | Worktree/workspace lifecycle | | Misc | `partial-match.ts`, `model-fallback.ts`, `busy-guard.ts`, `sandbox-ready.ts`, `format.ts` | Various helpers | diff --git a/docs/tools.md b/docs/tools.md index 885e9683f..5478fe103 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -20,6 +20,7 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c | `execute-goal` | Start a managed goal loop in a dedicated code session inside an isolated Forge worktree. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-cancel` | Cancel an active loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-status` | List loops, inspect one loop, or restart a restartable loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | +| `loop-migrate` | Move a loop to a configured remote opencode server, preserving its progress. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `launch-group` | Launch a group of features (from a PRD or a pre-split list), each planned and run as its own loop, scheduled with a concurrency cap. | [`src/tools/group.ts`](../src/tools/group.ts) | | `group-status` | List groups, inspect one group's per-feature stages, or restart a non-completed group. | [`src/tools/group.ts`](../src/tools/group.ts) | | `group-cancel` | Cancel a group, optionally cancelling its running loops. | [`src/tools/group.ts`](../src/tools/group.ts) | @@ -187,6 +188,28 @@ Arguments: Completed loops are history-only and cannot be restarted. See [Loop System](loop-system.md#restartability). +### `loop-migrate` + +Moves a loop to a configured remote opencode server (see [Configuration → Remotes](configuration.md#remotes)). The local loop is terminated with the terminal reason `migrated: ` and the loop continues on the remote from the pushed loop branch tip. The local worktree and branch stay in place; a migrated loop can still be restarted locally, but only with `loop-status restart=true force=true`, because a copy may be running on the remote. + +Eligibility: only worktree-backed loops that are not part of a feature group can be migrated. Project-directory loops (`execute-plan mode: new-session`) and feature-group loops are refused before any side effect, as are completed loops and loops already marked `migrated`. + +What is carried over: + +- The loop's phase, section pointers (`currentSectionIndex`/`totalSections`/`finalAuditDone`), section plan rows, section summaries, and review findings travel as a resume snapshot in the remote workspace's `forgeLoop` extra. The snapshot is validated on the remote; an invalid or incompatible snapshot removes the remote workspace instead of starting a fresh loop, and the local loop can then be force-restarted. +- The loop's original plan text is forwarded, so restartability display and legacy non-sectioned resume keep working on the remote. +- The execution/auditor models and variants are forwarded unchanged. +- The remote loop name is reserved (the local name is kept when available). The remote session's portable permission rules are resolved from the local loop's workspace — configured `loop.permissions` merged with any portable rules the loop itself arrived with — without host-specific external directories. + +Failure semantics: every pre-freeze failure (unknown remote, no matching project, unreachable server, ineligible loop) leaves the local loop untouched. The freeze re-checks the loop under its state lock and refuses if the loop changed state meanwhile. After the freeze, the loop's worktree must be clean (the teardown commit must have landed); a dirty worktree, a failed snapshot, branch-tip resolve, push, or remote launch rolls back: the sync-ref push is deleted (best effort) and the local loop is restored to its previous terminal status (`cancelled`, `errored`, or `stalled`) or relabelled `cancelled` when it was running, so it stays restartable with `loop-status restart=true`. Only the success path leaves the loop marked `migrated`. + +Arguments: + +| Argument | Description | +|---|---| +| `name` | Required loop name (or branch) to migrate. | +| `remote` | Required configured `remotes[].name` to migrate to. | + > Group, loop, and plan tools are denied inside loop and audit sessions so an in-flight loop cannot recursively spawn more work. ## Group Tools diff --git a/src/agents/architect-auto.ts b/src/agents/architect-auto.ts index b3e5c5533..5cb64067b 100644 --- a/src/agents/architect-auto.ts +++ b/src/agents/architect-auto.ts @@ -1,6 +1,14 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' import { ARCHITECT_TOOL_EXCLUDES } from './architect' +import { PLAN_AUTHORING_TOOL_NAMES, SHARED_STRUCTURAL_DENY_PERMISSIONS } from '../constants/loop' + +export const ARCHITECT_AUTO_TOOL_EXCLUDES = [ + ...ARCHITECT_TOOL_EXCLUDES, + ...SHARED_STRUCTURAL_DENY_PERMISSIONS.filter( + (name) => !(ARCHITECT_TOOL_EXCLUDES as string[]).includes(name) && !(PLAN_AUTHORING_TOOL_NAMES as readonly string[]).includes(name), + ), +] export function buildArchitectAutoAgent(promptsDir?: string): AgentDefinition { return { @@ -10,17 +18,7 @@ export function buildArchitectAutoAgent(promptsDir?: string): AgentDefinition { mode: 'primary', hidden: true, tools: { - exclude: [ - ...ARCHITECT_TOOL_EXCLUDES, - 'question', - 'execute-plan', - 'execute-goal', - 'launch-group', - 'group-status', - 'group-cancel', - 'loop-status', - 'loop-cancel', - ], + exclude: ARCHITECT_AUTO_TOOL_EXCLUDES, }, systemPrompt: loadPrompt(['agents', 'architect-auto.md'], promptsDir), } diff --git a/src/config.ts b/src/config.ts index f749f466b..988f0ee20 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,8 @@ function buildPluginCommands(promptsDir?: string): Record template: loadPrompt(['commands','loop-status.md'], promptsDir) }, 'loop-cancel': { description: 'Cancel the active loop', agent: 'code', subtask: false, template: loadPrompt(['commands','loop-cancel.md'], promptsDir) }, + 'loop-migrate': { description: 'Migrate a loop to a remote opencode server', agent: 'code', subtask: false, + template: loadPrompt(['commands','loop-migrate.md'], promptsDir) }, } } diff --git a/src/constants/loop.ts b/src/constants/loop.ts index 448990abc..ec7123f5e 100644 --- a/src/constants/loop.ts +++ b/src/constants/loop.ts @@ -27,6 +27,7 @@ export const SHARED_STRUCTURAL_DENY_PERMISSIONS = [ 'question', 'loop-cancel', 'loop-status', + 'loop-migrate', 'launch-group', 'group-status', 'group-cancel', @@ -268,6 +269,20 @@ export function buildLoopPermissionRuleset(options: LoopPermissionRulesetOptions return rules } +/** + * Selects the permission ruleset for a session from the agent that will run it: + * the audit ruleset for `auditor-loop`, the loop ruleset for every other agent. + * Single derivation so restart, TUI launch, and audit sessions cannot diverge. + */ +export function buildSessionPermissionRulesetForAgent( + agent: 'code' | 'auditor-loop', + options: LoopPermissionRulesetOptions = {}, +): PermissionRule[] { + return agent === 'auditor-loop' + ? buildAuditSessionPermissionRuleset(options) + : buildLoopPermissionRuleset(options) +} + /** * Builds the permission ruleset for audit sessions. * diff --git a/src/hooks/forge-session-attach.ts b/src/hooks/forge-session-attach.ts index dc58196fb..a95dcd04d 100644 --- a/src/hooks/forge-session-attach.ts +++ b/src/hooks/forge-session-attach.ts @@ -2,6 +2,8 @@ import type { Logger } from '../types' import type { ForgeClient } from '../client/port' import type { ForgeExecutionServiceDeps, ForgeLoopExtra, PlanSource } from '../services/execution' import { attachLoopToSession } from '../services/execution' +import { isLoopResumeSnapshot } from '../loop/resume-snapshot' +import { parseTerminationReasonString } from '../loop' import { resolveSandboxContextForLoop, isSandboxEnabled } from '../sandbox/context' import { classifyForgeWorkspace, isPendingAttachWorkspace } from '../workspace/classify-stale' import { removeForgeWorkspaceWithContext } from '../workspace/remove-with-context' @@ -114,9 +116,7 @@ async function attachForgeSession( return } - const cfg = (ws.extra ?? {}).forgeLoop as - | (Partial & { maxIterations?: number }) - | undefined + const cfg = (ws.extra ?? {}).forgeLoop as Partial | undefined if (cfg?.initialPromptOwner === 'tui' && sendInitialPrompt) { deps.logger.log(`[forge-session-attach] skip session=${sessionId} loop=${loopName} reason=tui-owned-initial-prompt`) @@ -222,11 +222,16 @@ async function attachForgeSession( { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-registration-only', reasonLabel: 'attach-safety-net-restartable' }, ) + const row = deps.execDeps.loopsRepo.get(sessionProjectId, loopName) + const parsedReason = row?.terminationReason ? parseTerminationReasonString(row.terminationReason) : null + const toastMessage = parsedReason?.kind === 'migrated' + ? `Loop "${loopName}" was migrated to remote "${parsedReason.message}". Restart locally only with loop-status restart=true force=true.` + : `Loop "${loopName}" is in terminal status. Use Loop-status restart to resume.` publishAttachFailureToast( deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, - `Loop "${loopName}" is in terminal status. Use Loop-status restart to resume.`, + toastMessage, ) return } else { @@ -241,26 +246,46 @@ async function attachForgeSession( ? cfg.hostSessionId : sessionId - const planSource: PlanSource = - cfg.planSource === 'inline' && cfg.planText - ? { kind: 'inline', planText: cfg.planText } - : { kind: 'stored', sessionId: resolvedHostSessionId } + if (cfg.resume !== undefined && !isLoopResumeSnapshot(cfg.resume)) { + deps.logger.error(`[forge-session-attach] invalid resume snapshot for loop=${loopName} workspace=${workspaceId}`) + publishAttachFailureToast( + deps, + ws.directory ?? deps.directory, + `Forge loop "${loopName}"`, + 'Migrated loop snapshot is invalid or from an incompatible forge version. Removed the workspace; re-run loop-migrate from a matching version.', + ) + await removeForgeWorkspaceWithContext( + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-bad-resume' }, + ) + return + } + + const resume = cfg.resume let planText: string - if (planSource.kind === 'inline') { - planText = planSource.planText + if (resume !== undefined) { + planText = cfg.planText ?? '' } else { - const row = deps.execDeps.plansRepo.getForSession(sessionProjectId, planSource.sessionId) - if (!row) { - deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${loopName} workspace=${workspaceId}`) - publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, 'No stored plan found for this loop. Re-run "Execute → Loop" from a session that has a captured plan.') - await removeForgeWorkspaceWithContext( - { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, - { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-no-plan' }, - ) - return + const planSource: PlanSource = + cfg.planSource === 'inline' && cfg.planText + ? { kind: 'inline', planText: cfg.planText } + : { kind: 'stored', sessionId: resolvedHostSessionId } + if (planSource.kind === 'inline') { + planText = planSource.planText + } else { + const row = deps.execDeps.plansRepo.getForSession(sessionProjectId, planSource.sessionId) + if (!row) { + deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${loopName} workspace=${workspaceId}`) + publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, 'No stored plan found for this loop. Re-run "Execute → Loop" from a session that has a captured plan.') + await removeForgeWorkspaceWithContext( + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-no-plan' }, + ) + return + } + planText = row.content } - planText = row.content } try { @@ -285,6 +310,7 @@ async function attachForgeSession( sandboxEnabled: sandbox.enabled, sandboxContainer: sandbox.containerName, planText, + resume, selectSession, selectSessionTiming: 'after-prompt', startWatchdog: true, diff --git a/src/hooks/host-side-effects.ts b/src/hooks/host-side-effects.ts index d5365b527..b353eec9d 100644 --- a/src/hooks/host-side-effects.ts +++ b/src/hooks/host-side-effects.ts @@ -133,6 +133,7 @@ function getToastVariant(reason: TerminationReason): { variant: 'info' | 'succes return { variant: 'success', duration: 5000 } case 'cancelled': case 'user_aborted': + case 'migrated': return { variant: 'info', duration: 3000 } case 'max_iterations': return { variant: 'warning', duration: 3000 } @@ -156,6 +157,8 @@ function getToastMessage(state: LoopState, reason: TerminationReason): string { return `Stalled after ${iterLabel}` case 'user_aborted': return 'Loop aborted by user' + case 'migrated': + return `Migrated to ${reason.message}` default: return `Loop ended: ${terminationReasonToString(reason)}` } @@ -298,5 +301,7 @@ function resolveReasonLabel(reason: TerminationReason): string { return 'aborted' case 'max_iterations': return 'max_iterations' + case 'migrated': + return 'migrated' } } diff --git a/src/loop/restartability.ts b/src/loop/restartability.ts index 477592f7e..c65804c42 100644 --- a/src/loop/restartability.ts +++ b/src/loop/restartability.ts @@ -11,6 +11,7 @@ export type RestartBlockedReason = | 'completed' | 'missing_worktree' | 'active_requires_force' + | 'migrated' export interface RestartabilityResult { restartable: boolean @@ -61,6 +62,14 @@ export function getRestartability( restartBlockedMessage: `Loop "${state.loopName}" completed successfully and cannot be restarted.`, } } + if (parsed.kind === 'migrated') { + return { + restartable: true, + restartRequiresForce: true, + restartBlockedReason: 'migrated', + restartBlockedMessage: `Loop "${state.loopName}" was migrated to remote "${parsed.message}". A copy may be running there; use force=true to restart it locally anyway.`, + } + } } // Missing worktree directory: the forge scratch branch is never deleted, so if diff --git a/src/loop/resume-prompt.ts b/src/loop/resume-prompt.ts new file mode 100644 index 000000000..154393ea9 --- /dev/null +++ b/src/loop/resume-prompt.ts @@ -0,0 +1,119 @@ +import type { PluginConfig } from '../types' +import type { LoopService } from './service' +import type { LoopState } from './state' +import { resolvePostActionConfig } from './post-action-config' +import { auditorModelChoiceAt, buildAuditorModelChain } from '../utils/loop-helpers' +import { parseModelString } from '../utils/model-fallback' + +export type ResumePhase = 'coding' | 'final_auditing' | 'post_action' + +export interface ResumePromptPlan { + phase: ResumePhase + promptText: string + agent: 'code' | 'auditor-loop' + model?: { providerID: string; modelID: string } + fallbackModel?: { providerID: string; modelID: string } + variant?: string + auditorModel?: string +} + +function normalizeModelString(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +/** + * A stopped final_audit_fix loop is a coding pass (the fix session), not an + * auditor phase — restart it as coding with the code prompt agent. The other + * auditor phases (final_auditing, post_action) preserve their persisted phase. + */ +export function resolveResumePhase(phase: LoopState['phase']): ResumePhase { + return phase === 'final_auditing' + ? 'final_auditing' + : phase === 'post_action' + ? 'post_action' + : 'coding' +} + +/** + * Single owner of the restart agent selection: a stopped final_audit_fix loop + * is a coding pass (the fix session), not an auditor phase — it re-dispatches + * as the code agent. Only the final_auditing phase re-dispatches the + * auditor-loop agent. + */ +export function resolveResumeAgent(phase: ResumePhase): 'code' | 'auditor-loop' { + return phase === 'final_auditing' ? 'auditor-loop' as const : 'code' as const +} + +/** + * Single owner of the restart prompt selection: maps a stopped loop's persisted + * state to the resume phase, prompt text, agent, and model used to re-dispatch + * it. handleLoopRestart consumes the plan instead of inlining this chain so + * restart behavior has exactly one definition. + */ +export function buildResumePromptPlan(input: { + service: LoopService + config: PluginConfig + state: LoopState +}): ResumePromptPlan { + const { service, config, state } = input + + const phase = resolveResumePhase(state.phase) + + const postActionCfg = state.phase === 'post_action' ? resolvePostActionConfig(config) : undefined + + let promptText: string + if (state.phase === 'post_action') { + promptText = service.buildPostActionPrompt(state, { skill: postActionCfg?.skill, prompt: postActionCfg?.prompt }) + } else if (state.kind === 'goal') { + // Goal loops have no plan, sections, or approval flow — restate the goal + // directly as a fresh coding pass. No initial audit findings on restart. + promptText = service.buildContinuationPrompt(state, undefined) + } else if (state.phase === 'final_audit_fix') { + // Resume fixing the final-audit findings rather than re-coding the last + // section: the persisted findings carry the remediation for this + // recovery path. + const outstandingBugs = service.getOutstandingFindings(state.loopName, 'bug') + promptText = service.buildFinalAuditFixPrompt(state, outstandingBugs) + } else if (state.totalSections > 0) { + // Use persisted section state to build the correct section prompt + if (state.phase === 'final_auditing') { + promptText = service.buildFinalAuditPrompt(state) + } else { + promptText = service.buildSectionInitialPrompt(state) + } + } else { + // Legacy non-sectioned prompt + promptText = state.prompt ?? '' + } + + const restartAuditorState = { + ...state, + auditorModel: normalizeModelString(state.auditorModel ?? config.auditorModel), + modelFailed: false, + auditorFallbackIndex: 0, + } + const restartAuditorChoice = auditorModelChoiceAt(buildAuditorModelChain(config, restartAuditorState), 0) + const restartAuditorModel = restartAuditorChoice.model + const model = state.phase === 'post_action' && postActionCfg?.model + ? parseModelString(postActionCfg.model) + : state.phase === 'final_auditing' || state.phase === 'post_action' + ? restartAuditorModel + : parseModelString(state.executionModel) ?? parseModelString(config.executionModel) + // When a configured post-action model is used, fall back to the loop's auditor model if it fails. + const fallbackModel = state.phase === 'post_action' && postActionCfg?.model + ? restartAuditorModel + : undefined + + const agent = resolveResumeAgent(phase) + + return { + phase, + promptText, + agent, + model, + fallbackModel, + variant: agent === 'auditor-loop' ? restartAuditorChoice.variant : state.executionVariant, + auditorModel: restartAuditorState.auditorModel, + } +} diff --git a/src/loop/resume-snapshot.ts b/src/loop/resume-snapshot.ts new file mode 100644 index 000000000..6b11fe73b --- /dev/null +++ b/src/loop/resume-snapshot.ts @@ -0,0 +1,142 @@ +import type { SectionPlanRow } from '../storage/repos/section-plans-repo' +import type { ReviewFindingRow } from '../storage/repos/review-findings-repo' +import type { SectionPlansRepo } from '../storage/repos/section-plans-repo' +import type { ReviewFindingsRepo } from '../storage/repos/review-findings-repo' +import type { LoopState } from './state' +import { resolveResumePhase, type ResumePhase } from './resume-prompt' +import { isRecord } from '../utils/is-record' + +/** + * Host-independent snapshot of a loop's resumable progress. Section rows and + * findings are stripped of project/loop scoping and timestamps so the payload + * can be serialized across a migration boundary and re-attached under a + * different project id / loop name. + */ +export interface LoopResumeSnapshot { + version: 1 + kind: 'plan' | 'goal' + phase: ResumePhase + currentSectionIndex: number + totalSections: number + finalAuditDone: boolean + /** Goal text, present only for goal loops. */ + goal?: string + sections: ResumeSectionRow[] + findings: ResumeFindingRow[] +} + +export type ResumeSectionRow = Omit + +export type ResumeFindingRow = Pick + +const RESUME_PHASES: readonly ResumePhase[] = ['coding', 'final_auditing', 'post_action'] + +export function captureLoopResumeSnapshot(input: { + projectId: string + state: LoopState + sectionPlansRepo: SectionPlansRepo + reviewFindingsRepo: ReviewFindingsRepo +}): LoopResumeSnapshot { + const { projectId, state, sectionPlansRepo, reviewFindingsRepo } = input + const sections = sectionPlansRepo.list(projectId, state.loopName).map(({ projectId: _p, loopName: _l, createdAt: _c, ...row }) => row) + const findings = reviewFindingsRepo + .listByLoopName(projectId, state.loopName) + .map((f) => ({ + file: f.file, + line: f.line, + severity: f.severity, + description: f.description, + scenario: f.scenario, + sectionIndex: f.sectionIndex, + })) + return { + version: 1, + kind: state.kind ?? 'plan', + phase: resolveResumePhase(state.phase), + currentSectionIndex: state.currentSectionIndex, + totalSections: state.totalSections, + finalAuditDone: state.finalAuditDone, + ...(state.kind === 'goal' && state.goal ? { goal: state.goal } : {}), + sections, + findings, + } +} + +export function restoreLoopResumeRows(input: { + projectId: string + loopName: string + snapshot: LoopResumeSnapshot + sectionPlansRepo: SectionPlansRepo + reviewFindingsRepo: ReviewFindingsRepo +}): void { + const { projectId, loopName, snapshot, sectionPlansRepo, reviewFindingsRepo } = input + sectionPlansRepo.immediateTransaction(() => { + sectionPlansRepo.restoreAll( + snapshot.sections.map((section) => ({ + ...section, + projectId, + loopName, + createdAt: Date.now(), + })), + ) + for (const finding of snapshot.findings) { + reviewFindingsRepo.write({ + projectId, + loopName, + file: finding.file, + line: finding.line, + severity: finding.severity, + description: finding.description, + scenario: finding.scenario, + sectionIndex: finding.sectionIndex, + }) + } + }) +} + +const SECTION_STATUSES: readonly SectionPlanRow['status'][] = ['pending', 'in_progress', 'completed', 'failed'] + +function isValidResumeSection(value: unknown): value is ResumeSectionRow { + if (!isRecord(value)) return false + if (typeof value.sectionIndex !== 'number') return false + if (typeof value.title !== 'string') return false + if (typeof value.content !== 'string') return false + if (!SECTION_STATUSES.includes(value.status as SectionPlanRow['status'])) return false + if (typeof value.attempts !== 'number') return false + for (const key of ['summaryDone', 'summaryDeviations', 'summaryFollowUps'] as const) { + const item = value[key] + if (item !== null && item !== undefined && typeof item !== 'string') return false + } + for (const key of ['startedAt', 'completedAt'] as const) { + const item = value[key] + if (item !== null && item !== undefined && typeof item !== 'number') return false + } + return true +} + +function isValidResumeFinding(value: unknown): value is ResumeFindingRow { + if (!isRecord(value)) return false + if (typeof value.file !== 'string') return false + if (typeof value.line !== 'number') return false + if (value.severity !== 'bug' && value.severity !== 'warning') return false + if (typeof value.description !== 'string') return false + if (value.scenario !== null && value.scenario !== undefined && typeof value.scenario !== 'string') return false + if (value.sectionIndex !== null && value.sectionIndex !== undefined && typeof value.sectionIndex !== 'number') return false + return true +} + +export function isLoopResumeSnapshot(value: unknown): value is LoopResumeSnapshot { + if (!isRecord(value)) return false + if (value.version !== 1) return false + if (value.kind !== 'plan' && value.kind !== 'goal') return false + if (!RESUME_PHASES.includes(value.phase as ResumePhase)) return false + if (typeof value.currentSectionIndex !== 'number') return false + if (typeof value.totalSections !== 'number') return false + if (typeof value.finalAuditDone !== 'boolean') return false + if (value.goal !== undefined && typeof value.goal !== 'string') return false + if (!Array.isArray(value.sections)) return false + if (!Array.isArray(value.findings)) return false + if (!value.sections.every(isValidResumeSection)) return false + if (!value.findings.every(isValidResumeFinding)) return false + return true +} diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 1f4bbd877..fa28a3ae3 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -108,6 +108,8 @@ export interface Loop { inspect(name: string): LoopState | null listActive(): LoopState[] listRecent(): LoopState[] + /** All loop names for the project (any status) without hydrating plans. */ + listLoopNames(): string[] findMatchByName(name: string): { match: LoopState | null; candidates: LoopState[] } hasOutstandingFindings(loopName?: string, severity?: 'bug' | 'warning'): boolean terminateAll(): Promise @@ -2971,6 +2973,10 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { return loopService.listRecent() } + function listLoopNames(): string[] { + return loopService.listLoopNames() + } + function findMatchByName(name: string): { match: LoopState | null; candidates: LoopState[] } { return loopService.findMatchByName(name) } @@ -3103,6 +3109,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { inspect, listActive, listRecent, + listLoopNames, findMatchByName, hasOutstandingFindings, terminateAll, diff --git a/src/loop/service.ts b/src/loop/service.ts index 4983e52dc..0f1161741 100644 --- a/src/loop/service.ts +++ b/src/loop/service.ts @@ -62,6 +62,8 @@ export interface LoopService { buildAuditPrompt(state: LoopState): string listActive(): LoopState[] listRecent(): LoopState[] + /** All loop names for the project (any status) without hydrating plans. */ + listLoopNames(): string[] findMatchByName(name: string): { match: LoopState | null; candidates: LoopState[] } getStallTimeoutMs(): number getMaxConsecutiveStalls(): number @@ -305,6 +307,10 @@ export function createLoopService( }) } + function listLoopNames(): string[] { + return loopsRepo.listLoopNames(projectId) + } + function findMatchByName(name: string): { match: LoopState | null; candidates: LoopState[] } { const result = loopsRepo.findPartial(projectId, name) const mapResult = (row: LoopRow | null): LoopState | null => { @@ -870,6 +876,7 @@ export function createLoopService( buildAuditPrompt, listActive, listRecent, + listLoopNames, findMatchByName, getStallTimeoutMs, getMaxConsecutiveStalls, diff --git a/src/loop/termination.ts b/src/loop/termination.ts index abe1f31c7..40171b820 100644 --- a/src/loop/termination.ts +++ b/src/loop/termination.ts @@ -16,6 +16,7 @@ export type TerminationReason = | { kind: 'worktree_failed'; message: string } | { kind: 'error_max_retries'; message: string } | { kind: 'provider_limit'; message: string } + | { kind: 'migrated'; message: string } export function terminationStatusFor(reason: TerminationReason): TerminationStatus { switch (reason.kind) { @@ -24,6 +25,7 @@ export function terminationStatusFor(reason: TerminationReason): TerminationStat case 'cancelled': case 'user_aborted': case 'shutdown': + case 'migrated': return 'cancelled' case 'stall_timeout': return 'stalled' @@ -40,6 +42,8 @@ export function terminationReasonToString(reason: TerminationReason): string { return `error_max_retries: ${reason.message}` case 'provider_limit': return `provider_limit: ${reason.message}` + case 'migrated': + return `migrated: ${reason.message}` default: return reason.kind } @@ -53,6 +57,7 @@ export function parseTerminationReasonString(str: string): TerminationReason { if (prefix === 'worktree_failed') return { kind: 'worktree_failed', message } if (prefix === 'error_max_retries') return { kind: 'error_max_retries', message } if (prefix === 'provider_limit') return { kind: 'provider_limit', message } + if (prefix === 'migrated') return { kind: 'migrated', message } } // For simple kinds without messages diff --git a/src/prompts/agents/code.md b/src/prompts/agents/code.md index 864fa7a69..b9df694cd 100644 --- a/src/prompts/agents/code.md +++ b/src/prompts/agents/code.md @@ -56,7 +56,7 @@ Mark todos as completed as soon as each task is done — do not batch completion - Use specialized tools (Read, Glob, Grep) instead of bash equivalents (cat, find, grep, sed, echo). ## Forge custom tools -- Do not call `execute-goal`, `execute-plan`, `launch-group`, or `loop-cancel` unless the user explicitly asks you to. They launch or stop loops and groups; never invoke them proactively. +- Do not call `execute-goal`, `execute-plan`, `launch-group`, `loop-cancel`, or `loop-migrate` unless the user explicitly asks you to. They launch, stop, or move loops and groups; never invoke them proactively. # Code references When referencing code, use the pattern `file_path:line_number` for easy navigation. diff --git a/src/prompts/commands/loop-migrate.md b/src/prompts/commands/loop-migrate.md new file mode 100644 index 000000000..be53f813b --- /dev/null +++ b/src/prompts/commands/loop-migrate.md @@ -0,0 +1,15 @@ +## Step 1: Identify the Loop + +Run `loop-status` to see all active loops if you don't know the name. + +## Step 2: Migrate the Loop + +Run `loop-migrate` with: +- name: The worktree name of the loop to migrate +- remote: The configured remote to migrate to (the `remotes[].name` values in the plugin config) + +## Step 3: Report the Migration + +Confirm the loop was migrated: report the remote loop name and remote session id, and note that the local loop is stopped as migrated; it restarts locally only with `loop-status restart=true force=true`. If the tool refused (project-directory loop, feature-group loop, or a loop that changed state), report the reason and do not retry automatically. + +$ARGUMENTS diff --git a/src/sandbox/process.ts b/src/sandbox/process.ts index ff9f3846f..48dca9b37 100644 --- a/src/sandbox/process.ts +++ b/src/sandbox/process.ts @@ -11,6 +11,7 @@ export interface RunCommandOpts { timeout?: number abort?: AbortSignal stdin?: string + cwd?: string logger: Logger logLabel?: string /** @@ -38,6 +39,7 @@ export function runCommand(command: string, args: string[], opts: RunCommandOpts const inner = new Promise((resolve) => { const stdioConfig: 'pipe' | 'ignore' = opts.stdin ? 'pipe' : 'ignore' const child: ChildProcess = spawn(command, args, { + cwd: opts.cwd, stdio: [stdioConfig, 'pipe', 'pipe'], }) diff --git a/src/services/execution-response.ts b/src/services/execution-response.ts new file mode 100644 index 000000000..5bcb5e33d --- /dev/null +++ b/src/services/execution-response.ts @@ -0,0 +1,41 @@ +/** + * Shared response construction for the Forge execution command bus. + * + * `ok`/`fail` builders plus the response/error/warning shapes, in one module + * so command handlers (e.g. the restart handler in execution.ts and the + * migration handler in loop-migration.ts) cannot drift in error construction. + */ + +export interface ForgeExecutionError { + code: 'bad_request' | 'not_found' | 'conflict' | 'disabled' | 'prompt_failed' | 'lifecycle_failed' | 'internal_error' | 'provider_limit' + status: number + message: string + candidates?: string[] + details?: Record +} + +export interface ForgeExecutionWarning { + code: string + message: string +} + +export type ForgeExecutionResponse = + | { ok: true; data: T; warnings?: ForgeExecutionWarning[] } + | { ok: false; error: ForgeExecutionError } + +export function ok(data: T, warnings?: ForgeExecutionWarning[]): ForgeExecutionResponse { + return { ok: true, data, warnings } +} + +export function fail( + code: ForgeExecutionError['code'], + status: number, + message: string, + details?: Record, + candidates?: string[], +): ForgeExecutionResponse { + return { + ok: false, + error: { code, status, message, details, candidates } + } +} diff --git a/src/services/execution.ts b/src/services/execution.ts index 7a173fe85..f932b5540 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -15,12 +15,11 @@ import type { createLoopEventHandler } from '../hooks' import type { SandboxManager } from '../sandbox/manager' import { extractPlanExecutionMetadata, createPlanExecutionSession } from '../utils/plan-execution' import { parseModelString } from '../utils/model-fallback' -import { auditorModelChoiceAt, buildAuditorModelChain } from '../utils/loop-helpers' import { classifyProviderLimit, extractErrorSignal } from '../loop/provider-limit' import { formatLoopSessionTitle, formatPlanSessionTitle } from '../utils/session-titles' import { slugify } from '../utils/logger' -import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, resolveLoopPermissionOptions } from '../constants/loop' +import { buildLoopPermissionRuleset, buildSessionPermissionRulesetForAgent, resolveLoopPermissionOptions } from '../constants/loop' import { resolveLoopPermissionOptionsForWorkspace } from '../utils/loop-permission-options' import { findPartialMatch } from '../utils/partial-match' import { isSandboxEnabled } from '../sandbox/context' @@ -37,7 +36,16 @@ import { getRestartability, type RestartBlockedReason } from '../loop/restartabi import { loopBranchExists } from '../workspace/forge-naming' import { getWorktreeProjectPreconditionError } from '../workspace/forge-worktree' import { resolveHostSessionDirectory } from '../utils/resolve-project-root' -import { resolvePostActionConfig, type ResolvedPostActionConfig } from '../loop/post-action-config' +import { resolvePostActionConfig } from '../loop/post-action-config' +import { buildResumePromptPlan, resolveResumeAgent, resolveResumePhase } from '../loop/resume-prompt' +import { restoreLoopResumeRows, type LoopResumeSnapshot } from '../loop/resume-snapshot' +import { migrateLoopToRemote } from './loop-migration' +import { + ok, + fail, + type ForgeExecutionError, + type ForgeExecutionResponse, +} from './execution-response' /** * A freshly created + warped loop session can transiently report "Session not @@ -100,6 +108,10 @@ export interface ForgeLoopExtra { sandboxEnabled?: boolean /** msb container name when the loop runs sandboxed. */ sandboxContainer?: string + /** Persisted progress snapshot for a loop migrated from another host; applied on attach instead of plan decomposition. */ + resume?: LoopResumeSnapshot + /** Iteration budget carried on the workspace extra; read by the attach hook and remote/migrated launches. */ + maxIterations?: number } export interface AttachLoopInput { @@ -125,6 +137,8 @@ export interface AttachLoopInput { goal?: string /** Executor session binding for goal loops (the dedicated code session). */ executorSessionId?: string + /** Migrated-loop progress snapshot; restores phase/section pointers and rows instead of decomposing planText. */ + resume?: LoopResumeSnapshot selectSession?: boolean selectSessionTiming?: 'after-create' | 'after-prompt' startWatchdog?: boolean @@ -260,6 +274,13 @@ export interface GetLoopStatusCommand { limit?: number } +export interface MigrateLoopCommand { + type: 'loop.migrate' + /** Migration always targets a named loop; only-active has no single owner. */ + selector: Exclude + remoteName: string +} + export type ForgeExecutionCommand = | ExecutePlanNewSessionCommand | ExecutePlanHereCommand @@ -268,27 +289,7 @@ export type ForgeExecutionCommand = | RestartLoopCommand | CancelLoopCommand | GetLoopStatusCommand - -// ============================================================================ -// Response/Error Types -// ============================================================================ - -export interface ForgeExecutionError { - code: 'bad_request' | 'not_found' | 'conflict' | 'disabled' | 'prompt_failed' | 'lifecycle_failed' | 'internal_error' | 'provider_limit' - status: number - message: string - candidates?: string[] - details?: Record -} - -export interface ForgeExecutionWarning { - code: string - message: string -} - -export type ForgeExecutionResponse = - | { ok: true; data: T; warnings?: ForgeExecutionWarning[] } - | { ok: false; error: ForgeExecutionError } + | MigrateLoopCommand // ============================================================================ // Result Types per Command @@ -403,6 +404,19 @@ export interface LoopStatusResult { recent: LoopStatusView[] } +export interface LoopMigratedResult { + operation: 'loop.migrate' + loopName: string + remoteName: string + remoteLoopName: string + remoteSessionId: string + startRef: string + syncRef: string + phase: import('../loop/resume-prompt').ResumePhase + currentSectionIndex: number + totalSections: number +} + // Type mapping from command to result export type ForgeExecutionResult = C extends ExecutePlanNewSessionCommand ? PlanExecutionStartedResult : @@ -412,6 +426,7 @@ export type ForgeExecutionResult = C extends RestartLoopCommand ? LoopRestartedResult : C extends CancelLoopCommand ? LoopCancelledResult : C extends GetLoopStatusCommand ? LoopStatusResult : + C extends MigrateLoopCommand ? LoopMigratedResult : never // ============================================================================ @@ -443,35 +458,14 @@ export interface ForgeExecutionServiceDeps { sandboxManager?: SandboxManager | null sectionPlansRepo?: import('../storage/repos/section-plans-repo').SectionPlansRepo reviewFindingsRepo?: import('../storage/repos/review-findings-repo').ReviewFindingsRepo + featureGroupsRepo?: import('../storage/repos/feature-groups-repo').FeatureGroupsRepo loopSessionUsageRepo?: import('../storage/repos/loop-session-usage-repo').LoopSessionUsageRepo workspaceStatusRegistry: import('../utils/workspace-status-registry').WorkspaceStatusRegistry pendingTeardowns: import('../workspace/pending-teardown').PendingTeardownRegistry -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function normalizeModelString(value: string | undefined): string | undefined { - const trimmed = value?.trim() - return trimmed ? trimmed : undefined -} - -function ok(data: T, warnings?: ForgeExecutionWarning[]): ForgeExecutionResponse { - return { ok: true, data, warnings } -} - -function fail( - code: ForgeExecutionError['code'], - status: number, - message: string, - details?: Record, - candidates?: string[] -): ForgeExecutionResponse { - return { - ok: false, - error: { code, status, message, details, candidates } - } + /** Git service for loop.migrate branch-tip/sync-ref pushes; defaults to defaultGitService. */ + git?: import('../utils/git-service').GitService + /** Remote client factory for loop.migrate discovery; defaults to createRemoteForgeClient. */ + createRemoteClient?: (opts: import('../client/sdk-adapter').RemoteClientOptions) => ForgeClient } // ============================================================================ @@ -521,6 +515,24 @@ async function resolvePlanSource( } } +// ============================================================================ +// Named-loop resolution +// ============================================================================ + +export function resolveNamedLoop( + deps: Pick, + name: string, +): { state: import('../loop/state').LoopState } | { response: ForgeExecutionResponse } { + const { match, candidates } = deps.loop.findMatchByName(name) + if (!match && candidates.length > 0) { + return { response: fail('conflict', 409, `Multiple loops match "${name}". Be more specific.`, undefined, candidates.map((s) => s.loopName)) } + } + if (!match) { + return { response: fail('not_found', 404, `No loop found for "${name}".`, undefined, deps.loop.listLoopNames()) } + } + return { state: match } +} + // ============================================================================ // Port-based helpers // ============================================================================ @@ -621,8 +633,9 @@ export async function attachLoopToSession( kind, goal, executorSessionId, + resume, } = input - const isGoal = kind === 'goal' + const isGoal = (resume?.kind ?? kind) === 'goal' const loopModel = parseModelString(executionModel) @@ -658,6 +671,10 @@ export async function attachLoopToSession( (await resolveHostSessionDirectory(deps.client, input.hostSessionId, ctx.directory, deps.logger)) ?? ctx.directory try { + if (resume && (!deps.sectionPlansRepo || !deps.reviewFindingsRepo)) { + return { ok: false, code: 'internal_error', message: 'Resume requires section and finding repositories' } + } + // Persist loop state const state: import('../loop/state').LoopState = { active: true, @@ -670,7 +687,7 @@ export async function attachLoopToSession( maxIterations, startedAt: new Date().toISOString(), prompt: isGoal ? undefined : planText, - phase: 'coding', + phase: resume ? resume.phase : 'coding', errorCount: 0, auditCount: 0, status: 'running', @@ -683,16 +700,32 @@ export async function attachLoopToSession( auditorVariant, workspaceId, hostSessionId: input.hostSessionId, - currentSectionIndex: 0, - totalSections: 0, - finalAuditDone: false, - ...(isGoal ? { kind: 'goal' as const, goal, executorSessionId } : {}), + currentSectionIndex: resume ? resume.currentSectionIndex : 0, + totalSections: resume ? resume.totalSections : 0, + finalAuditDone: resume ? resume.finalAuditDone : false, + ...(isGoal + ? { + kind: 'goal' as const, + goal: resume ? resume.goal ?? goal : goal, + executorSessionId: resume ? sessionId : executorSessionId, + } + : {}), } deps.loop.service.setState(loopName, state) deps.loop.service.registerLoopSession(sessionId, loopName) deps.loop.registerSessionReverseIndex(sessionId, loopName) + if (resume) { + restoreLoopResumeRows({ + projectId: ctx.projectId, + loopName, + snapshot: resume, + sectionPlansRepo: deps.sectionPlansRepo!, + reviewFindingsRepo: deps.reviewFindingsRepo!, + }) + } + deps.logger.log(`attachLoopToSession: state stored for loop=${loopName}`) onStarted?.({ @@ -705,7 +738,16 @@ export async function attachLoopToSession( // === Initial prompt === let promptText: string - if (isGoal) { + let resumePlan: ReturnType | undefined + if (resume) { + // A migrated loop must not be re-decomposed: its persisted section rows + // (including in-progress state and attempts) were restored above, and + // decomposition would reset section 0 / rewrite the rows. The resume + // prompt plan is built lazily after the sendInitialPrompt gate below — + // migrated loops always attach with sendInitialPrompt: false, so eager + // construction would compute the plan and discard it. + promptText = '' + } else if (isGoal) { // Goal loops have no sections; the initial prompt is the same goal // continuation prompt used on every later iteration. promptText = deps.loop.service.buildContinuationPrompt(state) @@ -764,17 +806,25 @@ export async function attachLoopToSession( return { ok: true, loopName } } + if (resume) { + resumePlan = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state }) + promptText = resumePlan.promptText + } + // Send initial prompt with fallback const sessionDir = worktreeDir const promptParts = [{ type: 'text' as const, text: promptText }] const workspaceParam = workspaceId ? { workspace: workspaceId } : {} + const promptAgent = resumePlan ? resumePlan.agent : 'code' as const + const promptVariant = resumePlan ? resumePlan.variant : undefined const promptResult = await sendLoopPrompt({ loopName, sessionId, - agent: 'code', + agent: promptAgent, logger: deps.logger, - primaryModel: loopModel, + primaryModel: resumePlan ? resumePlan.model : loopModel, + fallbackModel: resumePlan?.fallbackModel, useInFlightGuard: false, performPrompt: async (model) => { markPromptSent(loopName, sessionId, deps.logger) @@ -783,7 +833,8 @@ export async function attachLoopToSession( sessionID: sessionId, directory: sessionDir, parts: promptParts, - agent: 'code', + agent: promptAgent, + ...(promptVariant ? { variant: promptVariant } : {}), ...workspaceParam, ...(model ? { model } : {}), }) @@ -1706,16 +1757,9 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo } const name = command.selector.name - const active = deps.loop.listActive() - const recent = deps.loop.listRecent() - const allStates = [...active, ...recent] - const { match: stoppedState, candidates } = findPartialMatch(name, allStates, s => [s.loopName, s.worktreeBranch]) - if (!stoppedState && candidates.length > 0) { - return fail('conflict', 409, `Multiple loops match "${name}". Be more specific.`, undefined, candidates.map(s => s.loopName)) - } - if (!stoppedState) { - return fail('not_found', 404, `No loop found for "${name}".`, undefined, allStates.map(s => s.loopName)) - } + const resolved = resolveNamedLoop(deps, name) + if ('response' in resolved) return resolved.response + const stoppedState = resolved.state const restartability = getRestartability(stoppedState, { force: command.force, @@ -1830,7 +1874,13 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo } const permissionOptions = await resolveLoopPermissionOptionsForWorkspace(deps.client, deps.config, stoppedState.workspaceId) - const permissionRuleset = buildLoopPermissionRuleset(permissionOptions) + + // The recreated session's permission ruleset is selected before the session + // swap, so the resume phase is resolved from the persisted (under-lock) + // state here. The full prompt plan is built after the optional plan + // decomposition below, mirroring the original prompt-selection ordering. + const resumePhase = resolveResumePhase(stoppedState.phase) + const permission = buildSessionPermissionRulesetForAgent(resolveResumeAgent(resumePhase), permissionOptions) stoppedState.iteration = 1 @@ -1846,6 +1896,10 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const preservedExtra = Object.fromEntries( Object.entries(previousEntry?.extra ?? {}).filter(([key]) => !['startRef', 'syncRef', 'gitRemote'].includes(key)), ) + if (preservedExtra.forgeLoop && typeof preservedExtra.forgeLoop === 'object') { + const { resume: _consumedResume, ...restForgeLoop } = preservedExtra.forgeLoop as Record + preservedExtra.forgeLoop = restForgeLoop + } const wsResult = await createBuiltinWorktreeWorkspace(deps.client, { loopName: stoppedState.loopName, directory: stoppedState.projectDir || ctx.directory, @@ -1877,7 +1931,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo totalSections: stoppedState.totalSections ?? 0, }), directory: stoppedState.worktreeDir, - permission: stoppedState.phase === 'final_auditing' ? buildAuditSessionPermissionRuleset(permissionOptions) : permissionRuleset, + permission, workspaceId: stoppedState.workspaceId, loopName: stoppedState.loopName, logPrefix: 'loop-restart', @@ -1924,14 +1978,13 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // else: existing totalSections preserved as-is const effectiveSessionId = newSessionId! - // A stopped final_audit_fix loop is a coding pass (the fix session), not an - // auditor phase — restart it as coding with the code prompt agent. The other - // auditor phases (final_auditing, post_action) preserve their persisted phase. - const restartPhase = stoppedState.phase === 'final_auditing' - ? 'final_auditing' as const - : stoppedState.phase === 'post_action' - ? 'post_action' as const - : 'coding' as const + // Full resume plan after the optional in-restart plan decomposition, so a + // legacy non-sectioned loop resumes with the freshly decomposed section + // prompt and the prompt builders run exactly once, as before the refactor. + // Decomposition cannot change the resume phase; `resolveResumePhase` is + // pure on `state.phase`, so the pre-swap permission selection above and + // the plan below always agree. + const resume = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state: stoppedState }) const newState: import('../loop/state').LoopState = { active: true, @@ -1944,7 +1997,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo maxIterations: stoppedState.maxIterations, startedAt: new Date().toISOString(), prompt: stoppedState.prompt, - phase: restartPhase, + phase: resumePhase, errorCount: 0, auditCount: 0, status: 'running', @@ -1965,58 +2018,11 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo kind: stoppedState.kind, goal: stoppedState.goal, } - // Build appropriate prompt based on persisted state - let promptText: string - let postActionCfg: ResolvedPostActionConfig | undefined - - if (stoppedState.phase === 'post_action') { - postActionCfg = resolvePostActionConfig(deps.config) - promptText = deps.loop.service.buildPostActionPrompt(stoppedState, { skill: postActionCfg.skill, prompt: postActionCfg.prompt }) - } else if (stoppedState.kind === 'goal') { - // Goal loops have no plan, sections, or approval flow — restate the goal - // directly as a fresh coding pass. No initial audit findings on restart. - promptText = deps.loop.service.buildContinuationPrompt(stoppedState, undefined) - } else if (stoppedState.phase === 'final_audit_fix') { - // Resume fixing the final-audit findings rather than re-coding the last - // section: the persisted findings carry the remediation for this - // recovery path. - const outstandingBugs = deps.loop.service.getOutstandingFindings(stoppedState.loopName, 'bug') - promptText = deps.loop.service.buildFinalAuditFixPrompt(stoppedState, outstandingBugs) - } else if (stoppedState.totalSections > 0) { - // Use persisted section state to build the correct section prompt - if (stoppedState.phase === 'final_auditing') { - promptText = deps.loop.service.buildFinalAuditPrompt(stoppedState) - } else { - promptText = deps.loop.service.buildSectionInitialPrompt(stoppedState) - } - } else { - // Legacy non-sectioned prompt - promptText = stoppedState.prompt ?? '' - } - const restartAuditorState = { - ...stoppedState, - auditorModel: normalizeModelString(stoppedState.auditorModel ?? deps.config.auditorModel), - modelFailed: false, - auditorFallbackIndex: 0, - } - const restartAuditorChoice = auditorModelChoiceAt(buildAuditorModelChain(deps.config, restartAuditorState), 0) - const restartAuditorModel = restartAuditorChoice.model - const loopModel = stoppedState.phase === 'post_action' && postActionCfg?.model - ? parseModelString(postActionCfg.model) - : stoppedState.phase === 'final_auditing' || stoppedState.phase === 'post_action' - ? restartAuditorModel - : parseModelString(stoppedState.executionModel) ?? parseModelString(deps.config.executionModel) - // When a configured post-action model is used, fall back to the loop's auditor model if it fails. - const loopFallbackModel = stoppedState.phase === 'post_action' && postActionCfg?.model - ? restartAuditorModel - : undefined + const loopModel = resume.model + const loopFallbackModel = resume.fallbackModel const workspaceParam = stoppedState.workspaceId ? { workspace: stoppedState.workspaceId } : {} - // final_audit_fix is a coding-style phase: restart sends the final-audit fix - // prompt as the code agent (never the auditor-loop agent). - const promptAgent = stoppedState.phase === 'final_auditing' ? 'auditor-loop' as const : 'code' as const - deps.loopsRepo.restart(ctx.projectId, stoppedState.loopName, { sessionId: newState.sessionId, phase: newState.phase, @@ -2025,7 +2031,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo sandbox: newState.sandbox ?? false, sandboxContainer: newState.sandboxContainer ?? null, workspaceId: newState.workspaceId ?? null, - auditorModel: restartAuditorState.auditorModel ?? null, + auditorModel: resume.auditorModel ?? null, currentSectionIndex: newState.currentSectionIndex, totalSections: newState.totalSections, finalAuditDone: newState.finalAuditDone, @@ -2042,31 +2048,27 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // already be in place so the transition log shows the real sequence // (phase change → terminal). We skip when persisted phase matches the // restart phase (final_auditing / post_action stay in place). - const restartPhaseChanged = restartPhase !== stoppedState.phase + const restartPhaseChanged = resumePhase !== stoppedState.phase if (restartPhaseChanged) { deps.loop.service.recordTransition(stoppedState.loopName, { eventType: 'restart', transitionKind: 'phase', fromPhase: stoppedState.phase, - toPhase: restartPhase, + toPhase: resumePhase, iteration: 1, sectionIndex: transitionSectionIndex(stoppedState), }) } - const restartVariant = promptAgent === 'auditor-loop' - ? restartAuditorChoice.variant - : stoppedState.executionVariant - const performRestartPrompt = async (model?: { providerID: string; modelID: string }): Promise<{ error?: unknown }> => { markPromptSent(stoppedState.loopName, effectiveSessionId, deps.logger) try { await deps.client.session.promptAsync({ sessionID: effectiveSessionId, directory: stoppedState.worktreeDir, - parts: [{ type: 'text' as const, text: promptText }], - agent: promptAgent, - ...(model ? { model, ...(restartVariant ? { variant: restartVariant } : {}) } : {}), + parts: [{ type: 'text' as const, text: resume.promptText }], + agent: resume.agent, + ...(model ? { model, ...(resume.variant ? { variant: resume.variant } : {}) } : {}), ...workspaceParam, }) return {} @@ -2085,7 +2087,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const { result } = await sendLoopPrompt({ loopName: stoppedState.loopName, sessionId: effectiveSessionId, - agent: promptAgent, + agent: resume.agent, logger: deps.logger, primaryModel: loopModel, fallbackModel: loopFallbackModel, @@ -2113,7 +2115,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // For a final-audit restart the session is reused by the auditor // fallback re-dispatch, so keep its reverse index intact. For a // coding-style restart the loop terminates and the session is orphaned. - const isAuditorRestart = promptAgent === 'auditor-loop' + const isAuditorRestart = resume.agent === 'auditor-loop' if (!isAuditorRestart) { deps.loop.unregisterSessionReverseIndex(effectiveSessionId) } @@ -2173,14 +2175,14 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo restoreRow = previousState } deps.loop.service.restoreState(restoreRow.loopName, restoreRow) - const restartFromPhase = restartPhase + const restartFromPhase = resumePhase const restartToPhase = previousState.phase ?? 'coding' const iteration = previousState.iteration ?? 0 const sectionIndex = transitionSectionIndex(previousState) // Log the rollback restoration whenever the restart actually changed // the persisted phase, so the transition history stays continuous: - // previousPhase -> restartPhase (pre-prompt 'restart' phase row) - // restartPhase -> previousPhase (this 'rollback' row) + // previousPhase -> resumePhase (pre-prompt 'restart' phase row) + // resumePhase -> previousPhase (this 'rollback' row) // When the restart preserved the phase (final_auditing / post_action // stay in place), there is nothing to roll back phase-wise and no // rollback row is emitted. @@ -2348,6 +2350,8 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo return handleLoopCancel(ctx, command) as Promise>> case 'loop.restart': return handleLoopRestart(ctx, command) as Promise>> + case 'loop.migrate': + return migrateLoopToRemote(deps, ctx, command) as Promise>> default: return fail('bad_request', 400, 'Unknown command type') as ForgeExecutionResponse> } diff --git a/src/services/loop-migration.ts b/src/services/loop-migration.ts new file mode 100644 index 000000000..4154b7493 --- /dev/null +++ b/src/services/loop-migration.ts @@ -0,0 +1,269 @@ +/** + * Loop migration execution command: moves a local loop's progress to a remote + * opencode server. Freezes the local loop with a `migrated` termination + * reason, pushes the loop branch tip to the shared git remote's forge sync + * ref, and launches the remote loop with a resume snapshot and a + * phase-appropriate first prompt. + * + * Every step composes the owners built in Phases 2-6: remote discovery via + * `connectRemoteProject`, sync-ref push/delete via `pushForgeSyncRef`/ + * `deleteForgeSyncRef`, loop-name reservation + workspace/session creation + * via `launchTuiLoop`, snapshot capture via `captureLoopResumeSnapshot`, and + * prompt selection via `buildResumePromptPlan`. This module only sequences + * them and owns the freeze/rollback semantics. + */ + +import { existsSync } from 'fs' +import type { ForgeExecutionServiceDeps, ForgeExecutionRequestContext, MigrateLoopCommand, LoopMigratedResult } from './execution' +import type { ForgeExecutionResponse, ForgeExecutionWarning } from './execution-response' +import { ok, fail } from './execution-response' +import { connectRemoteProject, prepareRemoteLoopLaunch, pushAndLaunchRemoteLoop, deleteForgeSyncRef } from '../utils/tui-remote-launch' +import { resolveLoopPermissionOptionsForWorkspace } from '../utils/loop-permission-options' +import { resolveDataDir } from '../utils/opencode-paths' +import { resolveNamedLoop } from './execution' +import { getRestartability } from '../loop/restartability' +import { loopBranchExists, forgeBranchName } from '../workspace/forge-naming' +import { resolvePostActionConfig } from '../loop/post-action-config' +import { terminationStatusFor, terminationReasonToString, parseTerminationReasonString, type TerminationReason } from '../loop' +import { captureLoopResumeSnapshot, type LoopResumeSnapshot } from '../loop/resume-snapshot' +import { buildResumePromptPlan } from '../loop/resume-prompt' +import { defaultGitService } from '../utils/git-service' +import { getForgeWorkspaceEntry } from '../workspace/forge-worktree' + +interface PreFreezeTerminalSnapshot { + status: import('../loop/state').LoopState['status'] + terminationReason?: string + completedAt?: string + completionSummary?: string +} + +export async function migrateLoopToRemote( + deps: ForgeExecutionServiceDeps, + ctx: ForgeExecutionRequestContext, + command: MigrateLoopCommand, +): Promise> { + if (!deps.loopHandler || !deps.sectionPlansRepo || !deps.reviewFindingsRepo) { + return fail('internal_error', 500, 'Loop migration requires the loop handler and section/finding repositories') + } + const debug = (message: string) => deps.logger.debug(message) + + const resolved = resolveNamedLoop(deps, command.selector.name) + if ('response' in resolved) return resolved.response + const state = resolved.state + + if (!state.worktree) { + return fail('conflict', 409, `Loop "${state.loopName}" runs in the project directory (worktree: false) and cannot be migrated.`) + } + if (deps.featureGroupsRepo?.getFeatureByLoopName(ctx.projectId, state.loopName)) { + return fail('conflict', 409, `Loop "${state.loopName}" belongs to a feature group and cannot be migrated; groups are orchestrated locally.`) + } + + const git = deps.git ?? defaultGitService + const restartability = getRestartability(state, { + worktreeExists: existsSync, + branchExists: () => loopBranchExists(state, ctx.directory, git), + }) + if (!restartability.restartable || restartability.restartBlockedReason === 'migrated') { + return fail('conflict', 409, restartability.restartBlockedMessage ?? `Loop "${state.loopName}" cannot be migrated.`) + } + if (state.phase === 'post_action' && !resolvePostActionConfig(deps.config).enabled) { + return fail('conflict', 409, 'Loop implementation already completed; post-action is disabled — nothing to migrate.') + } + + const connected = await connectRemoteProject( + { remoteName: command.remoteName, localProjectId: ctx.projectId, localDirectory: ctx.directory }, + { config: deps.config, createClient: deps.createRemoteClient, debug }, + ) + if ('error' in connected) { + return fail('bad_request', 400, connected.error) + } + const { remote, project: remoteProject, client: remoteClient } = connected + + const original: PreFreezeTerminalSnapshot = { + status: state.status, + terminationReason: state.terminationReason, + completedAt: state.completedAt, + completionSummary: state.completionSummary, + } + const wsOptions = await resolveLoopPermissionOptionsForWorkspace(deps.client, deps.config, state.workspaceId) + const remotePermissionOptions = { extraRules: wsOptions.extraRules } + const previousEntry = state.workspaceId + ? await getForgeWorkspaceEntry(deps.client, state.workspaceId).catch(() => undefined) + : undefined + const ownSyncRef = typeof previousEntry?.extra?.syncRef === 'string' ? previousEntry.extra.syncRef : undefined + const ownGitRemote = typeof previousEntry?.extra?.gitRemote === 'string' ? previousEntry.extra.gitRemote : undefined + + const warnings: ForgeExecutionWarning[] = [] + const remoteLoopName = await prepareRemoteLoopLaunch({ + client: remoteClient, + requestedLoopName: state.loopName, + permissionOptions: remotePermissionOptions, + config: deps.config, + dataDir: deps.config.dataDir || resolveDataDir(), + localDirectory: ctx.directory, + debug, + onWarnings: (messages) => warnings.push(...messages.map((message) => ({ code: 'loop_permissions', message }))), + }) + const syncRef = remoteLoopName.syncRef + + const migratedReason: TerminationReason = { kind: 'migrated', message: remote.name } + const fresh = await deps.loop.runExclusive(state.loopName, async () => deps.loop.inspect(state.loopName)) + if (!fresh) { + return fail('not_found', 404, `Loop "${state.loopName}" vanished during migration freeze.`) + } + let frozen: import('../loop/state').LoopState | null + if (fresh.active) { + const terminated = await deps.loopHandler.terminateLoopByName(fresh.loopName, migratedReason) + if (terminated === false) { + return fail('conflict', 409, `Loop "${state.loopName}" changed state during migration; retry.`) + } + frozen = deps.loop.inspect(fresh.loopName) + } else { + await deps.loop.runExclusive(state.loopName, async () => { + const latest = deps.loop.inspect(state.loopName) + if (latest && !latest.active) { + relabelInactiveLoop(deps, state.loopName, migratedReason) + } + }) + frozen = deps.loop.inspect(state.loopName) + } + if (!frozen) { + return fail('internal_error', 500, `Loop "${state.loopName}" vanished during migration freeze.`) + } + if (frozen.active || parseTerminationReasonString(frozen.terminationReason ?? '').kind !== 'migrated') { + return fail('conflict', 409, `Loop "${state.loopName}" changed state during migration; retry.`) + } + + const projectDir = frozen.projectDir || ctx.directory + + if (frozen.worktreeDir && existsSync(frozen.worktreeDir)) { + const statusResult = git.statusPorcelain(frozen.worktreeDir) + if (!statusResult.ok) { + return rollbackAfterFreeze( + deps, + frozen, + original, + `Worktree ${frozen.worktreeDir} status check failed after teardown: ${statusResult.stderr || '(no stderr)'}; migration aborted so no work is lost.`, + ) + } + if (statusResult.stdout.trim().length > 0) { + return rollbackAfterFreeze( + deps, + frozen, + original, + `Worktree ${frozen.worktreeDir} has uncommitted changes after teardown; migration aborted so no work is lost.`, + ) + } + } + + let snapshot: LoopResumeSnapshot + let tip: string + let launch: { loopName: string; sessionId: string } + try { + snapshot = captureLoopResumeSnapshot({ + projectId: ctx.projectId, + state: frozen, + sectionPlansRepo: deps.sectionPlansRepo, + reviewFindingsRepo: deps.reviewFindingsRepo, + }) + const branch = frozen.worktreeBranch || forgeBranchName(frozen.loopName) + const tipResult = git.revParseRef(projectDir, `refs/heads/${branch}`) + if (!tipResult.ok) { + return rollbackAfterFreeze(deps, frozen, original, `Failed to resolve loop branch ${branch}: ${tipResult.stderr || '(no stderr)'}`) + } + tip = tipResult.stdout.trim() + + const resume = buildResumePromptPlan({ service: deps.loop.service, config: deps.config, state: frozen }) + const result = await pushAndLaunchRemoteLoop({ + git, + cwd: projectDir, + remote, + project: remoteProject, + client: remoteClient, + loopName: remoteLoopName.loopName, + syncRef, + sourceRef: `refs/heads/${branch}`, + startRef: tip, + title: frozen.loopName, + plan: frozen.prompt ?? '', + executionModel: frozen.executionModel, + auditorModel: frozen.auditorModel, + executionVariant: frozen.executionVariant, + auditorVariant: frozen.auditorVariant, + permissionOptions: remotePermissionOptions, + forgeLoopOverrides: { + maxIterations: frozen.maxIterations, + resume: snapshot, + }, + initialPrompt: { + text: resume.promptText, + agent: resume.agent, + model: resume.model, + variant: resume.variant, + }, + pushErrorPrefix: `Failed to push loop branch to remote "${remote.name}": `, + debug, + }) + + if ('error' in result) { + return rollbackAfterFreeze(deps, frozen, original, result.error) + } + launch = result + } catch (err) { + return rollbackAfterFreeze(deps, frozen, original, err instanceof Error ? err.message : String(err)) + } + + if (ownSyncRef && ownGitRemote) { + const cleanup = await deleteForgeSyncRef(git, { cwd: projectDir, gitRemote: ownGitRemote, syncRef: ownSyncRef }) + debug(`loop-migrate: previous sync-ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) + } + + return ok({ + operation: 'loop.migrate', + loopName: frozen.loopName, + remoteName: remote.name, + remoteLoopName: launch.loopName, + remoteSessionId: launch.sessionId, + startRef: tip, + syncRef, + phase: snapshot.phase, + currentSectionIndex: snapshot.currentSectionIndex, + totalSections: snapshot.totalSections, + }, warnings.length > 0 ? warnings : undefined) +} + +function relabelInactiveLoop( + deps: ForgeExecutionServiceDeps, + loopName: string, + reason: TerminationReason, +): void { + deps.loop.service.terminate(loopName, { + status: terminationStatusFor(reason), + reason: terminationReasonToString(reason), + completedAt: Date.now(), + }) +} + +function rollbackAfterFreeze( + deps: ForgeExecutionServiceDeps, + frozen: import('../loop/state').LoopState, + original: PreFreezeTerminalSnapshot, + message: string, +): ForgeExecutionResponse { + if (original.status === 'cancelled' || original.status === 'errored' || original.status === 'stalled') { + deps.loop.service.terminate(frozen.loopName, { + status: original.status, + reason: original.terminationReason ?? 'cancelled', + completedAt: original.completedAt ? Date.parse(original.completedAt) : Date.now(), + summary: original.completionSummary, + }) + } else { + relabelInactiveLoop(deps, frozen.loopName, { kind: 'cancelled' }) + } + deps.logger.error(`loop-migrate: migration of "${frozen.loopName}" failed after freeze: ${message}`) + return fail( + 'internal_error', + 502, + `${message}. Local loop "${frozen.loopName}" is restartable with loop-status restart=true.`, + ) +} diff --git a/src/storage/repos/loops-repo.ts b/src/storage/repos/loops-repo.ts index c2d6c46c1..acb2e8fdb 100644 --- a/src/storage/repos/loops-repo.ts +++ b/src/storage/repos/loops-repo.ts @@ -55,6 +55,7 @@ export interface LoopsRepo { getBySessionId(projectId: string, sessionId: string): LoopRow | null listByStatus(projectId: string, statuses: LoopRow['status'][]): LoopRow[] listAll(projectId: string): LoopRow[] + listLoopNames(projectId: string): string[] updatePhase(projectId: string, loopName: string, phase: LoopRow['phase']): void updateIteration(projectId: string, loopName: string, iteration: number): void incrementError(projectId: string, loopName: string): number @@ -265,6 +266,13 @@ export function createLoopsRepo(db: Database): LoopsRepo { WHERE project_id = ? AND status IN ` + const listLoopNamesStmt = db.prepare(` + SELECT loop_name + FROM loops + WHERE project_id = ? + ORDER BY started_at DESC + `) + const updatePhaseStmt = db.prepare(` UPDATE loops SET phase = ? WHERE project_id = ? AND loop_name = ? `) @@ -545,6 +553,11 @@ export function createLoopsRepo(db: Database): LoopsRepo { return this.listByStatus(projectId, allStatuses) }, + listLoopNames(projectId: string): string[] { + const rows = listLoopNamesStmt.all(projectId) as { loop_name: string }[] + return rows.map((row) => row.loop_name) + }, + updatePhase(projectId: string, loopName: string, phase: LoopRow['phase']): void { updatePhaseStmt.run(phase, projectId, loopName) }, diff --git a/src/tools/loop.ts b/src/tools/loop.ts index 7fc30d4ab..7553e398b 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -6,6 +6,7 @@ import { formatSessionOutput, formatAuditResult, formatCompletionSummary, format import { fetchSessionOutput, type LoopSessionOutput, MAX_RETRIES, type LoopState } from '../loop' import { formatDuration, computeElapsedSeconds, resolveUsageFallbackModelLabel, buildAuditorModelChain, auditorModelChoiceAt, usageRoleForPhase } from '../utils/loop-helpers' import { buildStartLoopCommand, createForgeExecutionService, type ForgeExecutionRequestContext, type PlanSource } from '../services/execution' +import type { ForgeExecutionError } from '../services/execution-response' import { resolveSessionPlanOfRecord } from '../services/plan-capture' import { formatLoopSessionTitle, formatPlanSessionTitle } from '../utils/session-titles' import { getRestartability } from '../loop/restartability' @@ -51,6 +52,13 @@ export function createLoopTools(ctx: ToolContext): Record `- ${c}`).join('\n')}` + } + return error.message + } + function makeService(sourceSessionId?: string) { const execCtx: ForgeExecutionRequestContext = { surface: 'tool', @@ -71,6 +79,8 @@ export function createLoopTools(ctx: ToolContext): Record `- ${c}`).join('\n')}` - return result.error.message + return formatExecutionError(result.error) } const d = result.data const branchInfo = d.worktreeBranch ? `\nBranch: ${d.worktreeBranch}` : '' @@ -298,10 +306,7 @@ export function createLoopTools(ctx: ToolContext): Record `- ${c}`).join('\n')}` - return result.error.message + return formatExecutionError(result.error) } const d = result.data const branchInfo = d.worktreeBranch ? `\nBranch: ${d.worktreeBranch}` : '' @@ -625,5 +630,40 @@ export function createLoopTools(ctx: ToolContext): Record { + const { service, execCtx } = makeService() + const result = await service.dispatch(execCtx, { + type: 'loop.migrate', + selector: { kind: 'partial', name: args.name }, + remoteName: args.remote, + }) + if (!result.ok) { + return formatExecutionError(result.error) + } + const d = result.data + const lines: string[] = [ + `Migrated loop "${d.loopName}" to ${d.remoteName}`, + '', + `Remote loop: ${d.remoteLoopName}`, + `Remote session: ${d.remoteSessionId}`, + d.totalSections > 0 + ? `Resumed at: phase ${d.phase}, section ${d.currentSectionIndex + 1}/${d.totalSections}` + : `Resumed at: phase ${d.phase}`, + `Pinned commit: ${d.startRef.slice(0, 7)} (${d.syncRef})`, + ] + for (const warning of result.warnings ?? []) { + lines.push(`Warning: ${warning.message}`) + } + lines.push('', 'The local loop is stopped as migrated; manage it with loop-status on the remote server. Restart it locally only with loop-status restart=true force=true.') + return lines.join('\n') + }, + }), + } } diff --git a/src/utils/git-service.ts b/src/utils/git-service.ts index 32f54ea2e..65d6e12f0 100644 --- a/src/utils/git-service.ts +++ b/src/utils/git-service.ts @@ -1,4 +1,6 @@ import { spawnSync } from 'child_process' +import { runCommand } from '../sandbox/process' +import type { Logger } from '../types' export interface GitResult { ok: boolean @@ -24,6 +26,12 @@ export interface GitService { revParseRef(cwd: string, ref: string): GitResult commitExists(cwd: string, sha: string): boolean push(cwd: string, remote: string, refspec: string, force: boolean): GitResult + /** + * Async, non-blocking counterpart of {@link push} for network pushes. Runs + * through the shared child-process spawner so the opencode server event loop + * is never blocked by a slow remote. + */ + pushAsync(cwd: string, remote: string, refspec: string, force: boolean): Promise fetchRef(cwd: string, remote: string, ref: string): GitResult worktreeAdd(cwd: string, directory: string, branch: string, createBranch: boolean, startPoint?: string): GitResult worktreeList(cwd: string): GitResult @@ -49,6 +57,24 @@ function runGit(args: string[], cwd: string): GitResult { return { ok: res.status === 0, status: res.status ?? -1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' } } +const quietLogger: Logger = { + log: () => {}, + error: () => {}, + debug: () => {}, +} + +const GIT_PUSH_ASYNC_TIMEOUT_MS = 120000 + +async function runGitAsync(args: string[], cwd: string): Promise { + const res = await runCommand('git', [...HOOKS_DISABLED_ARGS, ...args], { + cwd, + logger: quietLogger, + logLabel: 'git', + timeout: GIT_PUSH_ASYNC_TIMEOUT_MS, + }) + return { ok: res.exitCode === 0, status: res.exitCode, stdout: res.stdout, stderr: res.stderr } +} + export function createGitService(): GitService { return { addAll(cwd: string): GitResult { @@ -112,6 +138,11 @@ export function createGitService(): GitService { return runGit(args, cwd) }, + async pushAsync(cwd: string, remote: string, refspec: string, force: boolean): Promise { + const args = force ? ['push', '--force', remote, refspec] : ['push', remote, refspec] + return runGitAsync(args, cwd) + }, + fetchRef(cwd: string, remote: string, ref: string): GitResult { return runGit(['fetch', remote, ref], cwd) }, diff --git a/src/utils/tui-client.ts b/src/utils/tui-client.ts index 04ebc492c..70301e12e 100644 --- a/src/utils/tui-client.ts +++ b/src/utils/tui-client.ts @@ -13,7 +13,7 @@ import { deriveExecutionPreferencesFromWorkspaces } from './tui-execution-prefer import { parseModelString } from './model-fallback' import { listConnectedWorkspaces } from './workspace-listing' import { type ForgeLoopExtra } from '../services/execution' -import { buildLoopPermissionRuleset, type LoopPermissionRulesetOptions } from '../constants/loop' +import { buildSessionPermissionRulesetForAgent, type LoopPermissionRulesetOptions } from '../constants/loop' import { getForgeWorkspaceLoopName, removeExistingForgeLoopWorkspaces, getWorktreeProjectPreconditionError } from '../workspace/forge-worktree' import { classifyWorkspaceCreateThrow } from '../workspace/workspace-create-error' import { fetchLoopsList, fetchStoredSessionPlan } from './tui-loop-store' @@ -248,6 +248,13 @@ function buildTuiLoopInitialPrompt(planText: string): string { }) } +export interface LaunchInitialPrompt { + text: string + agent: 'code' | 'auditor-loop' + model?: { providerID: string; modelID: string } + variant?: string +} + export interface LaunchTuiLoopOptions { client: ForgeClient directory: string | undefined @@ -262,6 +269,13 @@ export interface LaunchTuiLoopOptions { loopNameReserved?: boolean title: string plan: string + /** + * Phase-appropriate override for the first prompt of the launched loop. + * When present, `session.create` receives the matching agent's ruleset and + * `promptAsync` sends exactly this text/agent/model/variant. When absent, + * the deterministic section-0 prompt with the `code` agent is sent. + */ + initialPrompt?: LaunchInitialPrompt executionModel?: string auditorModel?: string executionVariant?: string @@ -345,22 +359,27 @@ export async function launchTuiLoop( } const parsedModel = parseModelString(opts.executionModel) - const permission = buildLoopPermissionRuleset(opts.permissionOptions) + const initial: LaunchInitialPrompt = opts.initialPrompt ?? { + text: buildTuiLoopInitialPrompt(opts.plan), + agent: 'code', + model: parsedModel, + variant: opts.executionVariant, + } + const permission = buildSessionPermissionRulesetForAgent(initial.agent, opts.permissionOptions) const session = await opts.client.session.create({ workspaceID: workspace.id, title: loopName, directory: workspace.directory ?? undefined, permission, }) - const promptText = buildTuiLoopInitialPrompt(opts.plan) const promptInput = { sessionID: session.id, directory: workspace.directory ?? undefined, workspace: workspace.id, - agent: 'code' as const, - parts: [{ type: 'text' as const, text: promptText }], - ...buildPromptModelSelection(parsedModel, opts.executionVariant), + agent: initial.agent, + parts: [{ type: 'text' as const, text: initial.text }], + ...buildPromptModelSelection(initial.model, initial.variant), } try { await opts.client.session.promptAsync(promptInput) @@ -383,6 +402,7 @@ export async function launchTuiLoop( } } catch (err) { debug(`launchTuiLoop: post-create flow failed error=${err instanceof Error ? err.message : String(err)}`) + await opts.client.workspace.remove({ id: workspace.id }).catch(() => undefined) return { error: `Loop launch failed: ${err instanceof Error ? err.message : String(err)}` } } } diff --git a/src/utils/tui-remote-launch.ts b/src/utils/tui-remote-launch.ts index 6e22cf2d1..6d79fc7f9 100644 --- a/src/utils/tui-remote-launch.ts +++ b/src/utils/tui-remote-launch.ts @@ -1,12 +1,13 @@ -import { resolveRemoteServer, listRemoteNames, forgeSyncRef } from './remote-config' -import { defaultGitService, type GitService } from './git-service' +import { resolveRemoteServer, listRemoteNames, forgeSyncRef, type ResolvedRemoteServer } from './remote-config' +import { defaultGitService, type GitService, type GitResult } from './git-service' import { createRemoteForgeClient, type RemoteClientOptions } from '../client/sdk-adapter' import type { ForgeClient } from '../client/port' import type { PluginConfig } from '../types' -import { resolveRemoteLoopPermissionOptions } from '../constants/loop' +import { resolveRemoteLoopPermissionOptions, type LoopPermissionRulesetOptions } from '../constants/loop' import { emitLoopPermissionConfigWarnings } from './loop-permission-warnings' import { resolveDataDir } from './opencode-paths' -import { reserveTuiLoopName, launchTuiLoop } from './tui-client' +import { reserveTuiLoopName, launchTuiLoop, type LaunchInitialPrompt } from './tui-client' +import type { ForgeLoopExtra } from '../services/execution' export interface RemoteLoopRequest { remoteName: string @@ -38,15 +39,29 @@ export type RemoteLaunchResult = | { loopName: string; sessionId: string; remoteName: string } | { error: string } -export async function executeRemoteLoop( - req: RemoteLoopRequest, - deps: RemoteLaunchDeps, -): Promise { - const debug = deps.debug ?? (() => {}) - const git = deps.git ?? defaultGitService - const createClient = deps.createClient ?? createRemoteForgeClient +export type ConnectRemoteProjectResult = + | { remote: ResolvedRemoteServer; project: { id: string; worktree: string }; client: ForgeClient } + | { error: string } - debug(`remote-launch: start remote="${req.remoteName}" dir="${req.localDirectory}" projectId="${req.localProjectId}" loop="${req.loopName}"`) +export interface ConnectRemoteProjectDeps + extends Pick { + /** + * Local preflight gate (e.g. git checks) run after the remote is resolved + * but before any network call, so a caller can keep its error precedence. + */ + beforeDiscovery?: () => { error: string } | void +} + +/** + * Shared remote-connection steps: resolve the configured remote server, + * discover the remote project matching the local OpenCode project id, and + * create the scoped client for the matched worktree. + */ +export async function connectRemoteProject( + req: { remoteName: string; localProjectId: string; localDirectory?: string }, + deps: ConnectRemoteProjectDeps, +): Promise { + const debug = deps.debug ?? (() => {}) // 1. Resolve remote server const remote = resolveRemoteServer(deps.config, req.remoteName) @@ -56,23 +71,8 @@ export async function executeRemoteLoop( } debug(`remote-launch: resolved remote name="${remote.name}" url="${remote.url}" gitRemote="${remote.gitRemote}" sandbox=${remote.sandbox}`) - // 2. Preflight git checks - if (!git.isInsideWorkTree(req.localDirectory)) { - return { error: `Not a git repository: ${req.localDirectory}` } - } - - const headResult = git.revParseHead(req.localDirectory) - if (!headResult.ok) { - return { error: `Failed to resolve HEAD in ${req.localDirectory}: ${headResult.stderr}` } - } - const sha = headResult.stdout.trim() - debug(`remote-launch: preflight ok HEAD=${sha}`) - - // Warn about dirty working tree but proceed - const statusResult = git.statusPorcelain(req.localDirectory) - if (statusResult.ok && statusResult.stdout.trim().length > 0) { - deps.onWarning?.(`Uncommitted changes are not included; remote loop starts from HEAD ${sha.substring(0, 7)}`) - } + const preflight = deps.beforeDiscovery?.() + if (preflight && 'error' in preflight) return preflight // 3. Discovery: find the remote project sharing this repo's OpenCode project // identity. OpenCode derives the same id for a given repo on every server @@ -80,10 +80,10 @@ export async function executeRemoteLoop( // id is location-independent — unlike worktree paths, which differ per // machine (e.g. a local checkout vs. a container workspace). if (!req.localProjectId) { - return { error: `Could not resolve the local OpenCode project id for ${req.localDirectory}; cannot match a remote project.` } + return { error: `Could not resolve the local OpenCode project id for ${req.localDirectory ?? req.remoteName}; cannot match a remote project.` } } - const discoveryClient = createClient({ + const discoveryClient = (deps.createClient ?? createRemoteForgeClient)({ url: remote.url, username: remote.username, password: remote.password, @@ -109,79 +109,246 @@ export async function executeRemoteLoop( debug(`remote-launch: matched project id=${matched.id} worktree="${matched.worktree}"`) // 4. Create scoped client for the matched directory - const remoteClient = createClient({ + const client = (deps.createClient ?? createRemoteForgeClient)({ url: remote.url, username: remote.username, password: remote.password, directory: matched.worktree, }) - // 5. Reserve a unique loop name (once; launchTuiLoop uses it verbatim below) - const finalLoopName = await reserveTuiLoopName(remoteClient, null, req.loopName) - const syncRef = forgeSyncRef(finalLoopName) - debug(`remote-launch: reserved loop name="${finalLoopName}" syncRef="${syncRef}"`) + return { remote, project: { id: matched.id, worktree: matched.worktree }, client } +} + +/** + * Push a local source ref to the shared git remote's forge sync ref. The sync + * ref is never fetched locally, so the push is forced. + */ +export async function pushForgeSyncRef( + git: GitService, + args: { cwd: string; gitRemote: string; sourceRef: string; syncRef: string }, +): Promise<{ ok: true } | { ok: false; error: string }> { + const pushResult = await git.pushAsync(args.cwd, args.gitRemote, `${args.sourceRef}:${args.syncRef}`, true) + if (!pushResult.ok) { + return { ok: false, error: pushResult.stderr || '(no stderr)' } + } + return { ok: true } +} + +/** Delete the forge sync ref on the shared git remote (launch-failure cleanup). */ +export async function deleteForgeSyncRef( + git: GitService, + args: { cwd: string; gitRemote: string; syncRef: string }, +): Promise { + return git.pushAsync(args.cwd, args.gitRemote, `:${args.syncRef}`, false) +} + +export interface PrepareRemoteLoopLaunchInput { + client: ForgeClient + requestedLoopName: string + permissionOptions: LoopPermissionRulesetOptions + config: PluginConfig + dataDir: string + localDirectory: string + debug?: (message: string) => void + onWarnings?: (messages: string[]) => void +} + +export type PrepareRemoteLoopLaunchResult = { loopName: string; syncRef: string } + +export interface PushAndLaunchRemoteLoopInput { + git: GitService + cwd: string + remote: ResolvedRemoteServer + project: { id: string; worktree: string } + client: ForgeClient + loopName: string + syncRef: string + sourceRef: string + startRef: string + title: string + plan: string + executionModel?: string + auditorModel?: string + executionVariant?: string + auditorVariant?: string + permissionOptions: LoopPermissionRulesetOptions + forgeLoopOverrides?: Omit, 'sandboxEnabled'> + initialPrompt?: LaunchInitialPrompt + pushErrorPrefix?: string + debug?: (message: string) => void +} + +export type PushAndLaunchRemoteLoopResult = + | { loopName: string; sessionId: string } + | { error: string; pushed: boolean } + +export async function prepareRemoteLoopLaunch( + input: PrepareRemoteLoopLaunchInput, +): Promise { + const debug = input.debug ?? (() => {}) + const loopName = await reserveTuiLoopName(input.client, null, input.requestedLoopName) + const syncRef = forgeSyncRef(loopName) + debug(`remote-launch: reserved loop name="${loopName}" syncRef="${syncRef}"`) + emitLoopPermissionConfigWarnings(input.config, input.config.dataDir || input.dataDir, input.localDirectory, { + logger: { log: debug, error: debug, debug }, + onWarnings: (warnings) => input.onWarnings?.(warnings), + }) + return { loopName, syncRef } +} + +export async function pushAndLaunchRemoteLoop( + input: PushAndLaunchRemoteLoopInput, +): Promise { + const debug = input.debug ?? (() => {}) + const { remote } = input + + debug(`remote-launch: pushing ${input.sourceRef}:${input.syncRef} to gitRemote="${remote.gitRemote}" from "${input.cwd}"`) + const pushResult = await pushForgeSyncRef(input.git, { + cwd: input.cwd, + gitRemote: remote.gitRemote, + sourceRef: input.sourceRef, + syncRef: input.syncRef, + }) + if (!pushResult.ok) { + debug(`remote-launch: push FAILED: ${pushResult.error}`) + const message = input.pushErrorPrefix + ? `${input.pushErrorPrefix}${pushResult.error}` + : `Failed to push to remote "${remote.name}": ${pushResult.error}` + return { error: message, pushed: false } + } + debug(`remote-launch: push ok (ref ${input.syncRef} now on ${remote.gitRemote})`) + + let launchResult: Awaited> + try { + launchResult = await launchTuiLoop({ + client: input.client, + directory: input.project.worktree, + projectId: input.project.id, + requestedLoopName: input.loopName, + loopNameReserved: true, + connectPollIntervalMs: 500, + title: input.title, + plan: input.plan, + executionModel: input.executionModel, + auditorModel: input.auditorModel, + executionVariant: input.executionVariant, + auditorVariant: input.auditorVariant, + extraWorkspaceFields: { + startRef: input.startRef, + syncRef: input.syncRef, + gitRemote: remote.gitRemote, + permissionRules: input.permissionOptions.extraRules, + }, + forgeLoopOverrides: { + sandboxEnabled: remote.sandbox, + ...input.forgeLoopOverrides, + }, + permissionOptions: input.permissionOptions, + initialPrompt: input.initialPrompt, + debug, + }) + } catch (err) { + debug(`remote-launch: launchTuiLoop THREW: ${err instanceof Error ? err.message : String(err)}`) + const cleanup = await deleteForgeSyncRef(input.git, { cwd: input.cwd, gitRemote: remote.gitRemote, syncRef: input.syncRef }) + debug(`remote-launch: sync ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) + throw err + } + + if ('error' in launchResult) { + debug(`remote-launch: launchTuiLoop FAILED: ${launchResult.error}`) + const cleanup = await deleteForgeSyncRef(input.git, { cwd: input.cwd, gitRemote: remote.gitRemote, syncRef: input.syncRef }) + debug(`remote-launch: sync ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) + return { error: launchResult.error, pushed: false } + } + + debug(`remote-launch: launched loop="${launchResult.loopName}" session=${launchResult.sessionId} on "${remote.name}"`) + return { loopName: launchResult.loopName, sessionId: launchResult.sessionId } +} + +export async function executeRemoteLoop( + req: RemoteLoopRequest, + deps: RemoteLaunchDeps, +): Promise { + const debug = deps.debug ?? (() => {}) + const git = deps.git ?? defaultGitService + + debug(`remote-launch: start remote="${req.remoteName}" dir="${req.localDirectory}" projectId="${req.localProjectId}" loop="${req.loopName}"`) + + let sha = '' + + // 1+3+4. Resolve the remote server, discover the matching remote project, and + // create the scoped client. The git preflight is threaded through as a gate + // so the error precedence (unknown remote → git checks → discovery) is kept. + const connected = await connectRemoteProject( + { remoteName: req.remoteName, localProjectId: req.localProjectId, localDirectory: req.localDirectory }, + { + config: deps.config, + createClient: deps.createClient, + debug, + beforeDiscovery: () => { + if (!git.isInsideWorkTree(req.localDirectory)) { + return { error: `Not a git repository: ${req.localDirectory}` } + } + const headResult = git.revParseHead(req.localDirectory) + if (!headResult.ok) { + return { error: `Failed to resolve HEAD in ${req.localDirectory}: ${headResult.stderr}` } + } + sha = headResult.stdout.trim() + debug(`remote-launch: preflight ok HEAD=${sha}`) + return undefined + }, + }, + ) + if ('error' in connected) return connected + + const { remote, project: matched, client: remoteClient } = connected + + // Warn about dirty working tree but proceed + const statusResult = git.statusPorcelain(req.localDirectory) + if (statusResult.ok && statusResult.stdout.trim().length > 0) { + deps.onWarning?.(`Uncommitted changes are not included; remote loop starts from HEAD ${sha.substring(0, 7)}`) + } // Resolve the portable configured rules once for both the persisted workspace // field and the launch options. Host-specific directory grants stay omitted // because they do not exist on the remote machine. const remotePermissionOptions = resolveRemoteLoopPermissionOptions(deps.config) - emitLoopPermissionConfigWarnings(deps.config, deps.config.dataDir || resolveDataDir(), req.localDirectory, { - logger: { log: debug, error: debug, debug }, + const prepared = await prepareRemoteLoopLaunch({ + client: remoteClient, + requestedLoopName: req.loopName, + permissionOptions: remotePermissionOptions, + config: deps.config, + dataDir: deps.config.dataDir || resolveDataDir(), + localDirectory: req.localDirectory, + debug, onWarnings: (warnings) => deps.onWarning?.(warnings.join(' ')), }) - // 6. Push HEAD to remote ref - debug(`remote-launch: pushing HEAD:${syncRef} to gitRemote="${remote.gitRemote}" from "${req.localDirectory}"`) - const pushResult = git.push(req.localDirectory, remote.gitRemote, `HEAD:${syncRef}`, true) - if (!pushResult.ok) { - debug(`remote-launch: push FAILED: ${pushResult.stderr || '(no stderr)'}`) - return { error: `Failed to push to remote "${remote.name}": ${pushResult.stderr || '(no stderr)'}` } - } - debug(`remote-launch: push ok (ref ${syncRef} now on ${remote.gitRemote})`) - - // 7. Launch the remote loop - const launchResult = await launchTuiLoop({ + const launched = await pushAndLaunchRemoteLoop({ + git, + cwd: req.localDirectory, + remote, + project: matched, client: remoteClient, - directory: matched.worktree, - projectId: matched.id, - requestedLoopName: finalLoopName, - loopNameReserved: true, - connectPollIntervalMs: 500, + loopName: prepared.loopName, + syncRef: prepared.syncRef, + sourceRef: 'HEAD', + startRef: sha, title: req.title, plan: req.plan, executionModel: req.executionModel, auditorModel: req.auditorModel, executionVariant: req.executionVariant, auditorVariant: req.auditorVariant, - extraWorkspaceFields: { - startRef: sha, - syncRef, - gitRemote: remote.gitRemote, - // Persist the portable configured rules so every subsequent session of the - // remote loop (rotations, audits, post-actions) keeps them: those sessions - // rebuild rulesets from the remote server's own config, which lacks the - // launching machine's loop.permissions. - permissionRules: remotePermissionOptions.extraRules, - }, - forgeLoopOverrides: { - sandboxEnabled: remote.sandbox, - }, permissionOptions: remotePermissionOptions, debug, }) + if ('error' in launched) return { error: launched.error } - if ('error' in launchResult) { - debug(`remote-launch: launchTuiLoop FAILED: ${launchResult.error}`) - const cleanup = git.push(req.localDirectory, remote.gitRemote, `:${syncRef}`, false) - debug(`remote-launch: sync ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`) - return { error: launchResult.error } - } - - debug(`remote-launch: launched loop="${launchResult.loopName}" session=${launchResult.sessionId} on "${remote.name}"`) return { - loopName: launchResult.loopName, - sessionId: launchResult.sessionId, + loopName: launched.loopName, + sessionId: launched.sessionId, remoteName: remote.name, } } diff --git a/src/workspace/forge-adapter.ts b/src/workspace/forge-adapter.ts index 48f69a8c0..739bcc257 100644 --- a/src/workspace/forge-adapter.ts +++ b/src/workspace/forge-adapter.ts @@ -8,6 +8,7 @@ import { forgeBranchName, forgeWorktreeDir, forgeWorktreeSlug } from './forge-na import { cleanupLoopWorktree } from '../utils/worktree-cleanup' import { defaultGitService, type GitService } from '../utils/git-service' import { forgeSyncRef, DEFAULT_GIT_REMOTE } from '../utils/remote-config' +import { deleteForgeSyncRef } from '../utils/tui-remote-launch' import { writeWorktreeOpencodeConfig, WORKTREE_OPENCODE_CONFIG_FILENAME } from './worktree-opencode-config' import { commitWorktreeChanges } from './worktree-commit' import { sandboxContainerName } from '../sandbox/msb' @@ -194,13 +195,13 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd * commit locally, so the shared ref is no longer needed. Restart-preserving * teardowns keep the ref in place. */ - function stepDeleteSyncRef(info: WorkspaceInfo, loopName: string, ctx: TeardownContext): void { + async function stepDeleteSyncRef(info: WorkspaceInfo, loopName: string, ctx: TeardownContext): Promise { if (!ctx.doRemoveWorktree) return const pin = deriveSyncPin(info, loopName) if (!pin) return try { const projectDir = deriveProjectDirectory(info) - const res = git.push(projectDir, pin.gitRemote, `:${pin.syncRef}`, false) + const res = await deleteForgeSyncRef(git, { cwd: projectDir, gitRemote: pin.gitRemote, syncRef: pin.syncRef }) if (res.ok) { logger.log(`forge-adapter: deleted sync ref ${pin.syncRef} on ${pin.gitRemote}`) } else { @@ -369,7 +370,7 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd await stepRemoveWorktree(info.directory, ctx) // Remote-launched loops: drop the sync ref from the shared git remote. - stepDeleteSyncRef(info, loopName, ctx) + await stepDeleteSyncRef(info, loopName, ctx) // Branches are never deleted — `forge/*` scratch branches stay in place for potential restart. }, diff --git a/test/config-commands.test.ts b/test/config-commands.test.ts index e610603a3..ea4a61d92 100644 --- a/test/config-commands.test.ts +++ b/test/config-commands.test.ts @@ -62,6 +62,12 @@ describe('createConfigHandler commands', () => { expect(loopCancel).toBeDefined() expect(loopCancel.template).toContain('Identify the Loop') expect(loopCancel.agent).toBe('code') + + const loopMigrate = commands['loop-migrate'] + expect(loopMigrate).toBeDefined() + expect(loopMigrate.template).toContain('Identify the Loop') + expect(loopMigrate.agent).toBe('code') + expect(loopMigrate.subtask).toBe(false) }) test('user command template overrides via promptsDir', async () => { diff --git a/test/config.test.ts b/test/config.test.ts index 497358db1..565d6cb8f 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -132,7 +132,7 @@ describe('createConfigHandler', () => { expect(architectAutoPermission.question).toBe('deny') expect(architectAutoTools.question).toBe(false) - for (const tool of ['execute-plan', 'execute-goal', 'launch-group', 'group-status', 'group-cancel', 'loop-status', 'loop-cancel']) { + for (const tool of ['execute-plan', 'execute-goal', 'launch-group', 'group-status', 'group-cancel', 'loop-status', 'loop-cancel', 'loop-migrate']) { expect(architectTools[tool]).toBeUndefined() expect(architectPermission[tool]).toBeUndefined() expect(architectAutoTools[tool]).toBe(false) diff --git a/test/constants/loop.test.ts b/test/constants/loop.test.ts index ffdcc1363..e7b6c635f 100644 --- a/test/constants/loop.test.ts +++ b/test/constants/loop.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, resolveLoopAllowedDirectories, resolveLoopPermissionOptions, MAX_TOTAL_SECTIONS, PLAN_AUTHORING_TOOL_NAMES, FORGE_MANAGED_PERMISSIONS } from '../../src/constants/loop' +import { buildLoopPermissionRuleset, buildAuditSessionPermissionRuleset, buildSessionPermissionRulesetForAgent, resolveLoopAllowedDirectories, resolveLoopPermissionOptions, MAX_TOTAL_SECTIONS, PLAN_AUTHORING_TOOL_NAMES, FORGE_MANAGED_PERMISSIONS } from '../../src/constants/loop' import { resolveOpencodeToolOutputDir, resolveOpencodeTmpDir } from '../../src/utils/opencode-paths' const TOOL_OUTPUT_DIR = resolveOpencodeToolOutputDir() @@ -53,12 +53,20 @@ describe('buildLoopPermissionRuleset', () => { { permission: 'question', pattern: '*', action: 'deny' }, { permission: 'loop-cancel', pattern: '*', action: 'deny' }, { permission: 'loop-status', pattern: '*', action: 'deny' }, + { permission: 'loop-migrate', pattern: '*', action: 'deny' }, { permission: 'launch-group', pattern: '*', action: 'deny' }, { permission: 'group-status', pattern: '*', action: 'deny' }, { permission: 'group-cancel', pattern: '*', action: 'deny' }, ]) }) + it('denies loop-migrate in both loop and audit rulesets so active sessions cannot migrate themselves', () => { + const loopRules = buildLoopPermissionRuleset() + const auditRules = buildAuditSessionPermissionRuleset() + expect(loopRules).toContainEqual({ permission: 'loop-migrate', pattern: '*', action: 'deny' }) + expect(auditRules).toContainEqual({ permission: 'loop-migrate', pattern: '*', action: 'deny' }) + }) + it('denies execute-goal in both loop and audit rulesets so active sessions cannot recurse', () => { const loopRules = buildLoopPermissionRuleset() const auditRules = buildAuditSessionPermissionRuleset() @@ -249,3 +257,20 @@ describe('config -> resolveLoopPermissionOptions -> ruleset composition', () => }) } }) + +describe('buildSessionPermissionRulesetForAgent', () => { + const options = { allowDirectories: ['/vault'], extraRules: [{ permission: 'webfetch', pattern: '*', action: 'deny' as const }] } + + it('auditor-loop agent gets exactly the audit ruleset', () => { + expect(buildSessionPermissionRulesetForAgent('auditor-loop', options)).toEqual(buildAuditSessionPermissionRuleset(options)) + }) + + it('code agent gets exactly the loop ruleset', () => { + expect(buildSessionPermissionRulesetForAgent('code', options)).toEqual(buildLoopPermissionRuleset(options)) + }) + + it('defaults options like the underlying builders when omitted', () => { + expect(buildSessionPermissionRulesetForAgent('code')).toEqual(buildLoopPermissionRuleset()) + expect(buildSessionPermissionRulesetForAgent('auditor-loop')).toEqual(buildAuditSessionPermissionRuleset()) + }) +}) diff --git a/test/helpers/fake-git.ts b/test/helpers/fake-git.ts index c7855ef28..bea3139dc 100644 --- a/test/helpers/fake-git.ts +++ b/test/helpers/fake-git.ts @@ -19,6 +19,9 @@ export function createFakeGitService(overrides?: Partial): GitServic revParseRef: vi.fn<[string, string], GitResult>(() => ({ ...defaultOk })), commitExists: vi.fn<[string, string], boolean>(() => false), push: vi.fn<[string, string, string, boolean], GitResult>(() => ({ ...defaultOk })), + pushAsync: vi.fn<[string, string, string, boolean], Promise>(async function (this: { push: (cwd: string, remote: string, refspec: string, force: boolean) => GitResult }, cwd, remote, refspec, force) { + return this.push(cwd, remote, refspec, force) + }), fetchRef: vi.fn<[string, string, string], GitResult>(() => ({ ...defaultOk })), worktreeAdd: vi.fn<[string, string, string, boolean, string?], GitResult>(() => ({ ...defaultOk })), worktreeList: vi.fn<[string], GitResult>(() => ({ ...defaultOk })), diff --git a/test/helpers/fake-remote-client.ts b/test/helpers/fake-remote-client.ts new file mode 100644 index 000000000..d13e6e3f8 --- /dev/null +++ b/test/helpers/fake-remote-client.ts @@ -0,0 +1,49 @@ +/** + * Shared remote-loop test fakes: a `ForgeClient` with remote-launch flow + * defaults (fixed workspace/session ids, `connected` workspace status, one + * matching project) plus a `vi.fn()` client factory that records produced + * clients. Used by both `test/utils/tui-remote-launch.test.ts` and + * `test/services/loop-migration.test.ts`. + */ + +import { vi } from 'vitest' +import type { ForgeClient } from '../../src/client/port' +import type { RemoteClientOptions } from '../../src/client/sdk-adapter' +import { createFakeForgeClient } from './fake-client' + +export const REMOTE_URL = 'http://remote:4096' +export const LOCAL_PROJECT_ID = 'proj_1' +export const REMOTE_PROJECT_WORKTREE = '/remote/my-project' +export const REMOTE_SESSION_ID = 'sess_remote' +export const REMOTE_WORKSPACE_ID = 'ws_remote' + +export function makeFakeRemoteClient(overrides?: Parameters[0]): ForgeClient { + const { client } = createFakeForgeClient({ + session: { + create: async () => ({ id: REMOTE_SESSION_ID }), + }, + workspace: { + create: async () => ({ id: REMOTE_WORKSPACE_ID, directory: REMOTE_PROJECT_WORKTREE, branch: null }), + status: async () => [{ workspaceID: REMOTE_WORKSPACE_ID, status: 'connected' }], + }, + project: { + list: async () => [{ id: LOCAL_PROJECT_ID, worktree: REMOTE_PROJECT_WORKTREE }], + }, + ...overrides, + }) + return client +} + +/** Create a `vi.fn()` based `createClient` factory that records produced clients. */ +export function createClientSpy(overrides?: Parameters[0]): { + spy: ReturnType + clients: ForgeClient[] +} { + const clients: ForgeClient[] = [] + const spy = vi.fn((_opts: RemoteClientOptions) => { + const client = makeFakeRemoteClient(overrides) + clients.push(client) + return client + }) + return { spy, clients } +} diff --git a/test/hooks/forge-session-attach.test.ts b/test/hooks/forge-session-attach.test.ts index 3271f5c5d..4fa8ba9d7 100644 --- a/test/hooks/forge-session-attach.test.ts +++ b/test/hooks/forge-session-attach.test.ts @@ -1311,6 +1311,189 @@ describe('createForgeSessionAttachHook', () => { expect(mockAttachLoop).not.toHaveBeenCalled() }) + test('chat.message fallback forwards resume snapshot without stored-plan lookup', async () => { + const resume = { + version: 1 as const, + kind: 'goal' as const, + goal: 'ship it', + phase: 'coding' as const, + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + sections: [], + findings: [], + } + const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'SHOULD_NOT_BE_USED' }) + const deps = buildHookDeps({ + sessionGet: vi.fn().mockResolvedValue({ + id: 'new_sess', + workspaceID: 'ws_resume', + directory: '/tmp/wt/resume', + projectID: 'proj_1', + }), + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_resume', + type: 'forge', + directory: '/tmp/wt/resume', + extra: { + loopName: 'resume-loop', + projectDirectory: '/tmp/wt/resume', + forgeLoop: { + initialPromptOwner: 'tui', + pendingAttachStartedAt: Date.now(), + planSource: 'inline', + planText: '', + maxIterations: 12, + resume, + }, + }, + }, + ]), + plansRepoGetForSession, + loopsRepoGet: vi.fn().mockReturnValue(null), + }) + + const handler = createForgeSessionMessageAttachHook(deps as any) + + await handler({ sessionID: 'new_sess' }) + + expect(mockAttachLoop).toHaveBeenCalledTimes(1) + const [, , input] = mockAttachLoop.mock.calls[0] + expect(input.resume).toEqual(resume) + expect(input.maxIterations).toBe(12) + expect(input.planText).toBe('') + expect(input.sendInitialPrompt).toBe(false) + expect(plansRepoGetForSession).not.toHaveBeenCalled() + }) + + test('chat.message fallback removes workspace and publishes toast when resume snapshot is invalid', async () => { + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const tuiPublish = vi.fn().mockResolvedValue(undefined) + const loggerErrorSpy = vi.fn() + const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'SHOULD_NOT_BE_USED' }) + const deps = buildHookDeps({ + sessionGet: vi.fn().mockResolvedValue({ + id: 'new_sess', + workspaceID: 'ws_bad_resume', + directory: '/tmp/wt/bad-resume', + projectID: 'proj_1', + }), + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_bad_resume', + type: 'forge', + directory: '/tmp/wt/bad-resume', + extra: { + loopName: 'bad-resume-loop', + projectDirectory: '/tmp/wt/bad-resume', + forgeLoop: { + initialPromptOwner: 'tui', + pendingAttachStartedAt: Date.now(), + planSource: 'inline', + planText: '', + maxIterations: 12, + resume: { version: 2 }, + }, + }, + }, + ]), + plansRepoGetForSession, + loopsRepoGet: vi.fn().mockReturnValue(null), + workspaceRemove, + tuiPublish, + loggerErrorSpy, + }) + + const handler = createForgeSessionMessageAttachHook(deps as any) + + await handler({ sessionID: 'new_sess' }) + + expect(mockAttachLoop).not.toHaveBeenCalled() + expect(plansRepoGetForSession).not.toHaveBeenCalled() + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.stringContaining('invalid resume snapshot')) + expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws_bad_resume' }) + expect(deps.execDeps.pendingTeardowns.set).toHaveBeenCalledWith( + 'bad-resume-loop', + expect.objectContaining({ doRemoveWorktree: true, doCommit: false }), + ) + expect(tuiPublish).toHaveBeenCalledWith(expect.objectContaining({ + directory: '/tmp/wt/bad-resume', + body: expect.objectContaining({ + type: 'tui.toast.show', + properties: expect.objectContaining({ + title: 'Forge loop "bad-resume-loop"', + message: expect.stringContaining('Migrated loop snapshot is invalid'), + variant: 'error', + }), + }), + })) + }) + + test('terminal restartable toast mentions remote server when terminationReason kind is migrated', async () => { + const tuiPublish = vi.fn().mockResolvedValue(undefined) + const workspaceRemove = vi.fn().mockResolvedValue(undefined) + const loopsRepoGetMock = vi.fn().mockReturnValue({ + projectId: 'proj_1', + loopName: 'migrated-row-loop', + status: 'cancelled', + terminationReason: 'migrated: https://remote.example.com', + }) + const deps = buildHookDeps({ + workspaceList: vi.fn().mockResolvedValue([ + { + id: 'ws_migrated_row', + type: 'forge', + directory: '/tmp/wt/migrated-row', + extra: { + loopName: 'migrated-row-loop', + projectDirectory: '/tmp/wt/migrated-row', + forgeLoop: { + title: 'Migrated Row', + planSource: 'inline', + planText: '# Plan', + }, + }, + }, + ]), + loopsRepoGet: loopsRepoGetMock, + workspaceRemove, + tuiPublish, + }) + + const handler = createForgeSessionAttachHook(deps as any) + + await handler({ + event: { + type: 'session.created', + properties: { + info: { id: 'sess_migrated_row', workspaceID: 'ws_migrated_row' }, + }, + }, + }) + + expect(mockAttachLoop).not.toHaveBeenCalled() + expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws_migrated_row' }) + expect(deps.execDeps.pendingTeardowns.set).toHaveBeenCalledWith( + 'migrated-row-loop', + expect.objectContaining({ doRemoveWorktree: false, doCommit: false }), + ) + expect(tuiPublish).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + properties: expect.objectContaining({ + message: expect.stringContaining('was migrated to remote "https://remote.example.com"'), + }), + }), + })) + expect(tuiPublish).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + properties: expect.objectContaining({ + message: expect.stringContaining('loop-status restart=true force=true'), + }), + }), + })) + }) + test('attach hook prefers inline planText over stored plan when both are available', async () => { const plansRepoGetForSession = vi.fn().mockReturnValue({ content: 'STALE_PRIOR_PLAN_TEXT' }) const deps = buildHookDeps({ diff --git a/test/hooks/loop-section-advancement.test.ts b/test/hooks/loop-section-advancement.test.ts index 011cbcbf0..846f0aa04 100644 --- a/test/hooks/loop-section-advancement.test.ts +++ b/test/hooks/loop-section-advancement.test.ts @@ -606,6 +606,7 @@ describe('Loop Section Advancement', () => { return { ...ok } }, isPathTracked: () => false, + pushAsync: async () => ({ ...ok }), } as unknown as import('../../src/utils/git-service').GitService return { git, commits } } diff --git a/test/loop-permission-ruleset.test.ts b/test/loop-permission-ruleset.test.ts index b25252c95..d9630720f 100644 --- a/test/loop-permission-ruleset.test.ts +++ b/test/loop-permission-ruleset.test.ts @@ -41,6 +41,7 @@ describe('buildLoopPermissionRuleset', () => { { permission: 'question', pattern: '*', action: 'deny' }, { permission: 'loop-cancel', pattern: '*', action: 'deny' }, { permission: 'loop-status', pattern: '*', action: 'deny' }, + { permission: 'loop-migrate', pattern: '*', action: 'deny' }, { permission: 'launch-group', pattern: '*', action: 'deny' }, { permission: 'group-status', pattern: '*', action: 'deny' }, { permission: 'group-cancel', pattern: '*', action: 'deny' }, @@ -131,6 +132,7 @@ describe('buildAuditSessionPermissionRuleset', () => { expect(rules.some(r => r.permission === 'question' && r.pattern === '*' && r.action === 'deny')).toBe(true) expect(rules.some(r => r.permission === 'loop-cancel' && r.pattern === '*' && r.action === 'deny')).toBe(true) expect(rules.some(r => r.permission === 'loop-status' && r.pattern === '*' && r.action === 'deny')).toBe(true) + expect(rules.some(r => r.permission === 'loop-migrate' && r.pattern === '*' && r.action === 'deny')).toBe(true) // Plan-authoring tools: auditor's sanctioned path is plan-adjust, never plan-write/plan-edit. expect(rules.some(r => r.permission === 'plan-write' && r.pattern === '*' && r.action === 'deny')).toBe(true) expect(rules.some(r => r.permission === 'plan-edit' && r.pattern === '*' && r.action === 'deny')).toBe(true) diff --git a/test/loop/restartability.test.ts b/test/loop/restartability.test.ts index 70ea1bd9f..0668dfca7 100644 --- a/test/loop/restartability.test.ts +++ b/test/loop/restartability.test.ts @@ -93,4 +93,17 @@ describe('getRestartability', () => { expect(result.restartRequiresForce).toBe(true) expect(result.restartBlockedReason).toBe('active_requires_force') }) + + it('allows force-restart of a loop migrated to a remote server', () => { + const state = makeState({ terminationReason: 'migrated: server1' }) + const result = getRestartability(state, { + worktreeExists: () => true, + branchExists: () => true, + }) + expect(result.restartable).toBe(true) + expect(result.restartRequiresForce).toBe(true) + expect(result.restartBlockedReason).toBe('migrated') + expect(result.restartBlockedMessage).toContain('server1') + expect(result.restartBlockedMessage).toContain('force=true') + }) }) diff --git a/test/loop/resume-prompt.test.ts b/test/loop/resume-prompt.test.ts new file mode 100644 index 000000000..9cd2d1e58 --- /dev/null +++ b/test/loop/resume-prompt.test.ts @@ -0,0 +1,198 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createLoopsRepo } from '../../src/storage/repos/loops-repo' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' +import { createReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createLoopService, type LoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import type { PluginConfig } from '../../src/types' +import type { LoopState } from '../../src/loop/state' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { buildResumePromptPlan, resolveResumeAgent } from '../../src/loop/resume-prompt' + +const PROJECT_ID = 'test-project' +const noopLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } + +describe('buildResumePromptPlan', () => { + let db: Database + let service: LoopService + + beforeEach(() => { + const tempDir = mkdtempSync(join(tmpdir(), 'resume-prompt-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + service = createLoopService( + createLoopsRepo(db), + createPlansRepo(db), + createReviewFindingsRepo(db), + PROJECT_ID, + noopLogger, + undefined, + undefined, + createSectionPlansRepo(db), + ) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function seedLoop(state: Partial & Pick): LoopState { + const base: LoopState = { + active: false, + sessionId: 'session-old', + worktreeDir: '/tmp/wt', + projectDir: '/tmp', + iteration: 3, + maxIterations: 10, + startedAt: new Date().toISOString(), + errorCount: 0, + auditCount: 0, + status: 'stalled', + worktree: false, + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + ...state, + } as LoopState + service.setState(state.loopName, base) + service.setStatus(state.loopName, 'stalled') + return base + } + + function seedSections(loopName: string, total: number, inProgressIndex: number): void { + const sectionPlansRepo = createSectionPlansRepo(db) + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName, + sections: Array.from({ length: total }, (_, i) => ({ index: i, title: `S${i}`, content: `Section ${i} content` })), + }) + for (let i = 0; i < total; i++) { + sectionPlansRepo.setStatus(PROJECT_ID, loopName, i, i < inProgressIndex ? 'completed' : i === inProgressIndex ? 'in_progress' : 'pending') + } + } + + function seedLoopWithSections( + state: Partial & Pick, + totalSections: number, + ): LoopState { + // currentSectionIndex defaults to 0 (first section still in progress), matching a loop + // stopped mid-section; sections are seeded after the loop row (FK enforcement). + const seeded = seedLoop({ ...state, totalSections, currentSectionIndex: state.currentSectionIndex ?? 0 }) + seedSections(state.loopName, totalSections, state.currentSectionIndex ?? 0) + return seeded + } + + const baseConfig: PluginConfig = { + loop: { enabled: true }, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + } + + test('persisted auditing resumes as coding with the section-initial prompt', () => { + const state = seedLoopWithSections({ loopName: 'sectioned-auditing', phase: 'auditing' }, 2) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('coding') + expect(plan.agent).toBe('code') + expect(plan.promptText).toBe(service.buildSectionInitialPrompt(state)) + expect(plan.promptText).toContain('Section 0 content') + }) + + test('legacy non-sectioned prompt falls back to the persisted prompt text', () => { + seedLoop({ loopName: 'legacy-auditing', phase: 'auditing', prompt: 'Legacy plan text' }) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState('legacy-auditing')! }) + + expect(plan.phase).toBe('coding') + expect(plan.agent).toBe('code') + expect(plan.promptText).toBe('Legacy plan text') + }) + + test('persisted final_auditing resumes the final audit with the auditor agent and audit ruleset', () => { + const state = seedLoopWithSections({ loopName: 'final-audit-loop', phase: 'final_auditing' }, 3) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('final_auditing') + expect(plan.agent).toBe('auditor-loop') + expect(resolveResumeAgent(plan.phase)).toBe('auditor-loop') + expect(plan.promptText).toBe(service.buildFinalAuditPrompt(service.getAnyState(state.loopName)!)) + expect(plan.model).toEqual({ providerID: 'prov', modelID: 'aud' }) + }) + + test('persisted final_audit_fix resumes the fix pass as coding with the fix prompt', () => { + const state = seedLoop({ loopName: 'fix-loop', phase: 'final_audit_fix', prompt: 'Fix me' }) as LoopState & { prompt?: string } + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('coding') + expect(plan.agent).toBe('code') + expect(plan.promptText).toBe(service.buildFinalAuditFixPrompt(state, service.getOutstandingFindings(state.loopName, 'bug'))) + }) + + test('goal loop resumes with the continuation prompt restating the goal', () => { + const state = seedLoop({ loopName: 'goal-loop', phase: 'auditing', kind: 'goal', goal: 'Ship the endpoint.' }) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('coding') + expect(plan.agent).toBe('code') + expect(plan.promptText).toBe(service.buildContinuationPrompt(state, undefined)) + expect(plan.promptText).toContain('Ship the endpoint.') + }) + + test('post_action with postAction enabled uses the post-action phase, config skill, and configured model with auditor fallback', () => { + const state = seedLoopWithSections({ loopName: 'pa-loop', phase: 'post_action' }, 2) + + const config: PluginConfig = { + loop: { enabled: true, postAction: { enabled: true, skill: 'pr-review', model: 'prov/pa' } }, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + } + + const plan = buildResumePromptPlan({ service, config, state: service.getAnyState(state.loopName)! }) + + expect(plan.phase).toBe('post_action') + expect(plan.agent).toBe('code') + expect(plan.promptText).toBe(service.buildPostActionPrompt(service.getAnyState(state.loopName)!, { skill: 'pr-review', prompt: undefined })) + expect(plan.model).toEqual({ providerID: 'prov', modelID: 'pa' }) + expect(plan.fallbackModel).toEqual({ providerID: 'prov', modelID: 'aud' }) + }) + + test('unset state.auditorModel falls back to config.auditorModel and normalizes whitespace', () => { + const state = seedLoopWithSections({ loopName: 'aud-model-loop', phase: 'final_auditing', auditorModel: ' prov/norm ' }, 2) + + const plan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(state.loopName)! }) + + expect(plan.model).toEqual({ providerID: 'prov', modelID: 'norm' }) + expect(plan.auditorModel).toBe('prov/norm') + }) + + test('variant: auditor phase uses the persisted auditor variant, coding phases keep the execution variant', () => { + const auditState = seedLoopWithSections({ loopName: 'variant-loop', phase: 'final_auditing', executionVariant: 'exec-v', auditorVariant: 'audit-v' }, 2) + + const auditPlan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(auditState.loopName)! }) + expect(auditPlan.variant).toBe('audit-v') + + const codeState = seedLoop({ loopName: 'variant-code-loop', phase: 'coding', executionVariant: 'exec-v', auditorVariant: 'audit-v', prompt: 'Legacy plan' }) + const codePlan = buildResumePromptPlan({ service, config: baseConfig, state: service.getAnyState(codeState.loopName)! }) + expect(codePlan.variant).toBe('exec-v') + }) +}) + +describe('resolveResumeAgent', () => { + test('final_auditing maps to the auditor-loop agent', () => { + expect(resolveResumeAgent('final_auditing')).toBe('auditor-loop') + }) + + test('coding and post_action map to the code agent', () => { + expect(resolveResumeAgent('coding')).toBe('code') + expect(resolveResumeAgent('post_action')).toBe('code') + }) +}) diff --git a/test/loop/resume-snapshot.test.ts b/test/loop/resume-snapshot.test.ts new file mode 100644 index 000000000..7def3c5e9 --- /dev/null +++ b/test/loop/resume-snapshot.test.ts @@ -0,0 +1,405 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createLoopsRepo } from '../../src/storage/repos/loops-repo' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' +import { createReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import type { SectionPlanRow } from '../../src/storage/repos/section-plans-repo' +import type { ReviewFindingRow } from '../../src/storage/repos/review-findings-repo' +import { createLoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import type { LoopState } from '../../src/loop/state' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { + captureLoopResumeSnapshot, + restoreLoopResumeRows, + isLoopResumeSnapshot, +} from '../../src/loop/resume-snapshot' + +const PROJECT_ID = 'test-project' +const noopLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } + +describe('captureLoopResumeSnapshot', () => { + let db: Database + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'resume-snapshot-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function buildRepos() { + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const sectionPlansRepo = createSectionPlansRepo(db) + const loopService = createLoopService( + loopsRepo, + plansRepo, + reviewFindingsRepo, + PROJECT_ID, + noopLogger, + undefined, + undefined, + sectionPlansRepo, + ) + return { loopsRepo, plansRepo, reviewFindingsRepo, sectionPlansRepo, loopService } + } + + function seedStoppedLoop(sectionPlansRepo: ReturnType): LoopState { + const state: LoopState = { + active: false, + sessionId: 'sess_old', + loopName: 'my-loop', + worktreeDir: '/tmp/wt', + projectDir: '/tmp', + iteration: 4, + maxIterations: 10, + startedAt: new Date().toISOString(), + prompt: '# My Plan', + phase: 'auditing', + errorCount: 1, + auditCount: 2, + status: 'cancelled', + worktree: false, + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + } + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const service = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, PROJECT_ID, noopLogger, undefined, undefined, sectionPlansRepo) + service.setState('my-loop', state) + service.setStatus('my-loop', 'cancelled') + + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName: 'my-loop', + sections: [ + { index: 0, title: 'Setup', content: 'Do setup' }, + { index: 1, title: 'Build', content: 'Do build' }, + { index: 2, title: 'Ship', content: 'Do ship' }, + ], + }) + sectionPlansRepo.setStatus(PROJECT_ID, 'my-loop', 0, 'completed') + sectionPlansRepo.setSummary(PROJECT_ID, 'my-loop', 0, { done: 'setup done' }) + sectionPlansRepo.setStatus(PROJECT_ID, 'my-loop', 1, 'in_progress') + sectionPlansRepo.incrementAttempts(PROJECT_ID, 'my-loop', 1) + sectionPlansRepo.incrementAttempts(PROJECT_ID, 'my-loop', 1) + + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName: 'my-loop', + file: 'a.ts', + line: 10, + severity: 'bug', + description: 'section finding', + sectionIndex: 1, + }) + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName: 'my-loop', + file: 'b.ts', + line: 20, + severity: 'warning', + description: 'cross-section finding', + }) + + return state + } + + test('captures phase pointers, section rows, and findings without host scoping', () => { + const { sectionPlansRepo, reviewFindingsRepo } = buildRepos() + const state = seedStoppedLoop(sectionPlansRepo) + + const snapshot = captureLoopResumeSnapshot({ + projectId: PROJECT_ID, + state, + sectionPlansRepo, + reviewFindingsRepo, + }) + + expect(snapshot.version).toBe(1) + expect(snapshot.kind).toBe('plan') + expect(snapshot.phase).toBe('coding') + expect(snapshot.currentSectionIndex).toBe(1) + expect(snapshot.totalSections).toBe(3) + expect(snapshot.finalAuditDone).toBe(false) + expect(snapshot.goal).toBeUndefined() + + expect(snapshot.sections).toHaveLength(3) + const byIndex = new Map(snapshot.sections.map((s) => [s.sectionIndex, s])) + expect(byIndex.get(0)!.status).toBe('completed') + expect(byIndex.get(0)!.summaryDone).toBe('setup done') + expect(byIndex.get(1)!.status).toBe('in_progress') + expect(byIndex.get(1)!.attempts).toBe(2) + expect(byIndex.get(2)!.status).toBe('pending') + for (const section of snapshot.sections) { + expect(section).not.toHaveProperty('projectId') + expect(section).not.toHaveProperty('loopName') + expect(section).not.toHaveProperty('createdAt') + } + + expect(snapshot.findings).toHaveLength(2) + const finding = snapshot.findings.find((f) => f.file === 'a.ts')! + expect(finding.sectionIndex).toBe(1) + expect(finding.severity).toBe('bug') + const cross = snapshot.findings.find((f) => f.file === 'b.ts')! + expect(cross.sectionIndex).toBeNull() + for (const f of snapshot.findings) { + expect(f).not.toHaveProperty('projectId') + expect(f).not.toHaveProperty('loopName') + expect(f).not.toHaveProperty('createdAt') + } + }) + + test('final_auditing phase persists as final_auditing and goal loops carry goal text', () => { + const { sectionPlansRepo, reviewFindingsRepo, loopsRepo } = buildRepos() + const baseState: LoopState = { + active: false, + sessionId: 'sess_old', + loopName: 'final-loop', + worktreeDir: '/tmp/wt', + projectDir: '/tmp', + iteration: 2, + maxIterations: 10, + startedAt: new Date().toISOString(), + phase: 'final_auditing', + errorCount: 0, + auditCount: 0, + status: 'cancelled', + worktree: false, + currentSectionIndex: 2, + totalSections: 3, + finalAuditDone: false, + kind: 'goal', + goal: 'Ship it.', + } + loopsRepo.insert( + { + projectId: PROJECT_ID, + loopName: 'final-loop', + status: 'cancelled', + currentSessionId: 'sess_old', + worktree: false, + worktreeDir: '/tmp/wt', + worktreeBranch: null, + projectDir: '/tmp', + maxIterations: 10, + iteration: 2, + auditCount: 0, + errorCount: 0, + phase: 'final_auditing', + executionModel: null, + auditorModel: null, + modelFailed: false, + sandbox: false, + sandboxContainer: null, + startedAt: Date.now(), + completedAt: null, + terminationReason: null, + completionSummary: null, + workspaceId: null, + hostSessionId: null, + currentSectionIndex: 2, + totalSections: 3, + finalAuditDone: 0, + executionVariant: null, + auditorVariant: null, + kind: 'goal', + }, + { lastAuditResult: null, goal: 'Ship it.' }, + ) + + const snapshot = captureLoopResumeSnapshot({ + projectId: PROJECT_ID, + state: baseState, + sectionPlansRepo, + reviewFindingsRepo, + }) + + expect(snapshot.phase).toBe('final_auditing') + expect(snapshot.kind).toBe('goal') + expect(snapshot.goal).toBe('Ship it.') + }) +}) + +describe('restoreLoopResumeRows', () => { + let db: Database + let tempDir: string + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'resume-snapshot-restore-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function makeSnapshot(): ReturnType { + const sectionRow = (i: number): Omit => ({ + sectionIndex: i, + title: `S${i}`, + content: `Section ${i} content`, + status: i === 0 ? 'completed' : i === 1 ? 'in_progress' : 'pending', + attempts: i === 1 ? 2 : 0, + summaryDone: i === 0 ? 'done zero' : null, + summaryDeviations: null, + summaryFollowUps: null, + startedAt: i === 1 ? 1234 : null, + completedAt: i === 0 ? 999 : null, + }) + return { + version: 1, + kind: 'plan', + phase: 'coding', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + sections: [sectionRow(0), sectionRow(1), sectionRow(2)], + findings: [ + { file: 'a.ts', line: 1, severity: 'bug', description: 'bug desc', scenario: 'how', sectionIndex: 1 }, + { file: 'b.ts', line: 2, severity: 'warning', description: 'warn desc', scenario: null, sectionIndex: null }, + ], + } + } + + test('restores section rows and findings under a new project/loop scope', () => { + const loopsRepo = createLoopsRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const sectionPlansRepo = createSectionPlansRepo(db) + + loopsRepo.insert({ + projectId: 'remote-proj', + loopName: 'moved', + status: 'running', + currentSessionId: 'sess_new', + worktree: false, + worktreeDir: '/tmp/wt', + worktreeBranch: null, + projectDir: '/tmp', + maxIterations: 10, + iteration: 1, + auditCount: 0, + errorCount: 0, + phase: 'coding', + executionModel: null, + auditorModel: null, + modelFailed: false, + sandbox: false, + sandboxContainer: null, + startedAt: Date.now(), + completedAt: null, + terminationReason: null, + completionSummary: null, + workspaceId: null, + hostSessionId: null, + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: 0, + executionVariant: null, + auditorVariant: null, + kind: 'plan', + }, { lastAuditResult: null }) + + restoreLoopResumeRows({ + projectId: 'remote-proj', + loopName: 'moved', + snapshot: makeSnapshot(), + sectionPlansRepo, + reviewFindingsRepo, + }) + + const restored = sectionPlansRepo.list('remote-proj', 'moved') + expect(restored).toHaveLength(3) + const byIndex = new Map(restored.map((s) => [s.sectionIndex, s])) + expect(byIndex.get(0)!.status).toBe('completed') + expect(byIndex.get(0)!.summaryDone).toBe('done zero') + expect(byIndex.get(1)!.status).toBe('in_progress') + expect(byIndex.get(1)!.attempts).toBe(2) + expect(byIndex.get(1)!.startedAt).toBe(1234) + expect(byIndex.get(2)!.status).toBe('pending') + expect(restored.every((s) => s.projectId === 'remote-proj' && s.loopName === 'moved')).toBe(true) + expect(restored.every((s) => typeof s.createdAt === 'number')).toBe(true) + + const findings = reviewFindingsRepo.listByLoopName('remote-proj', 'moved') + expect(findings).toHaveLength(2) + const bug = findings.find((f: ReviewFindingRow) => f.file === 'a.ts')! + expect(bug.severity).toBe('bug') + expect(bug.sectionIndex).toBe(1) + expect(bug.scenario).toBe('how') + const cross = findings.find((f: ReviewFindingRow) => f.file === 'b.ts')! + expect(cross.sectionIndex).toBeNull() + }) + + test('isLoopResumeSnapshot accepts a valid snapshot and rejects malformed values', () => { + expect(isLoopResumeSnapshot(makeSnapshot())).toBe(true) + + expect(isLoopResumeSnapshot(null)).toBe(false) + expect(isLoopResumeSnapshot('nope')).toBe(false) + expect(isLoopResumeSnapshot({})).toBe(false) + expect(isLoopResumeSnapshot({ version: 2 })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + phase: 'auditing', + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + sections: 'nope', + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + findings: 42, + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + findings: [ + { file: 'a.ts', line: 1, severity: 'critical', description: 'bad severity', scenario: null, sectionIndex: 1 }, + ], + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + findings: [ + { file: 'a.ts', line: 1, severity: 'bug', description: 'bad sectionIndex', scenario: null, sectionIndex: 'one' }, + ], + })).toBe(false) + expect(isLoopResumeSnapshot({ + ...makeSnapshot(), + sections: [ + { sectionIndex: 0, title: 'S0', content: 'Section 0 content', status: 'archived', attempts: 0, summaryDone: null, summaryDeviations: null, summaryFollowUps: null, startedAt: null, completedAt: null }, + ], + })).toBe(false) + expect(isLoopResumeSnapshot({ + version: 1, + kind: 'goal', + phase: 'coding', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + sections: [], + findings: [], + })).toBe(true) + expect(isLoopResumeSnapshot({ + version: 1, + kind: 'goal', + phase: 'coding', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + goal: 'Ship it.', + sections: [], + findings: [], + })).toBe(true) + }) +}) diff --git a/test/loop/termination.test.ts b/test/loop/termination.test.ts index c26646357..c23239bd5 100644 --- a/test/loop/termination.test.ts +++ b/test/loop/termination.test.ts @@ -62,6 +62,10 @@ describe('terminationStatusFor', () => { it('maps provider_limit with message to errored', () => { expect(terminationStatusFor({ kind: 'provider_limit', message: 'usage limit hit' })).toBe('errored') }) + + it('maps migrated to cancelled', () => { + expect(terminationStatusFor({ kind: 'migrated', message: 'server1' })).toBe('cancelled') + }) }) describe('terminationReasonToString', () => { @@ -123,6 +127,11 @@ describe('terminationReasonToString', () => { const reason: TerminationReason = { kind: 'provider_limit', message: 'usage limit hit' } expect(terminationReasonToString(reason)).toBe('provider_limit: usage limit hit') }) + + it('stringifies migrated with message', () => { + const reason: TerminationReason = { kind: 'migrated', message: 'server1' } + expect(terminationReasonToString(reason)).toBe('migrated: server1') + }) }) describe('parseTerminationReasonString', () => { @@ -131,4 +140,10 @@ describe('parseTerminationReasonString', () => { const str = terminationReasonToString(reason) expect(parseTerminationReasonString(str)).toEqual(reason) }) + + it('round-trips migrated', () => { + const reason: TerminationReason = { kind: 'migrated', message: 'server1' } + const str = terminationReasonToString(reason) + expect(parseTerminationReasonString(str)).toEqual(reason) + }) }) diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 5348a6239..72fd6435d 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -161,6 +161,7 @@ describe('createForgePlugin', () => { expect(hooks.tool?.['execute-goal']).toBeDefined() expect(hooks.tool?.['loop-cancel']).toBeDefined() expect(hooks.tool?.['loop-status']).toBeDefined() + expect(hooks.tool?.['loop-migrate']).toBeDefined() }) test('Plugin does NOT register shadow glob or grep tools', async () => { diff --git a/test/services/attach-loop.test.ts b/test/services/attach-loop.test.ts index b3e83fba8..633e57cb4 100644 --- a/test/services/attach-loop.test.ts +++ b/test/services/attach-loop.test.ts @@ -626,4 +626,152 @@ describe('attachLoopToSession', () => { expect(row!.executionVariant).toBe('thinking-max') expect(row!.auditorVariant).toBe('audit-high') }) + + function makeResumeSnapshot(): import('../../src/loop/resume-snapshot').LoopResumeSnapshot { + return { + version: 1, + kind: 'plan', + phase: 'coding', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + sections: [ + { sectionIndex: 0, title: 'Setup', content: 'Do setup', status: 'completed', attempts: 1, summaryDone: 'setup done', summaryDeviations: null, summaryFollowUps: null, startedAt: 100, completedAt: 200 }, + { sectionIndex: 1, title: 'Build', content: 'Do build', status: 'in_progress', attempts: 2, summaryDone: null, summaryDeviations: null, summaryFollowUps: null, startedAt: 300, completedAt: null }, + { sectionIndex: 2, title: 'Ship', content: 'Do ship', status: 'pending', attempts: 0, summaryDone: null, summaryDeviations: null, summaryFollowUps: null, startedAt: null, completedAt: null }, + ], + findings: [ + { file: 'a.ts', line: 10, severity: 'bug', description: 'broken thing', scenario: 'when X', sectionIndex: 1 }, + ], + } + } + + test('attaches a migrated loop from a resume snapshot', async () => { + const { deps, loopsRepo, sectionPlansRepo, promptAsyncMock } = buildDeps() + const reviewFindingsRepo = createReviewFindingsRepo(db) + + const { attachLoopToSession } = await import('../../src/services/execution') + + const snapshot = makeResumeSnapshot() + + const result = await attachLoopToSession( + deps as any, + { surface: 'tui', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + sessionId: 'sess_resume', + workspaceId: 'ws_resume', + worktreeDir: '/tmp/wt/resume', + loopName: 'moved', + displayName: 'Moved Loop', + executionName: 'moved', + hostSessionId: 'host-sess', + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + maxIterations: 40, + sandboxEnabled: false, + planText: '# Stale plan text', + selectSession: false, + selectSessionTiming: 'after-prompt', + startWatchdog: false, + sendInitialPrompt: false, + resume: snapshot, + }, + ) + + expect(result.ok).toBe(true) + + // Loop row persists the snapshot's phase pointers. + const row = loopsRepo.get(PROJECT_ID, 'moved') + expect(row).not.toBeNull() + expect(row!.phase).toBe('coding') + expect(row!.currentSectionIndex).toBe(1) + expect(row!.totalSections).toBe(3) + expect(row!.finalAuditDone).toBe(0) + expect(row!.status).toBe('running') + + // Section rows match the snapshot exactly; section 1 stays in_progress + // (no applyPlanDecomposition reset to index 0). + const sections = sectionPlansRepo.list(PROJECT_ID, 'moved') + expect(sections).toHaveLength(3) + const byIndex = new Map(sections.map((s) => [s.sectionIndex, s])) + expect(byIndex.get(0)!.status).toBe('completed') + expect(byIndex.get(1)!.status).toBe('in_progress') + expect(byIndex.get(1)!.attempts).toBe(2) + expect(byIndex.get(2)!.status).toBe('pending') + expect(byIndex.get(1)!.content).toBe('Do build') + + // Findings were restored for the loop. + const findings = reviewFindingsRepo.listByLoopName(PROJECT_ID, 'moved') + expect(findings).toHaveLength(1) + expect(findings[0].file).toBe('a.ts') + expect(findings[0].sectionIndex).toBe(1) + + // No initial prompt is sent. + expect(promptAsyncMock).not.toHaveBeenCalled() + + // In-memory state reflects the snapshot too. + const state = (deps.loop.service as any).getActiveState('moved') + expect(state).not.toBeNull() + expect(state!.phase).toBe('coding') + expect(state!.currentSectionIndex).toBe(1) + expect(state!.totalSections).toBe(3) + expect(state!.prompt).toBe('# Stale plan text') + }) + + test('resume attach with sendInitialPrompt sends the resume prompt for the snapshot phase', async () => { + const { deps, promptAsyncMock } = buildDeps() + + const { attachLoopToSession } = await import('../../src/services/execution') + + const snapshot: import('../../src/loop/resume-snapshot').LoopResumeSnapshot = { + version: 1, + kind: 'plan', + phase: 'final_auditing', + currentSectionIndex: 2, + totalSections: 3, + finalAuditDone: false, + sections: [ + { sectionIndex: 0, title: 'Setup', content: 'Do setup', status: 'completed', attempts: 1, summaryDone: 'setup done', summaryDeviations: null, summaryFollowUps: null, startedAt: 100, completedAt: 200 }, + { sectionIndex: 1, title: 'Build', content: 'Do build', status: 'completed', attempts: 1, summaryDone: 'build done', summaryDeviations: null, summaryFollowUps: null, startedAt: 300, completedAt: 400 }, + { sectionIndex: 2, title: 'Ship', content: 'Do ship', status: 'completed', attempts: 1, summaryDone: 'ship done', summaryDeviations: null, summaryFollowUps: null, startedAt: 500, completedAt: 600 }, + ], + findings: [], + } + + const result = await attachLoopToSession( + deps as any, + { surface: 'tui', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + sessionId: 'sess_resume_audit', + workspaceId: 'ws_resume_audit', + worktreeDir: '/tmp/wt/resume-audit', + loopName: 'moved-audit', + displayName: 'Moved Audit Loop', + executionName: 'moved-audit', + hostSessionId: 'host-sess', + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + maxIterations: 40, + sandboxEnabled: false, + planText: '# Plan text', + selectSession: false, + selectSessionTiming: 'after-prompt', + startWatchdog: false, + sendInitialPrompt: true, + resume: snapshot, + }, + ) + + expect(result.ok).toBe(true) + + const state = (deps.loop.service as any).getActiveState('moved-audit') + expect(state).not.toBeNull() + expect(state!.phase).toBe('final_auditing') + + expect(promptAsyncMock).toHaveBeenCalledTimes(1) + const promptCallArgs = promptAsyncMock.mock.calls[0][0] + expect(promptCallArgs.agent).toBe('auditor-loop') + const expectedPrompt = deps.loop.service.buildFinalAuditPrompt(state) + expect(promptCallArgs.parts[0].text).toBe(expectedPrompt) + }) }) diff --git a/test/services/execution-restart.test.ts b/test/services/execution-restart.test.ts index e3813a10e..899c85eed 100644 --- a/test/services/execution-restart.test.ts +++ b/test/services/execution-restart.test.ts @@ -202,6 +202,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -256,6 +258,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -323,6 +329,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -377,6 +385,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -422,6 +434,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -478,6 +492,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -525,6 +543,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -597,6 +617,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -631,6 +655,152 @@ describe('handleLoopRestart from stall_timeout', () => { ) }) + test('restart drops the resume snapshot from the preserved forgeLoop extra but keeps the other forgeLoop fields', async () => { + insertLoop({ + loopName: 'snapshot-loop', + status: 'errored', + terminationReason: 'max_iterations', + iteration: 10, + worktree: true, + workspaceId: 'ws_old', + }) + + const noopFn = () => {} + const mockLoopService: Partial = { + listActive: () => loopService.listActive(), + listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), + getActiveState: (name) => loopService.getActiveState(name), + getAnyState: (name) => loopService.getAnyState(name), + registerLoopSession: noopFn, + setState: (name, state) => loopService.setState(name, state), + deleteState: (name) => loopService.deleteState(name), + setPhase: noopFn, + buildSectionInitialPrompt: () => 'section prompt', + buildFinalAuditPrompt: () => 'audit prompt', + recordTransition: (name, entry) => loopService.recordTransition(name, entry), + recordTerminalTransition: (name, entry) => loopService.recordTerminalTransition(name, entry), + restoreState: (name, state) => loopService.restoreState(name, state), + getOutstandingFindings: (name, severity) => loopService.getOutstandingFindings(name, severity), + generateUniqueLoopName: () => 'snapshot-loop', + } + + const { client } = createFakeForgeClient({ + session: { + create: async () => ({ id: 'new-sess-snapshot' }), + get: async () => ({}), + promptAsync: async () => {}, + abort: async () => {}, + delete: async () => {}, + messages: async () => [], + status: async () => ({}), + }, + workspace: { + create: async () => ({ id: 'ws_new', directory: '/tmp', branch: 'forge/snapshot-loop' }), + list: async () => [ + { + id: 'ws_old', + type: 'forge', + name: 'snapshot-loop', + branch: null, + directory: '/tmp', + projectID: PROJECT_ID, + extra: { + startRef: 'abc123', + syncRef: 'refs/forge/snapshot-loop', + gitRemote: 'origin', + forgeLoop: { + hostSessionId: 'host-sess', + title: 'Snapshot Loop', + executionModel: 'prov/exec', + planSource: 'stored', + planText: '# Snapshot plan', + maxIterations: 40, + resume: { + version: 1, + kind: 'plan', + phase: 'coding', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + sections: [], + findings: [], + }, + }, + }, + }, + ], + remove: async () => {}, + warp: async () => {}, + syncList: async () => {}, + }, + tui: { publish: async () => {}, selectSession: async () => {} }, + sync: { start: async () => {} }, + }) + + const mockLoopHandler = { + runExclusive: async (name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + } + + const { createForgeExecutionService } = await import('../../src/services/execution') + const service = createForgeExecutionService({ + projectId: PROJECT_ID, + directory: '/tmp/test', + config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, + logger: mockLogger, + dataDir: '/tmp', + + plansRepo, + loopsRepo, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + registerSessionReverseIndex: () => {}, + unregisterSessionReverseIndex: () => {}, + } as any, + loopHandler: mockLoopHandler as any, + sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + client, + pendingTeardowns: mockPendingTeardowns as any, + }) + + const result = await service.dispatch( + { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, + { type: 'loop.restart' as const, selector: { kind: 'exact' as const, name: 'snapshot-loop' } }, + ) + + expect(result.ok).toBe(true) + + expect(client.workspace.create).toHaveBeenCalledWith( + expect.objectContaining({ + extra: { + forgeLoop: { + hostSessionId: 'host-sess', + title: 'Snapshot Loop', + executionModel: 'prov/exec', + planSource: 'stored', + planText: '# Snapshot plan', + maxIterations: 40, + }, + loopName: 'snapshot-loop', + projectDirectory: '/tmp', + workspaceCreatedAt: expect.any(Number), + }, + }), + ) + }) + test('restart from stall_timeout routes to final_auditing when persisted phase is final_auditing', async () => { insertLoop({ loopName: 'final-audit-loop', @@ -664,6 +834,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -718,6 +890,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -781,6 +957,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -835,6 +1013,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -892,6 +1074,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -947,6 +1131,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1009,6 +1197,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: (sid: string, name: string) => loopService.registerLoopSession(sid, name), @@ -1061,6 +1251,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1136,6 +1330,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: (sid: string, name: string) => loopService.registerLoopSession(sid, name), @@ -1170,6 +1366,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1244,6 +1444,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => { if (!transitioned) { transitioned = true @@ -1316,6 +1518,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1402,6 +1608,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => { if (!transitioned) { transitioned = true @@ -1476,6 +1684,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1550,6 +1762,8 @@ describe('handleLoopRestart from stall_timeout', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -1607,6 +1821,10 @@ describe('handleLoopRestart from stall_timeout', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -1767,6 +1985,8 @@ describe('handleLoopRestart restartability rules', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -1879,6 +2099,10 @@ describe('handleLoopRestart restartability rules', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, @@ -2992,6 +3216,8 @@ describe('handleLoopRestart restartability rules', () => { const mockLoopService: Partial = { listActive: () => loopService.listActive(), listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + listLoopNames: () => loopService.listLoopNames(), getActiveState: (name) => loopService.getActiveState(name), getAnyState: (name) => loopService.getAnyState(name), registerLoopSession: noopFn, @@ -3066,6 +3292,8 @@ describe('handleLoopRestart restartability rules', () => { service: mockLoopService, listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + findMatchByName: (...args: any[]) => (mockLoopService.findMatchByName as any)(...args), + listLoopNames: (...args: any[]) => (mockLoopService.listLoopNames as any)(...args), setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), registerSessionReverseIndex: () => {}, diff --git a/test/services/loop-migration.test.ts b/test/services/loop-migration.test.ts new file mode 100644 index 000000000..44c3a2ac2 --- /dev/null +++ b/test/services/loop-migration.test.ts @@ -0,0 +1,608 @@ +import { describe, test, expect, beforeEach, afterEach, vi, type Mock } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync, mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createLoopsRepo } from '../../src/storage/repos/loops-repo' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' +import { createReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createFeatureGroupsRepo, type FeatureGroupsRepo } from '../../src/storage/repos/feature-groups-repo' +import { createLoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' +import { createLoopService } from '../../src/loop/service' +import type { LoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import type { LoopsRepo } from '../../src/storage/repos/loops-repo' +import type { PlansRepo } from '../../src/storage/repos/plans-repo' +import type { ReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import type { SectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import type { LoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' +import type { LoopState } from '../../src/loop/state' +import type { GitService } from '../../src/utils/git-service' +import type { ForgeClient } from '../../src/client/port' +import type { PluginConfig } from '../../src/types' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' +import { createFakeGitService } from '../helpers/fake-git' +import { createClientSpy, REMOTE_URL, LOCAL_PROJECT_ID } from '../helpers/fake-remote-client' + +vi.mock('../../src/utils/tui-execution-preferences', () => ({ + deriveExecutionPreferencesFromWorkspaces: vi.fn().mockReturnValue(null), +})) +vi.mock('../../src/utils/tui-models', () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue({ providers: [] }), + readOpenCodeFavoriteModels: vi.fn().mockReturnValue([]), +})) +vi.mock('../../src/utils/workspace-listing', () => ({ + listConnectedWorkspaces: vi.fn().mockResolvedValue([]), +})) +vi.mock('../../src/utils/tui-loop-store', () => ({ + fetchLoopsList: vi.fn().mockReturnValue([]), +})) +vi.mock('../../src/storage', () => ({ + resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), + resolveDataDir: vi.fn().mockReturnValue('/tmp/forge-test-data'), +})) +vi.mock('../../src/loop/resume-snapshot', async (importOriginal) => { + const original = await importOriginal() + return { ...original, captureLoopResumeSnapshot: vi.fn(original.captureLoopResumeSnapshot) } +}) +vi.mock('../../src/loop/resume-prompt', async (importOriginal) => { + const original = await importOriginal() + return { ...original, buildResumePromptPlan: vi.fn(original.buildResumePromptPlan) } +}) + +const mockLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } +const noopFn = () => {} +// The local OpenCode project id must match the remote fake's project id so +// discovery finds it (migration matches by identity, not path). +const PROJECT_ID = LOCAL_PROJECT_ID +const SYNC_REF = 'refs/forge/my-loop' +const DEADBEEF = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + +describe('migrateLoopToRemote', () => { + let db: Database + let tempDir: string + let loopsRepo: LoopsRepo + let plansRepo: PlansRepo + let reviewFindingsRepo: ReviewFindingsRepo + let sectionPlansRepo: SectionPlansRepo + let loopTransitionsRepo: LoopTransitionsRepo + let featureGroupsRepo: FeatureGroupsRepo + let loopService: LoopService + + const mockWorkspaceStatusRegistry = { + awaitConnected: async () => ({ connected: true }), + } + + const mockPendingTeardowns = { + register: () => {}, + unregister: () => {}, + get: () => undefined, + } + + beforeEach(() => { + process.env.FORGE_TUI_WORKSPACE_SETTLE_MS = '0' + tempDir = mkdtempSync(join(tmpdir(), 'loop-migration-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + + loopsRepo = createLoopsRepo(db) + plansRepo = createPlansRepo(db) + reviewFindingsRepo = createReviewFindingsRepo(db) + sectionPlansRepo = createSectionPlansRepo(db) + loopTransitionsRepo = createLoopTransitionsRepo(db) + featureGroupsRepo = createFeatureGroupsRepo(db) + loopService = createLoopService( + loopsRepo, + plansRepo, + reviewFindingsRepo, + PROJECT_ID, + mockLogger, + undefined, + undefined, + sectionPlansRepo, + loopTransitionsRepo, + ) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + interface SeedOpts { + loopName?: string + active?: boolean + status?: 'running' | 'completed' | 'cancelled' | 'errored' | 'stalled' + terminationReason?: string + worktreeDir?: string + worktree?: boolean + workspaceId?: string + } + + function seedLoop(opts: SeedOpts = {}): LoopState { + const loopName = opts.loopName ?? 'my-loop' + const active = opts.active ?? true + const state: LoopState = { + active, + sessionId: 'sess_local', + loopName, + worktreeDir: opts.worktreeDir ?? '/local/wt', + projectDir: '/local/proj', + worktreeBranch: 'forge/my-loop', + iteration: 3, + maxIterations: 40, + startedAt: new Date().toISOString(), + prompt: '# My Plan\n\nStep one.', + phase: 'auditing', + errorCount: 0, + auditCount: 1, + status: opts.status ?? (active ? 'running' : 'cancelled'), + worktree: opts.worktree ?? true, + sandbox: false, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + ...(opts.workspaceId ? { workspaceId: opts.workspaceId } : {}), + ...(opts.terminationReason ? { terminationReason: opts.terminationReason } : {}), + } + loopService.setState(loopName, state) + + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName, + sections: [ + { index: 0, title: 'Setup', content: 'Do setup' }, + { index: 1, title: 'Build', content: 'Do build' }, + { index: 2, title: 'Ship', content: 'Do ship' }, + ], + }) + sectionPlansRepo.setStatus(PROJECT_ID, loopName, 0, 'completed') + sectionPlansRepo.setStatus(PROJECT_ID, loopName, 1, 'in_progress') + + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName, + file: 'a.ts', + line: 10, + severity: 'bug', + description: 'section finding', + sectionIndex: 1, + }) + + plansRepo.writeForLoop(PROJECT_ID, loopName, '# My Plan\n\nStep one.') + return state + } + + function happyConfig(): PluginConfig { + return { + remotes: [{ name: 'server1', url: REMOTE_URL, password: 'sekret' }], + } + } + + function happyGit(): GitService { + return createFakeGitService({ + branchExists: vi.fn(() => true), + revParseRef: vi.fn(() => ({ ok: true, status: 0, stdout: `${DEADBEEF}\n`, stderr: '' })), + }) + } + + async function buildDeps(opts: { + config?: PluginConfig + git?: GitService + createRemoteClient?: (o: unknown) => ForgeClient + onTerminateLoopByName?: (name: string, reason: unknown) => void + terminateLoopByNameReturnsFalse?: boolean + featureGroupsRepo?: FeatureGroupsRepo + localWorkspaceList?: () => Promise>> + } = {}) { + const terminateCalls: Array<{ name: string; reason: string }> = [] + + const mockLoopHandler = { + runExclusive: async (_name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + terminateLoopByName: async (name: string, reason: { kind: string; message?: string }) => { + opts.onTerminateLoopByName?.(name, reason) + if (opts.terminateLoopByNameReturnsFalse) return false + terminateCalls.push({ name, reason: `${reason.kind}${reason.message ? `: ${reason.message}` : ''}` }) + const state = loopService.getActiveState(name) + if (!state?.active) return false + loopService.terminate(name, { + status: 'cancelled', + reason: `${reason.kind}${reason.message ? `: ${reason.message}` : ''}`, + completedAt: Date.now(), + }) + return true + }, + } + + const localClient = createFakeForgeClient( + opts.localWorkspaceList ? { workspace: { list: opts.localWorkspaceList } } : undefined, + ).client + const remoteSpy = opts.createRemoteClient ?? createClientSpy().spy + + const { createForgeExecutionService } = await import('../../src/services/execution') + const service = createForgeExecutionService({ + projectId: PROJECT_ID, + directory: '/local/proj', + config: opts.config ?? happyConfig(), + logger: mockLogger, + dataDir: '/tmp', + plansRepo, + loopsRepo, + loop: { + service: loopService, + inspect: (name: string) => loopService.getAnyState(name), + listActive: (...args: unknown[]) => (loopService.listActive as (...a: unknown[]) => LoopState[])(...args), + listRecent: (...args: unknown[]) => (loopService.listRecent as (...a: unknown[]) => LoopState[])(...args), + listLoopNames: (...args: unknown[]) => (loopService.listLoopNames as (...a: unknown[]) => string[])(...args), + findMatchByName: (...args: unknown[]) => (loopService.findMatchByName as (...a: unknown[]) => { match: LoopState | null; candidates: LoopState[] })(...args), + setPhase: (...args: unknown[]) => (loopService.setPhase as (...a: unknown[]) => void)(...args), + generateUniqueLoopName: (...args: unknown[]) => (loopService.generateUniqueLoopName as (...a: unknown[]) => string)(...args), + runExclusive: async (_name: string, fn: () => Promise) => fn(), + registerSessionReverseIndex: noopFn, + unregisterSessionReverseIndex: noopFn, + } as unknown as Parameters[0]['loop'], + loopHandler: mockLoopHandler as never, + sectionPlansRepo, + reviewFindingsRepo, + featureGroupsRepo: opts.featureGroupsRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as never, + client: localClient, + pendingTeardowns: mockPendingTeardowns as never, + git: opts.git, + createRemoteClient: remoteSpy as unknown as (o: unknown) => ForgeClient, + }) + + return { service, terminateCalls, remoteSpy: remoteSpy as unknown as ReturnType } + } + + const ctx = { surface: 'api' as const, projectId: PROJECT_ID, directory: '/local/proj' } + const migrateCommand = { type: 'loop.migrate' as const, selector: { kind: 'exact' as const, name: 'my-loop' }, remoteName: 'server1' } + + test('happy path: freezes the loop as migrated, pushes sync ref, launches remote loop with snapshot', async () => { + seedLoop() + const git = happyGit() + const { spy: createClient, clients } = createClientSpy() + const terminateReasons: Array<{ name: string; reason: unknown }> = [] + const { service } = await buildDeps({ + git, + createRemoteClient: createClient as never, + onTerminateLoopByName: (name, reason) => terminateReasons.push({ name, reason }), + }) + + const result = await service.dispatch(ctx, migrateCommand) + if (!result.ok) throw new Error(`expected ok, got: ${JSON.stringify(result.error)}`) + + expect(result.data).toEqual({ + operation: 'loop.migrate', + loopName: 'my-loop', + remoteName: 'server1', + remoteLoopName: 'my-loop', + remoteSessionId: 'sess_remote', + startRef: DEADBEEF, + syncRef: SYNC_REF, + phase: 'coding', + currentSectionIndex: 1, + totalSections: 3, + }) + + // Loop handler received the migrated reason; local row is cancelled/migrated. + expect(terminateReasons).toEqual([{ name: 'my-loop', reason: { kind: 'migrated', message: 'server1' } }]) + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + expect(row?.terminationReason).toBe('migrated: server1') + + // git.push: exactly one forced push of the loop branch tip to the sync ref. + expect(git.push).toHaveBeenCalledTimes(1) + expect(git.push).toHaveBeenCalledWith('/local/proj', 'origin', 'refs/heads/forge/my-loop:refs/forge/my-loop', true) + + // The scoped remote client created the workspace with the migration extras. + const remoteClient = clients[1] + expect(remoteClient.workspace.create).toHaveBeenCalledTimes(1) + const createParams = (remoteClient.workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.startRef).toBe(DEADBEEF) + expect(createParams.extra.syncRef).toBe(SYNC_REF) + expect(createParams.extra.gitRemote).toBe('origin') + expect(createParams.extra.forgeLoop.maxIterations).toBe(40) + expect(createParams.extra.forgeLoop.resume).toBeDefined() + expect(createParams.extra.forgeLoop.resume.totalSections).toBe(3) + expect(createParams.extra.forgeLoop.resume.currentSectionIndex).toBe(1) + expect(createParams.extra.forgeLoop.resume.findings).toHaveLength(1) + expect(createParams.extra.forgeLoop.planText).toBe('# My Plan\n\nStep one.') + + // Phase-appropriate first prompt on the remote session. + expect(remoteClient.session.promptAsync).toHaveBeenCalledTimes(1) + const promptInput = (remoteClient.session.promptAsync as ReturnType).mock.calls[0][0] + expect(promptInput.agent).toBe('code') + }) + + test('first remote prompt equals buildSectionInitialPrompt of the frozen state', async () => { + seedLoop() + const git = happyGit() + const { spy: createClient, clients } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + expect(result.ok).toBe(true) + + const frozen = loopService.getAnyState('my-loop')! + const expectedPrompt = loopService.buildSectionInitialPrompt(frozen) + expect(expectedPrompt.length).toBeGreaterThan(0) + const promptInput = (clients[1].session.promptAsync as ReturnType).mock.calls[0][0] + expect(promptInput.parts[0].text).toBe(expectedPrompt) + expect(promptInput.agent).toBe('code') + }) + + test('unknown remote fails bad_request without touching the loop', async () => { + seedLoop() + const git = happyGit() + const { service, terminateCalls } = await buildDeps({ git, createRemoteClient: createClientSpy().spy as never }) + + const result = await service.dispatch(ctx, { ...migrateCommand, remoteName: 'nope' }) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('bad_request') + expect(terminateCalls).toHaveLength(0) + expect(git.push).not.toHaveBeenCalled() + }) + + test('remote workspace.create failure rolls back: cleanup push, loop cancelled and restartable', async () => { + seedLoop() + const git = happyGit() + const { spy: createClient, clients } = createClientSpy({ + workspace: { + create: async () => { throw new Error('remote exploded') }, + }, + }) + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('internal_error') + expect(result.error.message).toContain('restartable') + + // Best-effort sync-ref cleanup push happened (delete: ':refs/forge/my-loop'). + expect(git.push).toHaveBeenCalledWith('/local/proj', 'origin', ':refs/forge/my-loop', false) + + // The local loop is cancelled (not migrated) and restartable again. + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + expect(row?.terminationReason).toBe('cancelled') + const state = loopService.getAnyState('my-loop')! + const { getRestartability } = await import('../../src/loop/restartability') + expect(getRestartability(state, { worktreeExists: () => true, branchExists: () => true }).restartable).toBe(true) + expect(clients[1].session.create).not.toHaveBeenCalled() + }) + + test('a loop already migrated elsewhere fails conflict naming the previous remote', async () => { + seedLoop({ active: false, terminationReason: 'migrated: elsewhere' }) + const git = happyGit() + const { service, terminateCalls } = await buildDeps({ git, createRemoteClient: createClientSpy().spy as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(result.error.message).toContain('elsewhere') + expect(terminateCalls).toHaveLength(0) + expect(git.push).not.toHaveBeenCalled() + }) + + test('a completed loop fails conflict', async () => { + seedLoop({ active: false, status: 'completed', terminationReason: 'completed' }) + const git = happyGit() + const { service, terminateCalls } = await buildDeps({ git, createRemoteClient: createClientSpy().spy as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(terminateCalls).toHaveLength(0) + expect(git.push).not.toHaveBeenCalled() + }) + + test('a worktree:false loop is refused before any remote call', async () => { + seedLoop({ worktree: false }) + const git = happyGit() + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(result.error.message).toContain('worktree: false') + expect(createClient).not.toHaveBeenCalled() + expect(git.push).not.toHaveBeenCalled() + }) + + test('a feature-group loop is refused', async () => { + seedLoop() + featureGroupsRepo.createGroup({ projectId: PROJECT_ID, groupId: 'g1', title: 'Group', status: 'running' }) + featureGroupsRepo.insertFeatures(PROJECT_ID, 'g1', [{ title: 'Feature', description: 'Do it' }]) + featureGroupsRepo.setFeatureLoopName(PROJECT_ID, 'g1', 0, 'my-loop') + const git = happyGit() + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never, featureGroupsRepo }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(result.error.message).toContain('feature group') + expect(createClient).not.toHaveBeenCalled() + expect(git.push).not.toHaveBeenCalled() + }) + + test('terminateLoopByName returning false fails conflict with no push', async () => { + seedLoop() + const git = happyGit() + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ + git, + createRemoteClient: createClient as never, + terminateLoopByNameReturnsFalse: true, + }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('conflict') + expect(result.error.message).toContain('changed state during migration') + expect(git.push).not.toHaveBeenCalled() + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('running') + }) + + test('dirty worktree after freeze rolls back to cancelled and never pushes', async () => { + const worktreeDir = join(tempDir, 'wt') + mkdirSync(worktreeDir, { recursive: true }) + seedLoop({ worktreeDir }) + const git = happyGit() + const statusPorcelain = vi.fn(() => ({ ok: true, status: 0, stdout: ' M a.ts\n', stderr: '' })) + git.statusPorcelain = statusPorcelain as never + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('internal_error') + expect(result.error.message).toContain('uncommitted changes') + expect(statusPorcelain).toHaveBeenCalledWith(worktreeDir) + expect(git.push).not.toHaveBeenCalled() + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + expect(row?.terminationReason).toBe('cancelled') + }) + + test('a throw from captureLoopResumeSnapshot after freeze rolls back without pushing', async () => { + seedLoop() + const git = happyGit() + const { captureLoopResumeSnapshot } = await import('../../src/loop/resume-snapshot') + ;(captureLoopResumeSnapshot as Mock).mockImplementationOnce(() => { throw new Error('snapshot boom') }) + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.code).toBe('internal_error') + expect(result.error.message).toContain('snapshot boom') + expect(result.error.message).toContain('restartable') + expect(git.push).not.toHaveBeenCalled() + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + expect(row?.terminationReason).toBe('cancelled') + }) + + test('a throw from buildResumePromptPlan rolls back without pushing', async () => { + seedLoop() + const git = happyGit() + const { buildResumePromptPlan } = await import('../../src/loop/resume-prompt') + ;(buildResumePromptPlan as Mock).mockImplementationOnce(() => { throw new Error('prompt boom') }) + const { service, remoteSpy } = await buildDeps({ git, createRemoteClient: createClientSpy().spy as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + expect(result.error.message).toContain('prompt boom') + expect(git.push).not.toHaveBeenCalled() + expect(remoteSpy).toHaveBeenCalledTimes(2) + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('cancelled') + }) + + test('an inactive errored loop restored to errored with its original reason on post-freeze failure', async () => { + seedLoop({ active: false, status: 'errored', terminationReason: 'error_max_retries: too many' }) + const git = happyGit() + const { spy: createClient, clients } = createClientSpy({ + workspace: { + create: async () => { throw new Error('remote exploded') }, + }, + }) + const { service } = await buildDeps({ git, createRemoteClient: createClient as never }) + + const result = await service.dispatch(ctx, migrateCommand) + + expect(result.ok).toBe(false) + if (result.ok) throw new Error('expected error') + const row = loopsRepo.get(PROJECT_ID, 'my-loop') + expect(row?.status).toBe('errored') + expect(row?.terminationReason).toBe('error_max_retries: too many') + expect(clients[1].session.create).not.toHaveBeenCalled() + }) + + test('portable permission rules merge the workspace extras with the config rules', async () => { + seedLoop({ workspaceId: 'ws_local' }) + const git = happyGit() + const config = happyConfig() + config.loop = { permissions: { deny: [{ permission: 'bash', pattern: 'git push *' }] } } + const { spy: createClient, clients } = createClientSpy() + const { service } = await buildDeps({ + config, + git, + createRemoteClient: createClient as never, + localWorkspaceList: async () => [ + { + id: 'ws_local', + type: 'forge', + extra: { + permissionRules: [{ permission: 'webfetch', pattern: 'example.com/*', action: 'deny' }], + }, + }, + ], + }) + + const result = await service.dispatch(ctx, migrateCommand) + expect(result.ok).toBe(true) + + const remoteClient = clients[1] + const createParams = (remoteClient.workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.permissionRules).toEqual([ + { permission: 'bash', pattern: 'git push *', action: 'deny' }, + { permission: 'webfetch', pattern: 'example.com/*', action: 'deny' }, + ]) + }) + + test('successful migration deletes the loop own previous sync pin', async () => { + seedLoop({ + workspaceId: 'ws_local', + }) + const git = happyGit() + const { spy: createClient } = createClientSpy() + const { service } = await buildDeps({ + git, + createRemoteClient: createClient as never, + localWorkspaceList: async () => [ + { + id: 'ws_local', + type: 'forge', + extra: { syncRef: 'refs/forge/old-pin', gitRemote: 'upstream' }, + }, + ], + }) + + const result = await service.dispatch(ctx, migrateCommand) + expect(result.ok).toBe(true) + + expect(git.push).toHaveBeenCalledTimes(2) + expect(git.push).toHaveBeenCalledWith('/local/proj', 'origin', 'refs/heads/forge/my-loop:refs/forge/my-loop', true) + expect(git.push).toHaveBeenCalledWith('/local/proj', 'upstream', ':refs/forge/old-pin', false) + }) +}) diff --git a/test/services/resolve-named-loop.test.ts b/test/services/resolve-named-loop.test.ts new file mode 100644 index 000000000..dbc292cd8 --- /dev/null +++ b/test/services/resolve-named-loop.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from 'vitest' +import { resolveNamedLoop } from '../../src/services/execution' +import type { LoopState } from '../../src/loop/state' +import type { Loop } from '../../src/loop/runtime' + +function makeLoopState(loopName: string, worktreeBranch?: string): LoopState { + return { + active: false, + sessionId: `sess-${loopName}`, + loopName, + worktreeDir: `/tmp/${loopName}`, + ...(worktreeBranch ? { worktreeBranch } : {}), + iteration: 1, + maxIterations: 5, + startedAt: new Date().toISOString(), + errorCount: 0, + auditCount: 0, + status: 'completed', + phase: 'coding', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + } +} + +describe('resolveNamedLoop', () => { + const name = 'my-loop' + + test('returns the matching state', () => { + const state = makeLoopState(name) + const loop = { + findMatchByName: () => ({ match: state, candidates: [] }), + listLoopNames: () => [name], + } as unknown as Loop + + const result = resolveNamedLoop({ loop }, name) + expect(result).toEqual({ state }) + }) + + test('ambiguous name returns conflict with candidate loop names', () => { + const loop = { + findMatchByName: () => ({ + match: null, + candidates: [makeLoopState('loop-a', 'forge/loop-a'), makeLoopState('loop-b', 'forge/loop-b')], + }), + listLoopNames: () => ['loop-a', 'loop-b', 'loop-c'], + } as unknown as Loop + + const result = resolveNamedLoop({ loop }, name) + expect('response' in result && result.response.ok).toBe(false) + if (!('response' in result)) throw new Error('expected response') + expect(result.response.error).toEqual({ + code: 'conflict', + status: 409, + message: `Multiple loops match "${name}". Be more specific.`, + details: undefined, + candidates: ['loop-a', 'loop-b'], + }) + }) + + test('unknown name returns not_found with available loop names without hydrating plans', () => { + const listLoopNames = () => ['loop-a', 'loop-b'] + const loop = { + findMatchByName: () => ({ match: null, candidates: [] }), + listLoopNames, + } as unknown as Loop + + const result = resolveNamedLoop({ loop }, name) + expect('response' in result && result.response.ok).toBe(false) + if (!('response' in result)) throw new Error('expected response') + expect(result.response.error).toEqual({ + code: 'not_found', + status: 404, + message: `No loop found for "${name}".`, + details: undefined, + candidates: ['loop-a', 'loop-b'], + }) + }) + + test('not-found path never calls plan-hydrating listings', () => { + const loop = { + findMatchByName: () => ({ match: null, candidates: [] }), + listLoopNames: () => [], + } as unknown as Loop + + const result = resolveNamedLoop({ loop }, name) + expect('response' in result && result.response.error.candidates).toEqual([]) + }) +}) diff --git a/test/tools/loop-migrate-tool.test.ts b/test/tools/loop-migrate-tool.test.ts new file mode 100644 index 000000000..b6436cba9 --- /dev/null +++ b/test/tools/loop-migrate-tool.test.ts @@ -0,0 +1,313 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { mkdtempSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createLoopsRepo } from '../../src/storage/repos/loops-repo' +import { createPlansRepo } from '../../src/storage/repos/plans-repo' +import { createReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import { createSectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import { createFeatureGroupsRepo } from '../../src/storage/repos/feature-groups-repo' +import { createLoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' +import { createLoopService } from '../../src/loop/service' +import type { LoopService } from '../../src/loop/service' +import type { Logger } from '../../src/types' +import type { LoopsRepo } from '../../src/storage/repos/loops-repo' +import type { PlansRepo } from '../../src/storage/repos/plans-repo' +import type { ReviewFindingsRepo } from '../../src/storage/repos/review-findings-repo' +import type { SectionPlansRepo } from '../../src/storage/repos/section-plans-repo' +import type { LoopTransitionsRepo } from '../../src/storage/repos/loop-transitions-repo' +import type { LoopState } from '../../src/loop/state' +import type { GitService } from '../../src/utils/git-service' +import type { ForgeClient } from '../../src/client/port' +import type { PluginConfig } from '../../src/types' +import { createLoopTools } from '../../src/tools/loop' +import { setupLoopsTestDb } from '../helpers/loops-test-db' +import { createFakeForgeClient } from '../helpers/fake-client' +import { createFakeGitService } from '../helpers/fake-git' +import { createClientSpy, REMOTE_URL, LOCAL_PROJECT_ID, REMOTE_SESSION_ID } from '../helpers/fake-remote-client' +vi.mock('../../src/utils/tui-execution-preferences', () => ({ + deriveExecutionPreferencesFromWorkspaces: vi.fn().mockReturnValue(null), +})) +vi.mock('../../src/utils/tui-models', () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue({ providers: [] }), + readOpenCodeFavoriteModels: vi.fn().mockReturnValue([]), +})) +vi.mock('../../src/utils/workspace-listing', () => ({ + listConnectedWorkspaces: vi.fn().mockResolvedValue([]), +})) +vi.mock('../../src/utils/tui-loop-store', () => ({ + fetchLoopsList: vi.fn().mockReturnValue([]), +})) +vi.mock('../../src/storage', () => ({ + resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), + resolveDataDir: vi.fn().mockReturnValue('/tmp/forge-test-data'), +})) + +// The tool's makeService leaves `git` at its default, so the default git +// service itself must be faked for the migration path to succeed in tests. +vi.mock('../../src/utils/git-service', async (importOriginal) => { + const original = await importOriginal() + const { createFakeGitService } = await import('../helpers/fake-git') + const { vi: viFromMock } = await import('vitest') + const fake = createFakeGitService({ + branchExists: viFromMock.fn(() => true), + revParseRef: viFromMock.fn(() => ({ ok: true, status: 0, stdout: `${DEADBEEF}\n`, stderr: '' })), + }) + return { ...original, defaultGitService: fake } +}) + +// Same for remote discovery: the default client factory must resolve to the +// shared fake remote client so no network access happens. +vi.mock('../../src/client/sdk-adapter', async (importOriginal) => { + const original = await importOriginal() + const { makeFakeRemoteClient } = await import('../helpers/fake-remote-client') + return { ...original, createRemoteForgeClient: () => makeFakeRemoteClient() } +}) + +const mockLogger: Logger = { log: () => {}, error: () => {}, debug: () => {} } +const noopFn = () => {} +const PROJECT_ID = LOCAL_PROJECT_ID +const SYNC_REF = 'refs/forge/my-loop' +const DEADBEEF = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + +describe('loop-migrate tool', () => { + let db: Database + let tempDir: string + let loopsRepo: LoopsRepo + let plansRepo: PlansRepo + let reviewFindingsRepo: ReviewFindingsRepo + let sectionPlansRepo: SectionPlansRepo + let loopTransitionsRepo: LoopTransitionsRepo + let featureGroupsRepo: ReturnType + let loopService: LoopService + + const mockWorkspaceStatusRegistry = { + awaitConnected: async () => ({ connected: true }), + } + + const mockPendingTeardowns = { + register: () => {}, + unregister: () => {}, + get: () => undefined, + } + + beforeEach(() => { + process.env.FORGE_TUI_WORKSPACE_SETTLE_MS = '0' + tempDir = mkdtempSync(join(tmpdir(), 'loop-migrate-tool-test-')) + db = new Database(join(tempDir, 'test.db')) + setupLoopsTestDb(db) + + loopsRepo = createLoopsRepo(db) + plansRepo = createPlansRepo(db) + reviewFindingsRepo = createReviewFindingsRepo(db) + sectionPlansRepo = createSectionPlansRepo(db) + loopTransitionsRepo = createLoopTransitionsRepo(db) + featureGroupsRepo = createFeatureGroupsRepo(db) + loopService = createLoopService( + loopsRepo, + plansRepo, + reviewFindingsRepo, + PROJECT_ID, + mockLogger, + undefined, + undefined, + sectionPlansRepo, + loopTransitionsRepo, + ) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function seedLoop(): LoopState { + const state: LoopState = { + active: true, + sessionId: 'sess_local', + loopName: 'my-loop', + worktreeDir: '/local/wt', + projectDir: '/local/proj', + worktreeBranch: 'forge/my-loop', + iteration: 3, + maxIterations: 40, + startedAt: new Date().toISOString(), + prompt: '# My Plan\n\nStep one.', + phase: 'auditing', + errorCount: 0, + auditCount: 1, + status: 'running', + worktree: true, + sandbox: false, + executionModel: 'prov/exec', + auditorModel: 'prov/aud', + currentSectionIndex: 1, + totalSections: 3, + finalAuditDone: false, + } + loopService.setState('my-loop', state) + + sectionPlansRepo.bulkInsert({ + projectId: PROJECT_ID, + loopName: 'my-loop', + sections: [ + { index: 0, title: 'Setup', content: 'Do setup' }, + { index: 1, title: 'Build', content: 'Do build' }, + { index: 2, title: 'Ship', content: 'Do ship' }, + ], + }) + sectionPlansRepo.setStatus(PROJECT_ID, 'my-loop', 0, 'completed') + sectionPlansRepo.setStatus(PROJECT_ID, 'my-loop', 1, 'in_progress') + + reviewFindingsRepo.write({ + projectId: PROJECT_ID, + loopName: 'my-loop', + file: 'a.ts', + line: 10, + severity: 'bug', + description: 'section finding', + sectionIndex: 1, + }) + + plansRepo.writeForLoop(PROJECT_ID, 'my-loop', '# My Plan\n\nStep one.') + return state + } + + function happyConfig(): PluginConfig { + return { + remotes: [{ name: 'server1', url: REMOTE_URL, password: 'sekret' }], + } + } + + function happyGit(): GitService { + return createFakeGitService({ + branchExists: vi.fn(() => true), + revParseRef: vi.fn(() => ({ ok: true, status: 0, stdout: `${DEADBEEF}\n`, stderr: '' })), + }) + } + + async function buildTools(opts: { + config?: PluginConfig + git?: GitService + createRemoteClient?: (o: unknown) => ForgeClient + } = {}) { + const mockLoopHandler = { + runExclusive: async (_name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + terminateLoopByName: async (name: string, reason: { kind: string; message?: string }) => { + const state = loopService.getActiveState(name) + if (!state?.active) return false + loopService.terminate(name, { + status: 'cancelled', + reason: `${reason.kind}${reason.message ? `: ${reason.message}` : ''}`, + completedAt: Date.now(), + }) + return true + }, + } + + const localClient = createFakeForgeClient().client + const remoteSpy = opts.createRemoteClient ?? createClientSpy().spy + + const { createForgeExecutionService } = await import('../../src/services/execution') + const service = createForgeExecutionService({ + projectId: PROJECT_ID, + directory: '/local/proj', + config: opts.config ?? happyConfig(), + logger: mockLogger, + dataDir: '/tmp', + plansRepo, + loopsRepo, + loop: { + service: loopService, + inspect: (name: string) => loopService.getAnyState(name), + listActive: (...args: unknown[]) => (loopService.listActive as (...a: unknown[]) => LoopState[])(...args), + listRecent: (...args: unknown[]) => (loopService.listRecent as (...a: unknown[]) => LoopState[])(...args), + listLoopNames: (...args: unknown[]) => (loopService.listLoopNames as (...a: unknown[]) => string[])(...args), + findMatchByName: (...args: unknown[]) => (loopService.findMatchByName as (...a: unknown[]) => { match: LoopState | null; candidates: LoopState[] })(...args), + setPhase: (...args: unknown[]) => (loopService.setPhase as (...a: unknown[]) => void)(...args), + generateUniqueLoopName: (...args: unknown[]) => (loopService.generateUniqueLoopName as (...a: unknown[]) => string)(...args), + runExclusive: async (_name: string, fn: () => Promise) => fn(), + registerSessionReverseIndex: noopFn, + unregisterSessionReverseIndex: noopFn, + } as unknown as Parameters[0]['loop'], + loopHandler: mockLoopHandler as never, + sectionPlansRepo, + reviewFindingsRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as never, + client: localClient, + pendingTeardowns: mockPendingTeardowns as never, + git: opts.git, + createRemoteClient: remoteSpy as unknown as (o: unknown) => ForgeClient, + }) + + const tools = createLoopTools({ + client: localClient, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as never, + pendingTeardowns: mockPendingTeardowns as never, + directory: '/local/proj', + config: opts.config ?? happyConfig(), + loopService, + loopHandler: mockLoopHandler as never, + logger: mockLogger, + plansRepo, + loopsRepo, + projectId: PROJECT_ID, + dataDir: '/tmp', + loop: { + service: loopService, + inspect: (name: string) => loopService.getAnyState(name), + listActive: () => loopService.listActive(), + listRecent: () => loopService.listRecent(), + findMatchByName: (name: string) => loopService.findMatchByName(name), + runExclusive: async (_name: string, fn: () => Promise) => fn(), + resolveLoopName: () => null, + } as never, + sectionPlansRepo, + reviewFindingsRepo, + featureGroupsRepo, + } as never) + + return tools + } + + test('dispatches loop.migrate with a partial selector and renders the success text', async () => { + seedLoop() + const tools = await buildTools({ git: happyGit(), createRemoteClient: createClientSpy().spy as never }) + + const result = await tools['loop-migrate'].execute( + { name: 'my-loop', remote: 'server1' }, + { sessionID: 'host-session' } as never, + ) + + expect(result).toContain('Migrated loop "my-loop" to server1') + expect(result).toContain(`Remote loop: my-loop`) + expect(result).toContain(`Remote session: ${REMOTE_SESSION_ID}`) + expect(result).toContain('Resumed at: phase coding, section 2/3') + expect(result).toContain('Pinned commit: deadbee (refs/forge/my-loop)') + expect(result).toContain('Restart it locally only with loop-status restart=true force=true') + }) + + test('renders candidate loops when the name is ambiguous', async () => { + const seeded = seedLoop() + const other: LoopState = { + ...seeded, + loopName: 'my-loop-2', + worktreeBranch: 'forge/my-loop-2', + sessionId: 'sess_local_2', + } + loopService.setState('my-loop-2', other) + + const tools = await buildTools({ git: happyGit(), createRemoteClient: createClientSpy().spy as never }) + + const result = await tools['loop-migrate'].execute( + { name: 'my-loo', remote: 'server1' }, + { sessionID: 'host-session' } as never, + ) + + expect(result).toContain('Multiple loops match') + expect(result).toContain('my-loop') + expect(result).toContain('my-loop-2') + }) +}) diff --git a/test/utils/tui-client-launch-initial-prompt.test.ts b/test/utils/tui-client-launch-initial-prompt.test.ts new file mode 100644 index 000000000..aceb77e9f --- /dev/null +++ b/test/utils/tui-client-launch-initial-prompt.test.ts @@ -0,0 +1,196 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest' + +// ── Module mocks required by the transitively-imported launchTuiLoop ────── + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn(), +})) + +vi.mock('../../src/utils/tui-execution-preferences', () => ({ + deriveExecutionPreferencesFromWorkspaces: vi.fn().mockReturnValue(null), +})) + +vi.mock('../../src/utils/tui-models', () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue({ providers: [] }), + readOpenCodeFavoriteModels: vi.fn().mockReturnValue([]), +})) + +vi.mock('../../src/utils/workspace-listing', () => ({ + listConnectedWorkspaces: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../../src/utils/tui-loop-store', () => ({ + fetchLoopsList: vi.fn().mockReturnValue([]), +})) + +vi.mock('../../src/storage', () => ({ + resolveLogPath: vi.fn().mockReturnValue('/tmp/forge-test.log'), +})) + +vi.mock('../../src/services/execution', () => ({ + ForgeLoopExtra: {}, +})) + +// ── SUT ─────────────────────────────────────────────────────────────────── + +import { launchTuiLoop } from '../../src/utils/tui-client' +import type { ForgeClient } from '../../src/client/port' +import { createFakeForgeClient } from '../helpers/fake-client' +import { buildAuditSessionPermissionRuleset, buildLoopPermissionRuleset } from '../../src/constants/loop' + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Fake ForgeClient with remote-launch flow defaults: a fixed workspace/ + * session id pair and a `connected` status so the poll resolves immediately. + */ +function makeFakeClient(): ForgeClient { + const { client } = createFakeForgeClient({ + session: { + create: async () => ({ id: 'sess_remote' }), + }, + workspace: { + create: async () => ({ id: 'ws_remote', directory: '/remote/wt', branch: null }), + status: async () => [{ workspaceID: 'ws_remote', status: 'connected' }], + }, + }) + return client +} + +beforeEach(() => { + process.env.FORGE_TUI_WORKSPACE_SETTLE_MS = '0' +}) + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('launchTuiLoop initialPrompt', () => { + test('with initialPrompt: session uses audit ruleset and promptAsync sends the override text/agent/model/variant', async () => { + const client = makeFakeClient() + + const result = await launchTuiLoop({ + client, + directory: '/p', + projectId: null, + requestedLoopName: 'moved', + loopNameReserved: true, + title: 'Moved', + plan: '# Plan', + permissionOptions: {}, + initialPrompt: { + text: 'AUDIT NOW', + agent: 'auditor-loop', + model: { providerID: 'p', modelID: 'm' }, + variant: 'high', + }, + }) + + expect('error' in result).toBe(false) + + const createArgs = (client.session.create as ReturnType).mock.calls[0][0] + expect(createArgs.permission).toEqual(buildAuditSessionPermissionRuleset({})) + + const promptCalls = (client.session.promptAsync as ReturnType).mock.calls + expect(promptCalls).toHaveLength(1) + const promptArgs = promptCalls[0][0] + expect(promptArgs.agent).toBe('auditor-loop') + expect(promptArgs.parts[0].type).toBe('text') + expect(promptArgs.parts[0].text).toBe('AUDIT NOW') + expect(promptArgs.model).toEqual({ providerID: 'p', modelID: 'm' }) + expect(promptArgs.variant).toBe('high') + }) + + test('without initialPrompt: deterministic section-0 prompt with code agent (existing behavior)', async () => { + const client = makeFakeClient() + + const plan = [ + '', + '# My Plan', + '', + '## First Section', + 'Do first work.', + '', + '## Second Section', + 'Do second work.', + '', + ].join('\n') + + const result = await launchTuiLoop({ + client, + directory: '/p', + projectId: null, + requestedLoopName: 'moved', + loopNameReserved: true, + title: 'Moved', + plan, + permissionOptions: {}, + }) + + expect('error' in result).toBe(false) + + const createArgs = (client.session.create as ReturnType).mock.calls[0][0] + expect(createArgs.permission).toEqual(buildLoopPermissionRuleset({})) + + const promptCalls = (client.session.promptAsync as ReturnType).mock.calls + expect(promptCalls).toHaveLength(1) + const promptArgs = promptCalls[0][0] + expect(promptArgs.agent).toBe('code') + expect(promptArgs.parts[0].text).toContain('[Loop section 1/2 -- iteration 1/50]') + expect(promptArgs.parts[0].text).toContain('## First Section') + expect(promptArgs.parts[0].text).not.toContain('Second Section') + expect(promptArgs.model).toBeUndefined() + expect(promptArgs.variant).toBeUndefined() + }) + + test('without initialPrompt and without a sectioned plan: sends the raw plan as prompt text', async () => { + const client = makeFakeClient() + + const result = await launchTuiLoop({ + client, + directory: '/p', + projectId: null, + requestedLoopName: 'moved', + loopNameReserved: true, + title: 'Moved', + plan: '# Plain Plan', + permissionOptions: {}, + }) + + expect('error' in result).toBe(false) + + const promptCalls = (client.session.promptAsync as ReturnType).mock.calls + expect(promptCalls).toHaveLength(1) + const promptArgs = promptCalls[0][0] + expect(promptArgs.agent).toBe('code') + expect(promptArgs.parts[0].text).toBe('# Plain Plan') + }) + + test('removes the created workspace when session.create throws', async () => { + const { client } = createFakeForgeClient({ + session: { + create: async () => { + throw new Error('session create exploded') + }, + }, + workspace: { + create: async () => ({ id: 'ws_orphan', directory: '/remote/wt', branch: null }), + status: async () => [{ workspaceID: 'ws_orphan', status: 'connected' }], + }, + }) + + const result = await launchTuiLoop({ + client, + directory: '/p', + projectId: null, + requestedLoopName: 'moved', + loopNameReserved: true, + title: 'Moved', + plan: '# Plan', + permissionOptions: {}, + }) + + expect(result).toEqual({ error: 'Loop launch failed: session create exploded' }) + expect(client.workspace.remove).toHaveBeenCalledTimes(1) + expect(client.workspace.remove).toHaveBeenCalledWith({ id: 'ws_orphan' }) + expect(client.session.promptAsync).not.toHaveBeenCalled() + }) +}) diff --git a/test/utils/tui-remote-launch.test.ts b/test/utils/tui-remote-launch.test.ts index 3287e8e51..9a5befef6 100644 --- a/test/utils/tui-remote-launch.test.ts +++ b/test/utils/tui-remote-launch.test.ts @@ -31,60 +31,41 @@ vi.mock('../../src/services/execution', () => ({ ForgeLoopExtra: {}, })) +vi.mock('../../src/utils/tui-client', async (importOriginal) => { + const original = await importOriginal() + return { ...original, launchTuiLoop: vi.fn(original.launchTuiLoop) } +}) + // ── SUT ─────────────────────────────────────────────────────────────────── -import { executeRemoteLoop } from '../../src/utils/tui-remote-launch' +import { + executeRemoteLoop, + connectRemoteProject, + pushForgeSyncRef, + deleteForgeSyncRef, + prepareRemoteLoopLaunch, + pushAndLaunchRemoteLoop, +} from '../../src/utils/tui-remote-launch' import type { PluginConfig } from '../../src/types' import type { GitService, GitResult } from '../../src/utils/git-service' import type { ForgeClient } from '../../src/client/port' import type { RemoteClientOptions } from '../../src/client/sdk-adapter' import { createFakeForgeClient } from '../helpers/fake-client' import { createFakeGitService } from '../helpers/fake-git' +import { + makeFakeRemoteClient as makeFakeClient, + createClientSpy, + REMOTE_URL, + LOCAL_PROJECT_ID, +} from '../helpers/fake-remote-client' // ── Helpers ─────────────────────────────────────────────────────────────── const defaultOk: GitResult = { ok: true, status: 0, stdout: '', stderr: '' } -/** - * Fake ForgeClient (shared helper) with remote-loop flow defaults: a fixed - * workspace/session id pair and a `connected` status so the poll resolves - * immediately. - */ -function makeFakeClient(): ForgeClient { - const { client } = createFakeForgeClient({ - session: { - create: async () => ({ id: 'sess_remote' }), - }, - workspace: { - create: async () => ({ id: 'ws_remote', directory: '/remote/wt', branch: null }), - status: async () => [{ workspaceID: 'ws_remote', status: 'connected' }], - }, - project: { - list: async () => [{ id: 'proj_1', worktree: '/remote/my-project' }], - }, - }) - return client -} - -/** Create a `vi.fn()` based `createClient` factory that records produced clients. */ -function createClientSpy(): { - spy: ReturnType - clients: ForgeClient[] -} { - const clients: ForgeClient[] = [] - const spy = vi.fn((_opts: RemoteClientOptions) => { - const client = makeFakeClient() - clients.push(client) - return client - }) - return { spy, clients } -} - // ── Shared test values ──────────────────────────────────────────────────── const LOCAL_DIR = '/home/user/my-project' -const REMOTE_URL = 'http://remote:4096' -const LOCAL_PROJECT_ID = 'proj_1' function happyConfig(): PluginConfig { return { @@ -100,6 +81,12 @@ function happyGit(): GitService { }) } +/** True when the resolved client is the spy's second (scoped) client. */ +function clientsMatch(client: ForgeClient, spy: ReturnType): boolean { + const scoped = spy.mock.calls[1]?.[0] + return scoped !== undefined && spy.mock.results[1]?.value === client +} + // ── Tests ───────────────────────────────────────────────────────────────── beforeEach(() => { @@ -186,6 +173,31 @@ describe('executeRemoteLoop', () => { expect(remoteClient.session.promptAsync).toHaveBeenCalledTimes(1) }) + test('resolves HEAD exactly once in the preflight and reuses it as startRef', async () => { + const config = happyConfig() + const git = happyGit() + const { spy: createClient, clients } = createClientSpy() + + const result = await executeRemoteLoop( + { + remoteName: 'server1', + localDirectory: LOCAL_DIR, + localProjectId: LOCAL_PROJECT_ID, + title: 'Test Plan', + loopName: 'test-loop', + plan: '# Test Plan\n\nDo work.', + }, + { config, git, createClient: createClient as any }, + ) + + expect('error' in result).toBe(false) + expect(git.revParseHead).toHaveBeenCalledTimes(1) + expect(git.revParseHead).toHaveBeenCalledWith(LOCAL_DIR) + + const createParams = (clients[1].workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.startRef).toBe('abc123def456abc123def456abc123def456abc1') + }) + // ── Error: unknown remote name ────────────────────────────────────────── test('returns error for unknown remote name', async () => { @@ -291,7 +303,7 @@ describe('executeRemoteLoop', () => { test('returns error when git push fails', async () => { const config = happyConfig() const git = happyGit() - git.push = vi.fn(() => ({ + git.pushAsync = vi.fn(async () => ({ ok: false, status: 1, stdout: '', @@ -319,7 +331,7 @@ describe('executeRemoteLoop', () => { // createClient WAS called (discovery + scoped), but workspace.create was NOT called // because push failure stops before launchTuiLoop - expect(git.push).toHaveBeenCalledTimes(1) + expect(git.pushAsync).toHaveBeenCalledTimes(1) expect(clients.length).toBeGreaterThanOrEqual(1) // The scoped client (last one) should NOT have workspace.create called @@ -615,3 +627,375 @@ describe('executeRemoteLoop', () => { ]) }) }) + +describe('connectRemoteProject', () => { + test('resolves remote, discovers project by id, and creates a scoped client', async () => { + const config = happyConfig() + const { spy: createClient } = createClientSpy() + const debug = vi.fn() + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: LOCAL_PROJECT_ID }, + { config, createClient: createClient as any, debug }, + ) + + expect(result).toEqual({ + remote: { + name: 'server1', + url: REMOTE_URL, + password: 'sekret', + username: 'opencode', + gitRemote: 'origin', + sandbox: true, + }, + project: { id: 'proj_1', worktree: '/remote/my-project' }, + client: expect.anything(), + }) + const client = (result as { client: ForgeClient }).client + expect(clientsMatch(client, createClient)).toBe(true) + + // Discovery first (no directory), then scoped with the matched worktree + expect(createClient).toHaveBeenCalledTimes(2) + expect(createClient).toHaveBeenNthCalledWith(1, { + url: REMOTE_URL, + username: 'opencode', + password: 'sekret', + }) + expect(createClient).toHaveBeenNthCalledWith(2, { + url: REMOTE_URL, + username: 'opencode', + password: 'sekret', + directory: '/remote/my-project', + }) + }) + + test('returns error naming configured remotes for an unknown remote name', async () => { + const config = happyConfig() + const createClient = vi.fn() + + const result = await connectRemoteProject( + { remoteName: 'unknown', localProjectId: LOCAL_PROJECT_ID }, + { config, createClient: createClient as any }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('unknown') + expect(result.error).toContain('server1') + } + expect(createClient).not.toHaveBeenCalled() + }) + + test('returns error when project.list fails', async () => { + const config = happyConfig() + const clients: ForgeClient[] = [] + const createClient = vi.fn((_opts: RemoteClientOptions) => { + const client = makeFakeClient() + client.project.list = vi.fn().mockRejectedValue(new Error('boom')) as any + clients.push(client) + return client + }) + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: LOCAL_PROJECT_ID }, + { config, createClient: createClient as any }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Failed to list projects on remote "server1"') + expect(result.error).toContain('boom') + } + }) + + test('returns error when no remote project matches the local project id', async () => { + const config = happyConfig() + const clients: ForgeClient[] = [] + const createClient = vi.fn((_opts: RemoteClientOptions) => { + const client = makeFakeClient() + client.project.list = vi.fn().mockResolvedValue([ + { id: 'proj_other', worktree: '/remote/other-project' }, + ]) as any + clients.push(client) + return client + }) + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: LOCAL_PROJECT_ID }, + { config, createClient: createClient as any }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain(LOCAL_PROJECT_ID) + expect(result.error).toContain('proj_other') + } + }) + + test('returns error when the local project id is missing', async () => { + const config = happyConfig() + const { spy: createClient } = createClientSpy() + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: '' }, + { config, createClient: createClient as any }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('project id') + } + expect(createClient).not.toHaveBeenCalled() + }) + + test('calls beforeDiscovery after resolving the remote but before any client creation', async () => { + const config = happyConfig() + const createClient = vi.fn() + const order: string[] = [] + + const result = await connectRemoteProject( + { remoteName: 'server1', localProjectId: LOCAL_PROJECT_ID }, + { + config, + createClient: createClient as any, + beforeDiscovery: () => { + order.push('beforeDiscovery') + return { error: 'preflight failed' } + }, + }, + ) + + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toBe('preflight failed') + } + expect(order).toEqual(['beforeDiscovery']) + expect(createClient).not.toHaveBeenCalled() + }) +}) + +describe('pushForgeSyncRef / deleteForgeSyncRef', () => { + test('pushForgeSyncRef pushes sourceRef to syncRef with force and returns ok', async () => { + const git = happyGit() + + const result = await pushForgeSyncRef(git, { + cwd: LOCAL_DIR, + gitRemote: 'origin', + sourceRef: 'refs/heads/forge/moved', + syncRef: 'refs/forge/moved', + }) + + expect(result).toEqual({ ok: true }) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'refs/heads/forge/moved:refs/forge/moved', true) + }) + + test('pushForgeSyncRef returns ok:false with stderr on push failure', async () => { + const git = happyGit() + git.pushAsync = vi.fn(async () => ({ + ok: false, + status: 1, + stdout: '', + stderr: 'error: failed to push some refs', + })) as any + + const result = await pushForgeSyncRef(git, { + cwd: LOCAL_DIR, + gitRemote: 'origin', + sourceRef: 'refs/heads/forge/moved', + syncRef: 'refs/forge/moved', + }) + + expect(result).toEqual({ + ok: false, + error: 'error: failed to push some refs', + }) + }) + + test('deleteForgeSyncRef issues a non-forced remote ref deletion', async () => { + const git = happyGit() + + await deleteForgeSyncRef(git, { cwd: LOCAL_DIR, gitRemote: 'origin', syncRef: 'refs/forge/moved' }) + + expect(git.pushAsync).toHaveBeenCalledTimes(1) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', ':refs/forge/moved', false) + }) +}) + +function pushAndLaunchInput(git: GitService, client: ForgeClient, overrides?: Partial[0]>): Parameters[0] { + return { + git, + cwd: LOCAL_DIR, + remote: { name: 'server1', url: REMOTE_URL, username: 'opencode', gitRemote: 'origin', sandbox: true }, + project: { id: LOCAL_PROJECT_ID, worktree: '/remote/my-project' }, + client, + loopName: 'my-loop', + syncRef: 'refs/forge/my-loop', + sourceRef: 'HEAD', + startRef: 'abc123', + title: 'Title', + plan: '# Plan', + permissionOptions: {}, + ...overrides, + } +} + +describe('prepareRemoteLoopLaunch', () => { + test('reserves the loop name, derives the sync ref, and forwards permission warnings', async () => { + const config: PluginConfig = { + remotes: [{ name: 'server1', url: REMOTE_URL, password: 'sekret' }], + loop: { + permissions: { deny: ['*'] }, + }, + } + const client = makeFakeClient() + const onWarnings = vi.fn() + const debug = vi.fn() + + const result = await prepareRemoteLoopLaunch({ + client, + requestedLoopName: 'my-loop', + permissionOptions: { extraRules: [] }, + config, + dataDir: '/tmp/forge-data', + localDirectory: LOCAL_DIR, + debug, + onWarnings, + }) + + expect(result).toEqual({ loopName: 'my-loop', syncRef: 'refs/forge/my-loop' }) + expect(onWarnings).toHaveBeenCalledTimes(1) + expect(onWarnings.mock.calls[0][0]).toEqual([ + expect.stringContaining('loop.permissions.deny entry "*" is ignored'), + ]) + expect(debug).toHaveBeenCalledWith(expect.stringContaining('reserved loop name="my-loop"')) + }) + + test('does not call onWarnings when loop.permissions is clean', async () => { + const config = happyConfig() + const client = makeFakeClient() + const onWarnings = vi.fn() + + const result = await prepareRemoteLoopLaunch({ + client, + requestedLoopName: 'my-loop', + permissionOptions: {}, + config, + dataDir: '/tmp/forge-data', + localDirectory: LOCAL_DIR, + onWarnings, + }) + + expect(result).toEqual({ loopName: 'my-loop', syncRef: 'refs/forge/my-loop' }) + expect(onWarnings).not.toHaveBeenCalled() + }) +}) + +describe('pushAndLaunchRemoteLoop', () => { + test('pushes the source ref and launches with the reserved loop name and sync ref', async () => { + const git = happyGit() + const client = makeFakeClient() + const debug = vi.fn() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client, { debug })) + + expect(result).toEqual({ loopName: 'my-loop', sessionId: 'sess_remote' }) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'HEAD:refs/forge/my-loop', true) + const createParams = (client.workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.startRef).toBe('abc123') + expect(createParams.extra.syncRef).toBe('refs/forge/my-loop') + expect(createParams.extra.gitRemote).toBe('origin') + expect(createParams.extra.forgeLoop.sandboxEnabled).toBe(true) + }) + + test('returns pushed:false without deleting the sync ref when the push fails', async () => { + const git = happyGit() + git.pushAsync = vi.fn(async () => ({ + ok: false, + status: 1, + stdout: '', + stderr: 'error: failed to push some refs', + })) as any + const client = makeFakeClient() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client)) + + expect(result).toEqual({ + error: 'Failed to push to remote "server1": error: failed to push some refs', + pushed: false, + }) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + expect(client.workspace.create).not.toHaveBeenCalled() + }) + + test('returns pushed:false with the caller pushErrorPrefix when the push fails', async () => { + const git = happyGit() + git.pushAsync = vi.fn(async () => ({ + ok: false, + status: 1, + stdout: '', + stderr: 'error: failed to push some refs', + })) as any + const client = makeFakeClient() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client, { + pushErrorPrefix: 'Failed to push loop branch to remote "server1": ', + })) + + expect(result).toEqual({ + error: 'Failed to push loop branch to remote "server1": error: failed to push some refs', + pushed: false, + }) + expect(git.pushAsync).toHaveBeenCalledTimes(1) + }) + + test('deletes the sync ref exactly once when launchTuiLoop fails', async () => { + const git = happyGit() + const client = makeFakeClient({ + workspace: { + create: async () => { throw new Error('remote exploded') }, + }, + }) + const debug = vi.fn() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client, { debug })) + + expect('error' in result && result.pushed === false).toBe(true) + expect('error' in result && result.error).toContain('remote exploded') + expect(git.pushAsync).toHaveBeenCalledTimes(2) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'HEAD:refs/forge/my-loop', true) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', ':refs/forge/my-loop', false) + expect(debug).toHaveBeenCalledWith(expect.stringContaining('launchTuiLoop FAILED')) + }) + + test('deletes the sync ref exactly once and rethrows when launchTuiLoop throws', async () => { + const git = happyGit() + const client = makeFakeClient() + const { launchTuiLoop } = await import('../../src/utils/tui-client') + ;(launchTuiLoop as ReturnType).mockImplementationOnce(async () => { + throw new Error('launch boom') + }) + + await expect(pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client))).rejects.toThrow('launch boom') + + expect(git.pushAsync).toHaveBeenCalledTimes(2) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', 'HEAD:refs/forge/my-loop', true) + expect(git.pushAsync).toHaveBeenCalledWith(LOCAL_DIR, 'origin', ':refs/forge/my-loop', false) + }) + + test('sets sandboxEnabled from remote.sandbox over any forgeLoopOverrides', async () => { + const git = happyGit() + const client = makeFakeClient() + + const result = await pushAndLaunchRemoteLoop(pushAndLaunchInput(git, client, { + remote: { name: 'server1', url: REMOTE_URL, username: 'opencode', gitRemote: 'origin', sandbox: false }, + forgeLoopOverrides: { maxIterations: 40 }, + })) + + expect(result).toEqual({ loopName: 'my-loop', sessionId: 'sess_remote' }) + const createParams = (client.workspace.create as ReturnType).mock.calls[0][0] + expect(createParams.extra.forgeLoop.sandboxEnabled).toBe(false) + expect(createParams.extra.forgeLoop.maxIterations).toBe(40) + }) +})