diff --git a/README.md b/README.md index 877a76dfe3..032da50092 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Load plans dialog showing archived plans: - **Plans** — architect produces marked plans that are auto-captured to SQL storage - **Execution** — `New session`, `Execute here`, and `Loop` launch paths for approved plans - **Loops** — iterative coding/auditing with isolated git worktree and optional Docker sandbox -- **Review Findings** — persistent, branch-aware review findings across loop sessions +- **Review Findings** — persistent, loop-scoped review findings across loop sessions - **TUI** — sidebar, plan viewer/editor, execution dialog, and load-plan UI - **Sandbox** — Optional Docker worktree loop isolation with bind-mounted project files @@ -109,7 +109,7 @@ Review finding storage for persisting audit results across session rotations. | Tool | Description | |------|-------------| -| `review-write` | Store a review finding with file, line, severity, and description. Auto-injects branch field. | +| `review-write` | Store a review finding with file, line, severity, and description. Findings are scoped to the current loop. | | `review-read` | Retrieve review findings. Filter by file path or search by regex pattern. | | `review-delete` | Delete a review finding by file and line. | @@ -121,7 +121,7 @@ Iterative development loops with automatic auditing. Loops always run in an isol |------|-------------| | `loop` | Execute a plan using an iterative development loop in an isolated git worktree. Args: `title` required; `plan`, `loopName`, and `hostSessionId` 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` to resume inactive loops. | +| `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` 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 `loop` tool arguments. @@ -488,7 +488,7 @@ Loops always run in an isolated git worktree. Sandbox is optional: when Docker i ### Auditor Integration -After each coding iteration, the auditor agent reviews changes against project conventions and stored review findings. Findings are persisted via `review-write` scoped to the loop's branch. Outstanding `severity: 'bug'` findings block completion — the loop terminates only when the auditor has run at least once and zero bug-severity findings remain. +After each coding iteration, the auditor agent reviews changes against project conventions and stored review findings. Findings are persisted via `review-write` scoped to the current loop. Outstanding `severity: 'bug'` findings block completion — the loop terminates only when the auditor has run at least once and zero bug-severity findings remain. ### Stall Detection diff --git a/bun.lock b/bun.lock index d2193a7766..4e8fd153cc 100644 --- a/bun.lock +++ b/bun.lock @@ -5,23 +5,25 @@ "": { "name": "@opencode-manager/memory", "dependencies": { - "@ast-grep/cli": "^0.42.1", - "@opencode-ai/plugin": "^1.14.30", - "@opencode-ai/sdk": "^1.14.30", + "@opencode-ai/plugin": "^1.14.47", + "@opencode-ai/sdk": "^1.14.47", + "jsonc-parser": "^3.3.1", "zod": "^3.25.76", }, "devDependencies": { + "@esbuild/linux-arm64": "^0.21.5", "@eslint/js": "^10.0.1", "@opentui/core": "0.1.105", + "@opentui/keymap": "^0.2.6", "@opentui/solid": "0.1.105", + "@rollup/rollup-linux-arm64-gnu": "^4.60.3", "@types/node": "^25.6.0", "@typescript-eslint/eslint-plugin": "^8.58.0", "@typescript-eslint/parser": "^8.58.0", + "better-sqlite3": "^12.9.0", "bun-types": "latest", "eslint": "^10.2.0", - "eslint-config-prettier": "^10.1.8", "eslint-plugin-solid": "^0.14.5", - "prettier": "^3.8.1", "solid-js": "^1.9.12", "typedoc": "^0.28.19", "typedoc-plugin-markdown": "^4.11.0", @@ -42,22 +44,6 @@ "packages": { "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - "@ast-grep/cli": ["@ast-grep/cli@0.42.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.42.1", "@ast-grep/cli-darwin-x64": "0.42.1", "@ast-grep/cli-linux-arm64-gnu": "0.42.1", "@ast-grep/cli-linux-x64-gnu": "0.42.1", "@ast-grep/cli-win32-arm64-msvc": "0.42.1", "@ast-grep/cli-win32-ia32-msvc": "0.42.1", "@ast-grep/cli-win32-x64-msvc": "0.42.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-L1D7JX7p/RohtvE4IViKelWCtEYjQDvmlZ85aP4LmJVoQth/iC/+z/fCXmg6qSK8zr9IjC9okpxDZX7x1arKbQ=="], - - "@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.42.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G9rk0NAN10mybxi611CVNqwPtPO+yF0rFqPzpdgHBa4roeCq5FYsg2q/WqxM95st3jilNS9UIfZFD0i3BDfKEw=="], - - "@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.42.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-IZ1HY69zj6sj4QnHKPR71FjtuCDImhLW5v5QhTGq6Fl0T9fjhQP/g9aEk7PrJB1puOk4HhTw/J/u0wZOPmp4bA=="], - - "@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.42.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-eirVmtAciL1cXwvODYkqKEFtWgxxYyhNLTTNchdKynktFixuAmAvn0OUX0bcQnhXH5DgsdT4+1+CtvjuPc5uGQ=="], - - "@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.42.1", "", { "os": "linux", "cpu": "x64" }, "sha512-DrsV3+LkzwcaCw2AkLqM5o9ISaS4ZfJIL96RIdFRD+ydp5Mirsdw3aZdDmBqpa6nxt2NjMsFWgOivvzPiKzGAw=="], - - "@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.42.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-9DabeCAtOQEUTiCVB6fpoJ0mI21brEAa5oY2jjOzaz1SBwYuh9TPLnKBn8F+PYbUU/4Umyy26YwVg+xw4+J/Ug=="], - - "@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.42.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-xQlxTwaqiCzOUZc5lB6rp/glS0DmQd67ID6MliRZ3TH1PvX+a3oiP+QEdSgcvfcArObuKxtRGNKlOfgwMe+J8Q=="], - - "@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.42.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bOMSGeTKGfYBB1m+S0WLiA2GUkNVBaHOy+mKw27mf/kd6W2KNG3DJwAWry35qargLL0WP9PBcCaOkdnzeNyZ3A=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "7.28.5", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -264,9 +250,9 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.33", "", { "dependencies": { "@opencode-ai/sdk": "1.14.33", "effect": "4.0.0-beta.57", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.2", "@opentui/solid": ">=0.2.2" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-C99NmgKgrLHsyNvTFLmmCq7sBnEB1CtuUK+f0dzGlhcxQfV5vEOLeX3PGSWd42ko+kabHLmMWXDiKScNPvSLBA=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.5", "", { "dependencies": { "@opencode-ai/sdk": "1.15.5", "effect": "4.0.0-beta.65", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.14", "@opentui/keymap": ">=0.2.14", "@opentui/solid": ">=0.2.14" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-QvhrDLlQuLeFGET1zB2crMWPvou6PpAzYtKrbun9akWvaskyAcXIAVn3lNYX/InMcut5VzL6ERbPgvM7ucHfLA=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.33", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-LtP9oLMSxVw47AE562IagIOwxLfHLVjk/CTExkYCYtdjPXTQeU5mjyZDbahilAGeFUen9fFE/R9e933zvjF9sQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.5", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-ozJuEmXzrOvia5n0L1KAuvpyf9ESGmTk1FiPhn0RK5X1whbzjlTXL0NAxqNCEkqETxL35jS1KHArEiTpvtJ6FQ=="], "@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="], @@ -282,6 +268,8 @@ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], + "@opentui/keymap": ["@opentui/keymap@0.2.14", "", { "dependencies": { "@opentui/core": "0.2.14" }, "peerDependencies": { "@opentui/react": "0.2.14", "@opentui/solid": "0.2.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Jd4F3S98D8bJcr41jk7KcsFwwQTA0GCNKb+LoDMkPwv00k+NV6XcrXPB3QlNeM/JrVbe55pyGaT/ynZklGYHRw=="], + "@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], @@ -300,7 +288,7 @@ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], @@ -404,6 +392,8 @@ "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -424,13 +414,19 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="], + "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "2.10.12", "caniuse-lite": "1.0.30001782", "electron-to-chromium": "1.5.328", "node-releases": "2.0.36", "update-browserslist-db": "1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "1.5.1", "ieee754": "1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], @@ -454,6 +450,8 @@ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "3.1.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -462,18 +460,26 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], - "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + "effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="], "electron-to-chromium": ["electron-to-chromium@1.5.328", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -486,8 +492,6 @@ "eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "@eslint-community/regexpp": "4.12.2", "@eslint/config-array": "0.23.4", "@eslint/config-helpers": "0.5.4", "@eslint/core": "1.2.0", "@eslint/plugin-kit": "0.7.0", "@humanfs/node": "0.16.7", "@humanwhocodes/module-importer": "1.0.1", "@humanwhocodes/retry": "0.4.3", "@types/estree": "1.0.8", "ajv": "6.14.0", "cross-spawn": "7.0.6", "debug": "4.4.3", "escape-string-regexp": "4.0.0", "eslint-scope": "9.1.2", "eslint-visitor-keys": "5.0.1", "espree": "11.2.0", "esquery": "1.7.0", "esutils": "2.0.3", "fast-deep-equal": "3.1.3", "file-entry-cache": "8.0.0", "find-up": "5.0.0", "glob-parent": "6.0.2", "ignore": "5.3.2", "imurmurhash": "0.1.4", "is-glob": "4.0.3", "json-stable-stringify-without-jsonify": "1.0.1", "minimatch": "10.2.5", "natural-compare": "1.4.0", "optionator": "0.9.4" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="], - "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": "10.2.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - "eslint-plugin-solid": ["eslint-plugin-solid@0.14.5", "", { "dependencies": { "@typescript-eslint/utils": "8.58.0", "estraverse": "5.3.0", "is-html": "2.0.0", "kebab-case": "1.0.2", "known-css-properties": "0.30.0", "style-to-object": "1.0.14" }, "peerDependencies": { "eslint": "10.2.0", "typescript": "5.9.3" } }, "sha512-nfuYK09ah5aJG/oEN6P1qziy1zLgW4PDWe75VNPi4CEFYk1x2AEqwFeQfEPR7gNn0F2jOeqKhx2E+5oNCOBYWQ=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "4.3.1", "@types/estree": "1.0.8", "esrecurse": "4.3.0", "estraverse": "5.3.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -512,6 +516,8 @@ "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], @@ -528,6 +534,8 @@ "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "3.0.4", "strtok3": "6.3.0", "token-types": "4.2.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], @@ -538,6 +546,8 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -546,8 +556,12 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "4.0.0", "omggif": "1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "1.0.0", "minimatch": "8.0.7", "minipass": "4.2.8", "path-scurry": "1.11.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -566,6 +580,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -596,6 +612,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "kebab-case": ["kebab-case@1.0.2", "", {}, "sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -626,10 +644,16 @@ "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msgpackr": ["msgpackr@1.11.9", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw=="], @@ -640,14 +664,20 @@ "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "0.1.4", "fast-levenshtein": "2.0.6", "levn": "0.4.1", "prelude-ls": "1.2.1", "type-check": "0.4.0", "word-wrap": "1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -694,19 +724,23 @@ "postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], - "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "3.0.0", "buffer": "6.0.3", "events": "3.3.0", "process": "0.11.10", "string_decoder": "1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], @@ -734,6 +768,10 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-xml-to-json": ["simple-xml-to-json@1.2.4", "", {}, "sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg=="], "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "3.2.3", "seroval": "1.5.1", "seroval-plugins": "1.5.1" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], @@ -746,14 +784,24 @@ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "0.3.0", "peek-readable": "4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -776,6 +824,8 @@ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typedoc": ["typedoc@0.28.19", "", { "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", "markdown-it": "^14.1.1", "minimatch": "^10.2.5", "yaml": "^2.8.3" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw=="], @@ -796,6 +846,8 @@ "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], @@ -812,6 +864,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": "1.6.0", "xmlbuilder": "11.0.1" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], @@ -832,6 +886,8 @@ "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@opentui/keymap/@opentui/core": ["@opentui/core@0.2.14", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.14", "@opentui/core-darwin-x64": "0.2.14", "@opentui/core-linux-arm64": "0.2.14", "@opentui/core-linux-x64": "0.2.14", "@opentui/core-win32-arm64": "0.2.14", "@opentui/core-win32-x64": "0.2.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-17YCr3BqM9mhi/DdNVM+omgmrKQNIl0G5RzoaTFOHe4+OAhG+W3iooYi+WdsekJWSUOEwZqDRz0QBTZhOtgZsQ=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "7.29.0" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], @@ -846,6 +902,8 @@ "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -856,12 +914,36 @@ "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "3.0.0", "buffer": "6.0.3", "events": "3.3.0", "process": "0.11.10", "string_decoder": "1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "rollup/@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], + + "@opentui/keymap/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iS4NZQkOKX2EP5rsNjDcU7inDLcKhPaSBn8ENjDXKx2smOh7p/rgM2qlEaiLI3njtL784QoF+nxTzSXbEI6+Jw=="], + + "@opentui/keymap/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft4ZwYHCV0VtRMwQtHH5mAgwqRLHEXP26DWcwtCZWDEHDvghClBR0cj9UZLH5JAKn/j7ds5hZDCCZz+nUiEHYA=="], + + "@opentui/keymap/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-t/EKD4+rlzWuwYAa6NzGCmiBOHvF+hzjNwExj+dnSqX5wK7TU+VHl+N2iYUl4VhhJK94kPP6BnrF5GcHnZGFLg=="], + + "@opentui/keymap/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Lvqmd92UZ+KZVnr0xU0jYj4XqnCSsBQJHS/FpYkJgZSAN7/4NmPlgMvQXIGW8a3BcFaGRKe8LuGpqM2E4oaX0Q=="], + + "@opentui/keymap/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jnuud29daaEoZNEp80dxUDLyUcwLr+g6SruHPyyWerOe7J10JE1ihJNkDlXLT7T49xdBGYRQlRkuNGwRfZWx5A=="], + + "@opentui/keymap/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-2ZUNh7yaAMUwAOK8oFEO28qXqPFrWPGGD0KHK1Gp97Th9XuVZniMLtbkbrFtDRh15+PVj7MyrG0N967W7rLr1A=="], + + "@opentui/keymap/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], + + "@opentui/keymap/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "3.0.0", "path-exists": "3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "1.5.1", "ieee754": "1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "2.3.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/docs/architecture.md b/docs/architecture.md index 154f273f13..3825b2faa1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -194,9 +194,10 @@ The plugin follows this initialization sequence within `createForgePlugin()`: 7. **Database** - Initialize SQLite storage (`initializeDatabase()`) 8. **Repositories** - Create typed repos (loops, plans, reviewFindings, sectionPlans) 9. **Loop Event Handler** - Connect loop runtime to events and state management -10. **Stale Loop Reconciliation** - Reconcile loops from previous session (cancel stalled, preserve active sandboxes, restart candidates) -11. **Tools and Agents** - Register all tools (`createTools()`) and agents (`buildAgents()`) -12. **Hooks** - Final registration of all hook points +10. **Tools and Agents** - Register all tools (`createTools()`) and agents (`buildAgents()`) +11. **Hooks** - Final registration of all hook points + +**Note:** Plugin initialization does not recover, cancel, or restart loops. Boot initializes storage and runtime services only. Loop recovery and restart are explicit user actions via `loop-status restart=true`. ## Cleanup diff --git a/docs/loop-system.md b/docs/loop-system.md index dee5d628d5..2d7aa7783a 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -2,6 +2,34 @@ The loop system provides autonomous iterative development with automatic code auditing. +## Loop Lifecycle Rules + +### Plugin Boot Behavior + +- **Plugin boot does not mutate loop rows.** Initialization loads storage and runtime services only. +- No loops are recovered, cancelled, restarted, or reconciled during plugin startup. +- Loop recovery and restart are explicit user actions via `loop-status restart=true`. + +### Restartability + +- **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. +- **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. + +### Stale Workspace Sweep + +- Stale workspace sweep is **teardown cleanup-only**, not boot-time recovery. +- Sweep removes workspace registrations for non-running restartable loops (`cancelled`, `errored`, `stalled`) while preserving worktrees for manual restart. +- Completed loops are fully removed (registration + worktree). +- Running loops are never touched by sweep. + ## Loop Lifecycle ```mermaid diff --git a/docs/modules.md b/docs/modules.md index 60047d2035..ca651636ca 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -135,7 +135,7 @@ The heart of Forge. Implements autonomous iterative development with phases: `co | `index.ts` | Public API barrel (all re-exports) | | `runtime.ts` | `createLoop()` factory, `Loop` interface (~2100 lines) | | `service.ts` | DB-backed `LoopService` (`createLoopService`) | -| `state.ts` | Discriminated union `LoopState` (4 phases), converters | +| `state.ts` | Discriminated union `LoopState` (3 phases: `coding`, `auditing`, `final_auditing`), converters | | `transitions.ts` | Pure `nextTransition()` table | | `termination.ts` | `TerminationReason` union, `terminationStatusFor()` | | `prompts.ts` | Prompt builders for each loop phase | diff --git a/package.json b/package.json index 01559c39e0..4be24d6c4d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-forge", - "version": "0.4.4", + "version": "0.4.5", "type": "module", "oc-plugin": [ "server", diff --git a/src/agents/architect.ts b/src/agents/architect.ts index 885573f1be..55e537cfb1 100644 --- a/src/agents/architect.ts +++ b/src/agents/architect.ts @@ -77,7 +77,15 @@ The plugin auto-captures marked plans from your assistant responses into SQL sto Present plans with: - **Objective**: What we're building and why - **Loop Name**: A short, machine-friendly name (1-3 words) that captures the plan's main intent. This will be used for worktree/session naming. Example: "Loop Name: auth-refactor" or "Loop Name: api-validation" -- **Phases**: Ordered implementation steps. Every executable section MUST be preceded by a \`\` marker on its own line. For every phase, specify the exact files affected, the precise code-level edits to make, sample change examples (such as function signature updates, new branches, or new exports), the existing symbols/modules being integrated with, concrete acceptance criteria, and phase-specific verification. Use \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, and \`### Verification\` inside each phase. Place a \`\` marker on its own line immediately before each section's heading. Shared blocks (\`## Decisions\`, \`## Conventions\`, \`## Key Context\`) go after all sections without a preceding marker. +- **Phases**: Ordered implementation steps. Use exactly one \`\` marker per executable phase. Place it immediately before that phase's \`## Phase ...\` heading. Never place it before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\` — those are subsections inside the current phase. For every phase, specify the exact files affected, the precise code-level edits to make, sample change examples (such as function signature updates, new branches, or new exports), the existing symbols/modules being integrated with, concrete acceptance criteria, and phase-specific verification. Use \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, and \`### Verification\` as subsections inside each phase. Shared blocks (\`## Decisions\`, \`## Conventions\`, \`## Key Context\`) go after all sections without a preceding marker. + + **Valid shape:** + \`\` + \`## Phase 1: ...\` + \`### Files\` + \`### Edits\` + \`### Acceptance Criteria\` + \`### Verification\` - **Verification**: Concrete criteria the code agent can validate automatically inside the loop. Every plan MUST include verification. Plans without verification are incomplete. Plans must be **detailed, self-contained, and implementation-ready**. The code agent should be able to execute the plan without inferring missing scope, files, APIs, data shapes, or verification steps. Every phase must be specific enough that another engineer could make the described edits directly from the plan. Each plan must include: diff --git a/src/hooks/forge-session-attach.ts b/src/hooks/forge-session-attach.ts index 3f7d69bcc7..30ab220f74 100644 --- a/src/hooks/forge-session-attach.ts +++ b/src/hooks/forge-session-attach.ts @@ -209,10 +209,10 @@ async function attachForgeSession( ) return } else if (action.action === 'remove-registration-only') { - // Restartable-terminal (cancelled/errored/stalled): remove registration, preserve worktree for manual restart + // Restartable (cancelled/errored/stalled): remove registration, preserve worktree for manual restart await removeForgeWorkspaceWithContext( { v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, - { workspaceId, loopName, action: 'remove-registration-only', reasonLabel: 'attach-safety-net-restartable-terminal' }, + { workspaceId, loopName, action: 'remove-registration-only', reasonLabel: 'attach-safety-net-restartable' }, ) publishAttachFailureToast( deps, diff --git a/src/index.ts b/src/index.ts index 7c4edf98a6..71a8992cad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,6 @@ import { resolveLogPath } from './storage' import { createLogger } from './utils/logger' import { createDockerService } from './sandbox/docker' import { createSandboxManager } from './sandbox/manager' -import { reconcileSandboxes } from './sandbox/reconcile' import type { PluginConfig, CompactionConfig } from './types' import { createTools } from './tools' import { createToolExecuteBeforeHook, createToolExecuteAfterHook, createPlanApprovalEventHook } from './hooks' @@ -272,79 +271,6 @@ export function createForgePlugin(config: PluginConfig): Plugin { const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, client, v2, logger, () => config, sandboxManager || undefined, dataDir, config.loop, sectionPlansRepo, notifyLoopChange, pendingTeardowns, loopSessionUsageRepo) - const reconcileResult = await loopHandler.loop.reconcileStale( - sandboxManager - ? { isSandboxLive: (name: string) => sandboxManager!.isLiveByName(name) } - : undefined - ) - if (reconcileResult.cancelled > 0) { - logger.log(`Reconciled ${reconcileResult.cancelled} stale loop(s) from previous session`) - } - if (reconcileResult.preserved.length > 0) { - logger.log(`Preserved ${reconcileResult.preserved.length} active sandbox loop(s) across restart: ${reconcileResult.preserved.join(', ')}`) - } - - if (reconcileResult.restartCandidates.length > 0) { - const { existsSync } = await import('node:fs') - const { createForgeExecutionService } = await import('./services/execution') - const restartService = createForgeExecutionService({ - projectId, - directory, - config, - logger, - dataDir, - v2, - legacyClient: client, - plansRepo, - loopsRepo, - loopHandler, - loop: loopHandler.loop, - sandboxManager, - sectionPlansRepo, - reviewFindingsRepo, - workspaceStatusRegistry, - pendingTeardowns, - }) - - let restored = 0 - let restoreFailed = 0 - for (const candidate of reconcileResult.restartCandidates) { - if (!candidate.worktreeDir || !existsSync(candidate.worktreeDir)) { - logger.log(`Loop: cannot auto-restart ${candidate.loopName}, worktree missing at ${candidate.worktreeDir}`) - loopHandler.loop.reconcileFinalize(candidate.loopName, 'cancel') - restoreFailed++ - continue - } - try { - const result = await restartService.dispatch( - { surface: 'tool', projectId, directory: candidate.projectDir ?? candidate.worktreeDir }, - { type: 'loop.restart', selector: { kind: 'partial', name: candidate.loopName }, force: true }, - ) - if (result.ok) { - loopHandler.loop.reconcileFinalize(candidate.loopName, 'restored') - restored++ - } else { - logger.error(`Loop: auto-restart failed for ${candidate.loopName}: ${result.error.message}`) - loopHandler.loop.reconcileFinalize(candidate.loopName, 'cancel') - restoreFailed++ - } - } catch (err) { - logger.error(`Loop: auto-restart threw for ${candidate.loopName}`, err) - loopHandler.loop.reconcileFinalize(candidate.loopName, 'cancel') - restoreFailed++ - } - } - if (restored > 0) { - logger.log(`Auto-restored ${restored} loop(s) across plugin restart`) - } - if (restoreFailed > 0) { - logger.log(`Auto-restart unavailable for ${restoreFailed} loop(s); cancelled`) - } - } - - // Sandbox reconciliation interval handle - let sandboxReconcileInterval: ReturnType | null = null - const agents = buildAgents() const compactionConfig: CompactionConfig | undefined = config.compaction @@ -365,12 +291,6 @@ export function createForgePlugin(config: PluginConfig): Plugin { process.removeListener('SIGINT', handleSigint) process.removeListener('SIGTERM', handleSigterm) - // Clear sandbox reconciliation interval - if (sandboxReconcileInterval) { - clearInterval(sandboxReconcileInterval) - sandboxReconcileInterval = null - } - logger.log('Loop: active loops preserved during plugin cleanup') loopHandler.clearAllRetryTimeouts() @@ -413,16 +333,9 @@ export function createForgePlugin(config: PluginConfig): Plugin { pendingTeardowns, } - if (sandboxManager) { - const reconcileDeps = { sandboxManager, loop: loopHandler.loop, logger } - await reconcileSandboxes(reconcileDeps) - - sandboxReconcileInterval = setInterval(() => { - reconcileSandboxes(reconcileDeps).catch((err) => { - logger.error('Sandbox reconciliation failed', err) - }) - }, 2000) - } + // Sandbox reconciliation interval removed per Phase 2 requirements. + // Sandbox reconciliation now only occurs for loops started/restarted + // in the current plugin process, triggered by explicit runtime events. // Create forge-session-attach hook for triggering attachLoopToSession on session.created events const forgeAttachExecDeps = { @@ -578,7 +491,8 @@ READ-ONLY mode: no file edits, no destructive commands. Search and analyze only. When emitting the final plan: - Wrap the plan in \`\` and \`\` (each on its own line) -- Insert \`\` on its own line before each executable section +- Use exactly one \`\` marker per executable phase; place it immediately before that phase's \`## Phase\` heading +- Do not insert \`\` before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\` - Shared \`## Decisions\` / \`## Conventions\` / \`## Key Context\` blocks go after all sections (no preceding marker) - After the plan, call the \`question\` tool with options: "New session", "Execute here", "Loop" `, diff --git a/src/loop/restartability.ts b/src/loop/restartability.ts new file mode 100644 index 0000000000..3933426311 --- /dev/null +++ b/src/loop/restartability.ts @@ -0,0 +1,85 @@ +/** + * Shared restartability logic for determining if a loop can be restarted. + * Used by both execution service and tooling/display layers. + */ + +import { existsSync } from 'fs' +import type { LoopState } from '../loop/state' +import { parseTerminationReasonString } from '../loop' + +export type RestartBlockedReason = + | 'completed' + | 'missing_worktree' + | 'active_requires_force' + +export interface RestartabilityResult { + restartable: boolean + restartRequiresForce: boolean + restartBlockedReason?: RestartBlockedReason + restartBlockedMessage?: string +} + +/** + * Determine if a loop can be restarted based on its state. + * + * Rules: + * - Completed loops cannot restart (checked by status field and terminationReason) + * - Missing worktree blocks restart + * - Active/running loops require force + * - All other terminal states (cancelled, errored, stalled) are restartable without force + */ +export function getRestartability( + state: LoopState, + opts?: { force?: boolean; worktreeExists?: (path: string) => boolean } +): RestartabilityResult { + const worktreeExists = opts?.worktreeExists ?? existsSync + + // Completed loops cannot restart - check persisted status first + if (state.status === 'completed') { + return { + restartable: false, + restartRequiresForce: false, + restartBlockedReason: 'completed', + restartBlockedMessage: `Loop "${state.loopName}" completed successfully and cannot be restarted.`, + } + } + + // Also check terminationReason for legacy/secondary validation + if (state.terminationReason) { + const parsed = parseTerminationReasonString(state.terminationReason) + if (parsed.kind === 'completed') { + return { + restartable: false, + restartRequiresForce: false, + restartBlockedReason: 'completed', + restartBlockedMessage: `Loop "${state.loopName}" completed successfully and cannot be restarted.`, + } + } + } + + // Missing worktree blocks restart + if (state.worktree && state.worktreeDir && !worktreeExists(state.worktreeDir)) { + return { + restartable: false, + restartRequiresForce: false, + restartBlockedReason: 'missing_worktree', + restartBlockedMessage: `Cannot restart "${state.loopName}": worktree directory no longer exists at ${state.worktreeDir}.`, + } + } + + // Active/running loops require force + if (state.active) { + return { + restartable: true, + restartRequiresForce: true, + restartBlockedReason: 'active_requires_force', + restartBlockedMessage: `Loop "${state.loopName}" is currently active. Use force=true to force-restart a stuck loop.`, + } + } + + // All other terminal states (cancelled, errored, stalled) are restartable without force + return { + restartable: true, + restartRequiresForce: false, + } +} diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 3e4945bd6f..1a52c35c66 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -32,6 +32,7 @@ import type { TerminationReason } from './termination' import { terminationStatusFor, terminationReasonToString } from './termination' import { nextTransition } from './transitions' import { summarizeAssistantUsage, type UsageAttribution } from './token-usage' +import { loopRegistry } from '../utils/loop-registry' export interface LoopEvent { type: string @@ -109,8 +110,6 @@ export interface Loop { resetError(name: string): void terminateLoop(name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number; summary?: string }): void getOutstandingFindings(loopName?: string, severity?: 'bug' | 'warning'): ReviewFindingRow[] - reconcileStale(opts?: { isSandboxLive?: (loopName: string) => Promise }): Promise<{ cancelled: number; preserved: string[]; restartCandidates: LoopState[] }> - reconcileFinalize(loopName: string, action: 'cancel' | 'restored'): void getStallTimeoutMs(): number getMaxConsecutiveStalls(): number @@ -880,6 +879,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { async function terminateLoop(loopName: string, state: LoopState, reason: TerminationReason): Promise { const sessionId = state.sessionId watchdog.stop(loopName) + loopRegistry.remove(loopName) const retryTimeout = retryTimeouts.get(loopName) if (retryTimeout) { @@ -2013,6 +2013,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { loopService.setState(state.loopName, state) loopService.registerLoopSession(state.sessionId, state.loopName) + loopRegistry.add(state.loopName) logger.log(`Loop: started loop=${state.loopName} session=${state.sessionId}`) } @@ -2020,6 +2021,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { loopService.deleteState(name) loopService.setState(name, params.newState) loopService.registerLoopSession(params.newSessionId, name) + loopRegistry.add(name) } function setPhase(name: string, phase: LoopState['phase']): void { @@ -2068,8 +2070,6 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { resetError: (name: string) => loopService.resetError(name), terminateLoop: (name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number; summary?: string }) => loopService.terminate(name, opts), getOutstandingFindings: (loopName?: string, severity?: 'bug' | 'warning') => loopService.getOutstandingFindings(loopName, severity), - reconcileStale: (opts?: { isSandboxLive?: (loopName: string) => Promise }) => loopService.reconcileStale(opts), - reconcileFinalize: (loopName: string, action: 'cancel' | 'restored') => loopService.reconcileFinalize(loopName, action), getStallTimeoutMs: () => loopService.getStallTimeoutMs(), getMaxConsecutiveStalls: () => loopService.getMaxConsecutiveStalls(), diff --git a/src/loop/service.ts b/src/loop/service.ts index 76466ee578..c38e8a7341 100644 --- a/src/loop/service.ts +++ b/src/loop/service.ts @@ -26,7 +26,7 @@ export type LoopChangeReason = | 'rotate' | 'phase' | 'iteration' | 'status' | 'session' | 'sandbox' | 'workspace' | 'audit-result' - | 'model-failed' | 'error' | 'reconcile' + | 'model-failed' | 'error' export type LoopChangeNotifier = (reason: LoopChangeReason, loopName: string, hint?: { projectDir?: string; worktreeDir?: string }) => void @@ -45,8 +45,6 @@ export interface LoopService { getStallTimeoutMs(): number getMaxConsecutiveStalls(): number terminateAll(): Promise - reconcileStale(opts?: { isSandboxLive?: (loopName: string) => Promise }): Promise<{ cancelled: number; preserved: string[]; restartCandidates: LoopState[] }> - reconcileFinalize(loopName: string, action: 'cancel' | 'restored'): void hasOutstandingFindings(loopName?: string, severity?: 'bug' | 'warning'): boolean getOutstandingFindings(loopName?: string, severity?: 'bug' | 'warning'): ReviewFindingRow[] generateUniqueLoopName(baseName: string): string @@ -98,6 +96,7 @@ export function rowToLoopState(row: LoopRow, large: LoopLargeFields | null): Loo lastAuditResult: large?.lastAuditResult ?? undefined, errorCount: row.errorCount, auditCount: row.auditCount, + status: row.status, terminationReason: row.terminationReason ?? undefined, completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, worktree: row.worktree, @@ -132,7 +131,7 @@ export function createLoopService( return { projectId, loopName: state.loopName, - status: state.active ? 'running' : 'completed', + status: state.status, currentSessionId: state.sessionId, worktree: state.worktree ?? false, worktreeDir: state.worktreeDir, @@ -326,86 +325,6 @@ export function createLoopService( logger.log(`Loop: terminated ${String(active.length)} active loop(s)`) } - async function reconcileStale(opts?: { isSandboxLive?: (loopName: string) => Promise }): Promise<{ cancelled: number; preserved: string[]; restartCandidates: LoopState[] }> { - const active = listActive() - const now = Date.now() - const preserved: string[] = [] - const restartCandidates: LoopState[] = [] - let cancelled = 0 - - // Back-compatible path: no opts means cancel everything (old behavior) - if (!opts?.isSandboxLive) { - for (const state of active) { - loopsRepo.terminate(projectId, state.loopName, { - status: 'cancelled', - reason: 'shutdown', - completedAt: now, - }) - notifyLoopChange('reconcile', state.loopName, { projectDir: state.projectDir, worktreeDir: state.worktreeDir }) - logger.log(`Reconciled stale active loop: ${state.loopName} (was at iteration ${String(state.iteration)})`) - } - return { cancelled: active.length, preserved: [], restartCandidates: [] } - } - - // Selective path: preserve loops with live sandbox containers; flag dead-sandbox loops - // with intact worktrees as restart candidates (caller decides whether to actually restart). - for (const state of active) { - const eligibleForPreserve = - !!state.sandbox && - !!state.worktree && - !!state.worktreeDir && - !!state.sandboxContainer && - !!state.loopName - - const live = eligibleForPreserve ? await opts.isSandboxLive(state.loopName) : false - - if (live) { - preserved.push(state.loopName) - logger.log(`Loop: preserved active sandbox loop across plugin restart: ${state.loopName} (iteration ${String(state.iteration)})`) - continue - } - - const eligibleForRestart = - !!state.sandbox && - !!state.worktree && - !!state.worktreeDir && - !!state.loopName - - if (eligibleForRestart) { - restartCandidates.push(state) - logger.log(`Loop: queued for auto-restart attempt: ${state.loopName} (iteration ${String(state.iteration)}, phase=${state.phase})`) - continue - } - - loopsRepo.terminate(projectId, state.loopName, { - status: 'cancelled', - reason: 'shutdown', - completedAt: now, - }) - notifyLoopChange('reconcile', state.loopName, { projectDir: state.projectDir, worktreeDir: state.worktreeDir }) - logger.log(`Reconciled stale active loop: ${state.loopName} (was at iteration ${String(state.iteration)})`) - cancelled++ - } - - return { cancelled, preserved, restartCandidates } - } - - function reconcileFinalize(loopName: string, action: 'cancel' | 'restored'): void { - const state = getAnyState(loopName) - if (action === 'cancel') { - loopsRepo.terminate(projectId, loopName, { - status: 'cancelled', - reason: 'shutdown', - completedAt: Date.now(), - }) - notifyLoopChange('reconcile', loopName, state ? { projectDir: state.projectDir, worktreeDir: state.worktreeDir } : undefined) - logger.log(`Loop: auto-restart unavailable, cancelled stale loop: ${loopName}`) - } else { - notifyLoopChange('reconcile', loopName, state ? { projectDir: state.projectDir, worktreeDir: state.worktreeDir } : undefined) - logger.log(`Loop: auto-restored stale loop across plugin restart: ${loopName}`) - } - } - function getOutstandingFindings(loopName?: string, severity?: 'bug' | 'warning'): ReviewFindingRow[] { const rows = loopName ? reviewFindingsRepo.listByLoopName(projectId, loopName) : reviewFindingsRepo.listAll(projectId) return severity ? rows.filter((r) => r.severity === severity) : rows @@ -593,8 +512,6 @@ export function createLoopService( getStallTimeoutMs, getMaxConsecutiveStalls, terminateAll, - reconcileStale, - reconcileFinalize, hasOutstandingFindings, getOutstandingFindings, generateUniqueLoopName, diff --git a/src/loop/state.ts b/src/loop/state.ts index 514c7511b2..fbb226b8c4 100644 --- a/src/loop/state.ts +++ b/src/loop/state.ts @@ -14,6 +14,7 @@ interface LoopStateBase { lastAuditResult?: string errorCount: number auditCount: number + status: LoopRow['status'] terminationReason?: string completedAt?: string worktree?: boolean @@ -60,6 +61,7 @@ export function loopRowToState(row: LoopRow, large?: LoopLargeFields | null): Lo lastAuditResult: large?.lastAuditResult ?? undefined, errorCount: row.errorCount, auditCount: row.auditCount, + status: row.status, terminationReason: row.terminationReason ?? undefined, completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, worktree: row.worktree, @@ -90,7 +92,7 @@ export function loopStateToRow(state: LoopState, projectId: string): Omit() * 2. Container names are persisted in loop state * 3. Stale container references are restored * + * IMPORTANT: Only loops registered in the current plugin process are reconciled. + * Pre-existing persisted loops are NOT reconciled to avoid boot-time recovery. + * * The function includes a re-entrancy guard to prevent concurrent executions. * * @param deps - Dependencies including sandbox manager, loop service, and logger @@ -45,8 +53,14 @@ export async function reconcileSandboxes(deps: ReconcileSandboxesDeps): Promise< try { const activeLoops = loop.listActive() + const registeredLoops = loopRegistry.getAll() for (const state of activeLoops) { + // Only reconcile loops that were started/restarted in the current process. + // This prevents boot-time recovery of pre-existing persisted loops. + if (!registeredLoops.includes(state.loopName)) { + continue + } // Only process loops with sandbox enabled, worktree mode, and a worktree directory. if (state.sandbox !== true || !state.worktreeDir || !state.loopName) { continue diff --git a/src/services/execution.ts b/src/services/execution.ts index 090b6f41c6..a398d4355d 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -29,6 +29,7 @@ import { ConcurrentPromptError, type PromptAgent, } from '../loop/in-flight-guard' +import { getRestartability, type RestartBlockedReason } from '../loop/restartability' // ============================================================================ // Surface Types - Identifies the caller boundary @@ -308,6 +309,10 @@ export interface LoopStatusView { totalSections?: number finalAuditDone?: boolean usage?: import('../loop/token-usage').LoopUsageSummary + restartable: boolean + restartRequiresForce: boolean + restartBlockedReason?: RestartBlockedReason + restartBlockedMessage?: string sections?: Array<{ index: number title: string @@ -827,6 +832,7 @@ export async function attachLoopToSession( phase: 'coding', errorCount: 0, auditCount: 0, + status: 'running', worktree: true, sandbox: sandboxEnabled, sandboxContainer: sandboxContainer ?? undefined, @@ -1513,6 +1519,8 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo } } + const restartability = getRestartability(state, { worktreeExists: existsSync }) + return { loopName: state.loopName, displayName: state.loopName, // Could extract from plan if needed @@ -1536,6 +1544,10 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo totalSections: state.totalSections, finalAuditDone: state.finalAuditDone, usage, + restartable: restartability.restartable, + restartRequiresForce: restartability.restartRequiresForce, + restartBlockedReason: restartability.restartBlockedReason, + restartBlockedMessage: restartability.restartBlockedMessage, sections: sectionViews, } }) @@ -1639,27 +1651,15 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (!stoppedState) { return fail('not_found', 404, `No loop found for "${name}".`, undefined, allStates.map(s => s.loopName)) } - if (stoppedState.active && !command.force) { - return fail('conflict', 409, `Loop "${stoppedState.loopName}" is currently active. Use force=true to force-restart a stuck loop.`) - } - if (stoppedState.terminationReason && parseTerminationReasonString(stoppedState.terminationReason).kind === 'completed') { - return fail('conflict', 409, `Loop "${stoppedState.loopName}" completed successfully and cannot be restarted.`) - } - if ( - stoppedState.terminationReason && - parseTerminationReasonString(stoppedState.terminationReason).kind === 'final_audit_retry_exhausted' && - !command.force - ) { - return fail( - 'conflict', - 409, - `Loop "${stoppedState.loopName}" terminated during final audit retry exhaustion. Use force=true to restart.`, - ) + + const restartability = getRestartability(stoppedState, { force: command.force, worktreeExists: existsSync }) + + if (!restartability.restartable) { + return fail('conflict', 409, restartability.restartBlockedMessage!) } - if (stoppedState.worktree && stoppedState.worktreeDir) { - if (!existsSync(stoppedState.worktreeDir)) { - return fail('conflict', 409, `Cannot restart "${stoppedState.loopName}": worktree directory no longer exists at ${stoppedState.worktreeDir}.`) - } + + if (restartability.restartRequiresForce && !command.force) { + return fail('conflict', 409, restartability.restartBlockedMessage!) } const restartSandbox = isSandboxEnabled(deps.config, deps.sandboxManager) @@ -1796,6 +1796,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo phase: restartPhase, errorCount: 0, auditCount: 0, + status: 'running', worktree: stoppedState.worktree, sandbox: restartSandbox, sandboxContainer: restartSandbox ? deps.sandboxManager?.docker.containerName(stoppedState.loopName) : undefined, diff --git a/src/storage/migrations/118_drop_audit_session_id_from_loops.sql b/src/storage/migrations/118_drop_audit_session_id_from_loops.sql index 9b127e745d..d0f0baf338 100644 --- a/src/storage/migrations/118_drop_audit_session_id_from_loops.sql +++ b/src/storage/migrations/118_drop_audit_session_id_from_loops.sql @@ -5,7 +5,6 @@ -- -- Note: If any loops are currently in phase=auditing at deploy time, the -- audit_session_id value is lost. This is acceptable because loops do not --- persist across forge restarts in any meaningful way (they get reconciled --- stale on startup via reconcileStale). +-- persist across forge restarts (boot-time recovery was removed). ALTER TABLE loops DROP COLUMN audit_session_id; diff --git a/src/tools/loop.ts b/src/tools/loop.ts index 280fee771b..c6675defb1 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -8,6 +8,7 @@ import { formatDuration, computeElapsedSeconds } from '../utils/loop-helpers' import { buildStartLoopCommand, createForgeExecutionService, type ForgeExecutionRequestContext, type PlanSource } from '../services/execution' import { captureLatestPlanForSession } from '../services/plan-capture' import { formatLoopSessionTitle, formatPlanSessionTitle } from '../utils/session-titles' +import { getRestartability } from '../loop/restartability' const z = tool.schema @@ -160,10 +161,10 @@ export function createLoopTools(ctx: ToolContext): Record { @@ -229,11 +230,11 @@ export function createLoopTools(ctx: ToolContext): Record { const durationStr = formatDuration(computeElapsedSeconds(s.startedAt, s.completedAt)) lines.push(`${i + 1}. ${s.loopName}`) - lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) + lines.push(` Status: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) lines.push('') }) lines.push('Use loop-status for detailed info.') @@ -273,13 +274,13 @@ export function createLoopTools(ctx: ToolContext): Record 0) { - lines.push('Recently Completed:') + lines.push('Recent Loops:') lines.push('') const limitedRecent = recent.slice(0, 10) limitedRecent.forEach((s, i) => { const durationStr = formatDuration(computeElapsedSeconds(s.startedAt, s.completedAt)) lines.push(`${i + 1}. ${s.loopName}`) - lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) + lines.push(` Status: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) lines.push('') }) if (recent.length > 10) { @@ -289,6 +290,7 @@ export function createLoopTools(ctx: ToolContext): Record for detailed info, or loop-cancel to stop a loop.') + lines.push('Use loop-status restart=true force=true to force-restart a stuck running loop.') return lines.join('\n') } @@ -350,6 +352,18 @@ export function createLoopTools(ctx: ToolContext): Record() + +export const loopRegistry = { + /** + * Register a loop as started/restarted in the current process. + */ + add(loopName: string): void { + activeLoops.add(loopName) + }, + + /** + * Remove a loop from the registry (e.g., on termination). + */ + remove(loopName: string): void { + activeLoops.delete(loopName) + }, + + /** + * Check if a loop was started/restarted in the current process. + */ + has(loopName: string): boolean { + return activeLoops.has(loopName) + }, + + /** + * Get all registered loop names. + */ + getAll(): string[] { + return Array.from(activeLoops) + }, + + /** + * Clear all registered loops (useful for testing). + */ + clear(): void { + activeLoops.clear() + }, +} diff --git a/src/workspace/classify-stale.ts b/src/workspace/classify-stale.ts index 8815f54e24..109ac08d13 100644 --- a/src/workspace/classify-stale.ts +++ b/src/workspace/classify-stale.ts @@ -21,7 +21,7 @@ export interface ForgeWorkspaceEntry { export type ClassifyAction = | { action: 'keep'; reason: 'running' | 'pending-attach' | 'pending-start' | 'not-forge' | 'no-loop-name' | 'no-project-directory' | 'wrong-project' } - | { action: 'remove-registration-only'; reason: 'restartable-terminal'; loopName: string } + | { action: 'remove-registration-only'; reason: 'restartable'; loopName: string } | { action: 'remove-fully'; reason: 'completed' | 'missing-row'; loopName: string } export interface ClassifyForgeWorkspaceOptions { @@ -72,7 +72,7 @@ export function isPendingStartWorkspace( * 7. loopsRepo.get returns null → remove-fully/missing-row * 8. loop status === 'running' → keep/running * 9. loop status === 'completed' → remove-fully/completed - * 10. loop status in ['cancelled', 'errored', 'stalled'] → remove-registration-only/restartable-terminal + * 10. loop status in ['cancelled', 'errored', 'stalled'] → remove-registration-only/restartable */ export function classifyForgeWorkspace( entry: ForgeWorkspaceEntry, @@ -125,10 +125,10 @@ export function classifyForgeWorkspace( return { action: 'remove-fully', reason: 'completed', loopName } } - // Check 10: restartable terminal statuses (cancelled, errored, stalled) - // Remove registration only, preserve worktree for manual restart + // Check 10: non-running restartable loops (cancelled, errored, stalled) + // Remove registration only, preserve worktree for restart if (row.status === 'cancelled' || row.status === 'errored' || row.status === 'stalled') { - return { action: 'remove-registration-only', reason: 'restartable-terminal', loopName } + return { action: 'remove-registration-only', reason: 'restartable', loopName } } // Fallback: should not reach here, but treat as keep to be safe diff --git a/src/workspace/sweep-stale.ts b/src/workspace/sweep-stale.ts index 998b1888ff..9f50bec3e3 100644 --- a/src/workspace/sweep-stale.ts +++ b/src/workspace/sweep-stale.ts @@ -3,7 +3,7 @@ * * Invoked from teardownWorktree in host-side-effects.ts after the terminating * loop's own workspace is removed. Scans same-project forge workspaces and - * removes those classified as stale (completed, missing-row, restartable-terminal). + * removes those classified as stale (completed, missing-row, non-running restartable loops). * * The sweep is fire-and-forget: failures are logged but never block the teardown. */ diff --git a/test/agents.test.ts b/test/agents.test.ts index 2489dd14e1..54ceb9ea0e 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -132,6 +132,18 @@ describe('Agent definitions', () => { expect(prompt).toContain('### Edits') expect(prompt).toContain('### Acceptance Criteria') expect(prompt).toContain('### Verification') + // New explicit rules + expect(prompt).toContain('exactly one') + expect(prompt).toContain('immediately before') + expect(prompt).toContain('## Phase') + expect(prompt).toContain('Never place') + }) + + test('architect.systemPrompt prohibits markers before subsection headings', () => { + const prompt = architectAgent.systemPrompt + // The prompt must not say "before each section's heading" without clarifying + // it means the ## Phase heading, not ### subsection headings + expect(prompt).not.toMatch(/before each section'?s? heading/i) }) test('architect.systemPrompt does not duplicate marker self-check (moved to reminder)', () => { diff --git a/test/boot-sandbox-preserve.test.ts b/test/boot-sandbox-preserve.test.ts deleted file mode 100644 index 30603434cc..0000000000 --- a/test/boot-sandbox-preserve.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { describe, it, expect, mock } from 'bun:test' -import { createLoopService } from '../src/loop/service' -import type { LoopsRepo, LoopRow } 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 { Logger } from '../src/types' -import type { SandboxManager } from '../src/sandbox/manager' -import { reconcileSandboxes, type ReconcileSandboxesDeps } from '../src/sandbox/reconcile' - -describe('boot sandbox preserve integration', () => { - function createMockRepos() { - const mockLoopsRepo = { - insert: mock(() => true), - get: mock(() => null), - getLarge: mock(() => null), - delete: mock(() => {}), - setStatus: mock(() => {}), - setCurrentSessionId: mock(() => {}), - getBySessionId: mock(() => null), - findPartial: mock(() => ({ match: null, candidates: [] })), - listByStatus: mock(() => []), - updatePhase: mock(() => {}), - setPhaseAndResetError: mock(() => {}), - setModelFailed: mock(() => {}), - setLastAuditResult: mock(() => {}), - replaceSession: mock(() => {}), - terminate: mock(() => {}), - setSandboxContainer: mock(() => {}), - clearWorkspaceId: mock(() => {}), - setWorkspaceId: mock(() => {}), - incrementError: mock(() => 0), - resetError: mock(() => {}), - } as unknown as LoopsRepo - - const mockPlansRepo = {} as PlansRepo - const mockReviewFindingsRepo = {} as ReviewFindingsRepo - const mockLogger = { log: mock(), error: mock(), debug: mock() } as Logger - - return { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } - } - - function createSandboxLoopRow(overrides?: Partial): LoopRow { - const now = Date.now() - return { - projectId: 'test-project', - loopName: 'alpha', - status: 'running', - currentSessionId: 's1', - worktree: true, - worktreeDir: '/tmp/wt', - worktreeBranch: null, - projectDir: '/tmp/wt', - maxIterations: 5, - iteration: 1, - auditCount: 0, - errorCount: 0, - phase: 'coding', - executionModel: null, - auditorModel: null, - modelFailed: false, - sandbox: true, - sandboxContainer: 'forge-alpha', - startedAt: now, - completedAt: null, - terminationReason: null, - completionSummary: null, - workspaceId: null, - hostSessionId: null, - ...overrides, - } - } - - it('should preserve loop with live container at boot', async () => { - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - const sandboxRow = createSandboxLoopRow() - mockLoopsRepo.listByStatus = mock(() => [sandboxRow]) - mockLoopsRepo.getLarge = mock(() => ({ lastAuditResult: null })) - mockLoopsRepo.get = mock((projectId: string, loopName: string) => { - if (loopName === 'alpha') return sandboxRow - return null - }) - - const loopService = createLoopService( - mockLoopsRepo, - mockPlansRepo, - mockReviewFindingsRepo, - 'test-project', - mockLogger, - undefined, - undefined - ) - - // Mock sandbox manager with isLiveByName returning true for 'alpha' - const mockSandboxManager = { - isLiveByName: mock(async (name: string) => name === 'alpha'), - cleanupOrphans: mock(async () => 0), - start: mock(async () => ({ containerName: 'forge-alpha' })), - restore: mock(async () => {}), - stop: mock(async () => {}), - isActive: mock(() => false), - isLive: mock(async () => false), - getActive: mock(() => null), - docker: {} as any, - } as unknown as SandboxManager - - // Step 1: reconcileStale with isSandboxLive probe - const reconcileResult = await loopService.reconcileStale({ - isSandboxLive: (name) => mockSandboxManager.isLiveByName(name), - }) - - expect(reconcileResult.cancelled).toBe(0) - expect(reconcileResult.preserved).toEqual(['alpha']) - expect(mockLoopsRepo.terminate).not.toHaveBeenCalled() - - // Step 2: reconcileSandboxes (boot no longer eagerly cleans orphans; - // the DB-driven per-project reconcile restores containers for preserved loops) - const reconcileDeps: ReconcileSandboxesDeps = { - sandboxManager: mockSandboxManager, - loop: loopService as any, - logger: mockLogger, - } - - await reconcileSandboxes(reconcileDeps) - - // restore should be called (it will see container running and repopulate map) - expect(mockSandboxManager.restore).toHaveBeenCalledWith('alpha', '/tmp/wt', expect.any(String)) - // start should NOT be called - expect(mockSandboxManager.start).not.toHaveBeenCalled() - // stop should NOT be called - expect(mockSandboxManager.stop).not.toHaveBeenCalled() - // cleanupOrphans must NOT be called at boot - expect(mockSandboxManager.cleanupOrphans).not.toHaveBeenCalled() - - // Loop row should still be running (not cancelled) - const state = loopService.getActiveState('alpha') - expect(state).toBeTruthy() - expect(state?.active).toBe(true) - }) - - it('should restore container for preserved loop when container exists in Docker', async () => { - // This test verifies the "negative case" from Phase 4: when a loop is preserved - // (isLiveByName returns true) but the in-memory map is empty (boot scenario), - // reconcileSandboxes should call restore, which will repopulate the map. - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - let loopRow = createSandboxLoopRow() - mockLoopsRepo.listByStatus = mock(() => (loopRow.status === 'running' ? [loopRow] : [])) - mockLoopsRepo.getLarge = mock(() => ({ lastAuditResult: null })) - mockLoopsRepo.get = mock((projectId: string, loopName: string) => { - if (loopName === 'alpha') return loopRow - return null - }) - - const loopService = createLoopService( - mockLoopsRepo, - mockPlansRepo, - mockReviewFindingsRepo, - 'test-project', - mockLogger, - undefined, - undefined - ) - - // Mock sandbox manager: isLiveByName returns true (loop preserved, container live in Docker) - // isActive returns false (in-memory map is empty at boot) - // restore should be called by reconcileSandboxes to repopulate the map - const mockSandboxManager = { - isLiveByName: mock(async () => true), - cleanupOrphans: mock(async () => 0), - start: mock(async () => ({ containerName: 'forge-alpha' })), - restore: mock(async () => {}), - stop: mock(async () => {}), - isActive: mock(() => false), - isLive: mock(async () => false), - getActive: mock(() => null), - docker: {} as any, - } as unknown as SandboxManager - - // Step 1: reconcileStale with isSandboxLive probe - loop is preserved - const reconcileResult = await loopService.reconcileStale({ - isSandboxLive: (name) => mockSandboxManager.isLiveByName(name), - }) - - expect(reconcileResult.cancelled).toBe(0) - expect(reconcileResult.preserved).toEqual(['alpha']) - expect(mockLoopsRepo.terminate).not.toHaveBeenCalled() - expect(loopRow.status).toBe('running') - - // Step 2: reconcileSandboxes - should call restore to repopulate map - const reconcileDeps: ReconcileSandboxesDeps = { - sandboxManager: mockSandboxManager, - loop: loopService as any, - logger: mockLogger, - } - - await reconcileSandboxes(reconcileDeps) - - // restore should be called because isActive returns false (map is empty) - // even though the container is actually running (isLiveByName returns true) - expect(mockSandboxManager.restore).toHaveBeenCalledWith('alpha', '/tmp/wt', expect.any(String)) - // start should NOT be called - restore handles it - expect(mockSandboxManager.start).not.toHaveBeenCalled() - expect(mockSandboxManager.cleanupOrphans).not.toHaveBeenCalled() - }) - - it('should start fresh container when loop is active but container is gone', async () => { - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - // Create a loop that is still running but has a dead container - // This simulates the case where reconcileStale preserves the loop (isLiveByName returns true) - // but the container reference is stale and needs to be restarted - let loopRow = createSandboxLoopRow() - mockLoopsRepo.listByStatus = mock(() => (loopRow.status === 'running' ? [loopRow] : [])) - mockLoopsRepo.getLarge = mock(() => ({ lastAuditResult: null })) - mockLoopsRepo.get = mock((projectId: string, loopName: string) => { - if (loopName === 'alpha') return loopRow - return null - }) - - const loopService = createLoopService( - mockLoopsRepo, - mockPlansRepo, - mockReviewFindingsRepo, - 'test-project', - mockLogger, - undefined, - undefined - ) - - // Mock sandbox manager: isLiveByName returns true (loop preserved), - // but isActive returns false (container not in map), triggering restore - const mockSandboxManager = { - isLiveByName: mock(async () => true), - cleanupOrphans: mock(async () => 0), - start: mock(async () => ({ containerName: 'forge-alpha-new' })), - restore: mock(async () => {}), - stop: mock(async () => {}), - isActive: mock(() => false), - isLive: mock(async () => false), - getActive: mock(() => null), - docker: {} as any, - } as unknown as SandboxManager - - // Step 1: reconcileStale with isSandboxLive probe - loop is preserved - const reconcileResult = await loopService.reconcileStale({ - isSandboxLive: (name) => mockSandboxManager.isLiveByName(name), - }) - - expect(reconcileResult.cancelled).toBe(0) - expect(reconcileResult.preserved).toEqual(['alpha']) - expect(mockLoopsRepo.terminate).not.toHaveBeenCalled() - - // Step 2: reconcileSandboxes - since container is not active, restore is called - const reconcileDeps: ReconcileSandboxesDeps = { - sandboxManager: mockSandboxManager, - loop: loopService as any, - logger: mockLogger, - } - - await reconcileSandboxes(reconcileDeps) - - // Since sandboxContainer is set but isActive returns false, restore should be called - // restore will then determine if container is actually running or needs to be started - expect(mockSandboxManager.restore).toHaveBeenCalledWith('alpha', '/tmp/wt', expect.any(String)) - // start should NOT be called directly - restore handles it - expect(mockSandboxManager.start).not.toHaveBeenCalled() - expect(mockSandboxManager.cleanupOrphans).not.toHaveBeenCalled() - }) - - it('should cancel stale loop and restore preserved loop (negative case)', async () => { - // This test verifies the Phase 4 "negative case" with two loops: - // - alpha: isLiveByName=false (stale, should be cancelled) - // - beta: isLiveByName=true (preserved, should be restored) - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - const alphaRow = createSandboxLoopRow({ loopName: 'alpha', sandboxContainer: 'forge-alpha', worktreeDir: '/tmp/wt-alpha', sandbox: false }) - const betaRow = createSandboxLoopRow({ loopName: 'beta', sandboxContainer: 'forge-beta', worktreeDir: '/tmp/wt-beta' }) - - // listByStatus tracks current status of both rows - mockLoopsRepo.listByStatus = mock((projectId: string, statuses: string[]) => { - if (statuses.includes('running')) { - const result: LoopRow[] = [] - if (alphaRow.status === 'running') result.push(alphaRow) - if (betaRow.status === 'running') result.push(betaRow) - return result - } - return [] - }) - mockLoopsRepo.getLarge = mock(() => ({ lastAuditResult: null })) - mockLoopsRepo.get = mock((projectId: string, loopName: string) => { - if (loopName === 'alpha') return alphaRow - if (loopName === 'beta') return betaRow - return null - }) - mockLoopsRepo.terminate = mock((projectId: string, loopName: string, opts: any) => { - if (loopName === 'alpha') { - alphaRow.status = opts.status ?? 'cancelled' - alphaRow.terminationReason = opts.reason ?? null - alphaRow.completedAt = opts.completedAt ?? null - } else if (loopName === 'beta') { - betaRow.status = opts.status ?? 'cancelled' - betaRow.terminationReason = opts.reason ?? null - betaRow.completedAt = opts.completedAt ?? null - } - }) - - const loopService = createLoopService( - mockLoopsRepo, - mockPlansRepo, - mockReviewFindingsRepo, - 'test-project', - mockLogger, - undefined, - undefined - ) - - // isLiveByName: alpha=false (stale), beta=true (preserved) - const mockSandboxManager = { - isLiveByName: mock(async (name: string) => name === 'beta'), - cleanupOrphans: mock(async () => 0), - start: mock(async () => ({ containerName: 'forge-new' })), - restore: mock(async () => {}), - stop: mock(async () => {}), - isActive: mock(() => false), - isLive: mock(async () => false), - getActive: mock(() => null), - docker: {} as any, - } as unknown as SandboxManager - - // Step 1: reconcileStale - alpha cancelled (non-sandbox), beta preserved - const reconcileResult = await loopService.reconcileStale({ - isSandboxLive: (name) => mockSandboxManager.isLiveByName(name), - }) - - expect(reconcileResult.cancelled).toBe(1) - expect(reconcileResult.preserved).toEqual(['beta']) - expect(mockLoopsRepo.terminate).toHaveBeenCalledTimes(1) - expect(mockLoopsRepo.terminate).toHaveBeenCalledWith('test-project', 'alpha', expect.any(Object)) - expect(alphaRow.status).toBe('cancelled') - expect(betaRow.status).toBe('running') - - // Step 2: reconcileSandboxes - only beta is processed (alpha is restart candidate) - const reconcileDeps: ReconcileSandboxesDeps = { - sandboxManager: mockSandboxManager, - loop: loopService as any, - logger: mockLogger, - } - - await reconcileSandboxes(reconcileDeps) - - // restore should be called for beta only - expect(mockSandboxManager.restore).toHaveBeenCalledWith('beta', '/tmp/wt-beta', expect.any(String)) - expect(mockSandboxManager.restore).not.toHaveBeenCalledWith('alpha', expect.any(String), expect.any(String)) - // start should NOT be called (restore handles it) - expect(mockSandboxManager.start).not.toHaveBeenCalled() - expect(mockSandboxManager.cleanupOrphans).not.toHaveBeenCalled() - }) -}) diff --git a/test/hooks/audit-rotate-ordering.test.ts b/test/hooks/audit-rotate-ordering.test.ts index ae397d6735..d86ff3b4b4 100644 --- a/test/hooks/audit-rotate-ordering.test.ts +++ b/test/hooks/audit-rotate-ordering.test.ts @@ -419,6 +419,7 @@ describe('audit→code rotation ordering', () => { startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'auditing', + status: 'running', errorCount: 0, auditCount: 0, worktree: true, @@ -511,6 +512,7 @@ describe('audit→code rotation ordering', () => { startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'auditing', + status: 'running', errorCount: 0, auditCount: 0, worktree: true, diff --git a/test/hooks/loop-event-gate.test.ts b/test/hooks/loop-event-gate.test.ts index e0e3284a46..86dd105c83 100644 --- a/test/hooks/loop-event-gate.test.ts +++ b/test/hooks/loop-event-gate.test.ts @@ -211,6 +211,7 @@ describe('Loop Event Idle Gate', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'running', worktree: true, modelFailed: false, sandbox: false, diff --git a/test/hooks/loop-section-audit-retry.test.ts b/test/hooks/loop-section-audit-retry.test.ts index 44728cd57f..81581b1dcc 100644 --- a/test/hooks/loop-section-audit-retry.test.ts +++ b/test/hooks/loop-section-audit-retry.test.ts @@ -166,6 +166,7 @@ describe('Loop Section Audit Retry', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'running', worktree: true, modelFailed: false, sandbox: false, diff --git a/test/loop-runtime-audit-permissions.test.ts b/test/loop-runtime-audit-permissions.test.ts index 7c7739ddb0..6403e63884 100644 --- a/test/loop-runtime-audit-permissions.test.ts +++ b/test/loop-runtime-audit-permissions.test.ts @@ -155,6 +155,7 @@ describe('Legacy audit fallback permissions', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'running', worktree: true, modelFailed: false, sandbox: false, diff --git a/test/loop-service-notify.test.ts b/test/loop-service-notify.test.ts index d5c543a17a..fd8f29db74 100644 --- a/test/loop-service-notify.test.ts +++ b/test/loop-service-notify.test.ts @@ -609,318 +609,6 @@ describe('LoopChangeNotifier', () => { }) }) - describe('reconcileStale', () => { - it('should cancel all loops when called without opts (back-compatible)', async () => { - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - const now = Date.now() - const validRow = { - loopName: 'stale-loop', - status: 'running' as const, - currentSessionId: 's1', - worktree: false, - worktreeDir: '/test', - worktreeBranch: null, - projectDir: '/test', - maxIterations: 5, - iteration: 1, - auditCount: 0, - errorCount: 0, - phase: 'coding' as const, - executionModel: null, - auditorModel: null, - modelFailed: false, - sandbox: false, - sandboxContainer: null, - startedAt: now, - completedAt: null, - terminationReason: null, - completionSummary: null, - workspaceId: null, - hostSessionId: null, - loopId: 1, - projectId: 'test-project', - } - - // Mock listActive to return one loop - mockLoopsRepo.listByStatus = () => [validRow] - mockLoopsRepo.getLarge = () => ({ lastAuditResult: null }) - - const notifyCalls: Array<{ reason: string; loopName: string }> = [] - const notify: LoopChangeNotifier = (reason, loopName, _hint) => { - notifyCalls.push({ reason, loopName }) - } - - const loop = createLoop({ - loopsRepo: mockLoopsRepo, - plansRepo: mockPlansRepo, - reviewFindingsRepo: mockReviewFindingsRepo, - projectId: 'test-project', - logger: mockLogger, - client: {} as any, - v2Client: {} as any, - getConfig: () => ({} as any), - notify, - }) - - const result = await loop.reconcileStale() - - expect(notifyCalls.length).toBe(1) - expect(notifyCalls[0].reason).toBe('reconcile') - expect(notifyCalls[0].loopName).toBe('stale-loop') - expect(result.cancelled).toBe(1) - expect(result.preserved).toEqual([]) - }) - - it('should preserve loop when isSandboxLive returns true', async () => { - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - const now = Date.now() - const sandboxRow = { - loopName: 'live-sandbox-loop', - status: 'running' as const, - currentSessionId: 's1', - worktree: true, - worktreeDir: '/test/wt', - worktreeBranch: null, - projectDir: '/test', - maxIterations: 5, - iteration: 1, - auditCount: 0, - errorCount: 0, - phase: 'coding' as const, - executionModel: null, - auditorModel: null, - modelFailed: false, - sandbox: true, - sandboxContainer: 'forge-live-sandbox-loop', - startedAt: now, - completedAt: null, - terminationReason: null, - completionSummary: null, - workspaceId: null, - hostSessionId: null, - loopId: 1, - projectId: 'test-project', - } - - mockLoopsRepo.listByStatus = () => [sandboxRow] - mockLoopsRepo.getLarge = () => ({ lastAuditResult: null }) - - const notifyCalls: Array<{ reason: string; loopName: string }> = [] - const notify: LoopChangeNotifier = (reason, loopName, _hint) => { - notifyCalls.push({ reason, loopName }) - } - - const loop = createLoop({ - loopsRepo: mockLoopsRepo, - plansRepo: mockPlansRepo, - reviewFindingsRepo: mockReviewFindingsRepo, - projectId: 'test-project', - logger: mockLogger, - client: {} as any, - v2Client: {} as any, - getConfig: () => ({} as any), - notify, - }) - - const isSandboxLive = mock(async (name: string) => name === 'live-sandbox-loop') - const result = await loop.reconcileStale({ isSandboxLive }) - - expect(notifyCalls.length).toBe(0) - expect(result.cancelled).toBe(0) - expect(result.preserved).toEqual(['live-sandbox-loop']) - }) - - it('should cancel loop when isSandboxLive returns false', async () => { - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - const now = Date.now() - const sandboxRow = { - loopName: 'dead-sandbox-loop', - status: 'running' as const, - currentSessionId: 's1', - worktree: false, - worktreeDir: '/test/wt', - worktreeBranch: null, - projectDir: '/test', - maxIterations: 5, - iteration: 1, - auditCount: 0, - errorCount: 0, - phase: 'coding' as const, - executionModel: null, - auditorModel: null, - modelFailed: false, - sandbox: true, - sandboxContainer: 'forge-dead-sandbox-loop', - startedAt: now, - completedAt: null, - terminationReason: null, - completionSummary: null, - workspaceId: null, - hostSessionId: null, - loopId: 1, - projectId: 'test-project', - } - - mockLoopsRepo.listByStatus = () => [sandboxRow] - mockLoopsRepo.getLarge = () => ({ lastAuditResult: null }) - - const notifyCalls: Array<{ reason: string; loopName: string }> = [] - const notify: LoopChangeNotifier = (reason, loopName, _hint) => { - notifyCalls.push({ reason, loopName }) - } - - const loop = createLoop({ - loopsRepo: mockLoopsRepo, - plansRepo: mockPlansRepo, - reviewFindingsRepo: mockReviewFindingsRepo, - projectId: 'test-project', - logger: mockLogger, - client: {} as any, - v2Client: {} as any, - getConfig: () => ({} as any), - notify, - }) - - const isSandboxLive = mock(async () => false) - const result = await loop.reconcileStale({ isSandboxLive }) - - expect(notifyCalls.length).toBe(1) - expect(notifyCalls[0].reason).toBe('reconcile') - expect(notifyCalls[0].loopName).toBe('dead-sandbox-loop') - expect(result.cancelled).toBe(1) - expect(result.preserved).toEqual([]) - }) - - it('should cancel loop when sandbox=false (isSandboxLive not called)', async () => { - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - const now = Date.now() - const nonSandboxRow = { - loopName: 'non-sandbox-loop', - status: 'running' as const, - currentSessionId: 's1', - worktree: true, - worktreeDir: '/test/wt', - worktreeBranch: null, - projectDir: '/test', - maxIterations: 5, - iteration: 1, - auditCount: 0, - errorCount: 0, - phase: 'coding' as const, - executionModel: null, - auditorModel: null, - modelFailed: false, - sandbox: false, - sandboxContainer: null, - startedAt: now, - completedAt: null, - terminationReason: null, - completionSummary: null, - workspaceId: null, - hostSessionId: null, - loopId: 1, - projectId: 'test-project', - } - - mockLoopsRepo.listByStatus = () => [nonSandboxRow] - mockLoopsRepo.getLarge = () => ({ lastAuditResult: null }) - - const notifyCalls: Array<{ reason: string; loopName: string }> = [] - const notify: LoopChangeNotifier = (reason, loopName, _hint) => { - notifyCalls.push({ reason, loopName }) - } - - const loop = createLoop({ - loopsRepo: mockLoopsRepo, - plansRepo: mockPlansRepo, - reviewFindingsRepo: mockReviewFindingsRepo, - projectId: 'test-project', - logger: mockLogger, - client: {} as any, - v2Client: {} as any, - getConfig: () => ({} as any), - notify, - }) - - const isSandboxLive = mock(async () => true) - const result = await loop.reconcileStale({ isSandboxLive }) - - expect(isSandboxLive).not.toHaveBeenCalled() - expect(notifyCalls.length).toBe(1) - expect(notifyCalls[0].reason).toBe('reconcile') - expect(notifyCalls[0].loopName).toBe('non-sandbox-loop') - expect(result.cancelled).toBe(1) - expect(result.preserved).toEqual([]) - }) - - it('should cancel loop when sandboxContainer=null (isSandboxLive not called)', async () => { - const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() - - const now = Date.now() - const noContainerRow = { - loopName: 'no-container-loop', - status: 'running' as const, - currentSessionId: 's1', - worktree: false, - worktreeDir: '/test/wt', - worktreeBranch: null, - projectDir: '/test', - maxIterations: 5, - iteration: 1, - auditCount: 0, - errorCount: 0, - phase: 'coding' as const, - executionModel: null, - auditorModel: null, - modelFailed: false, - sandbox: true, - sandboxContainer: null, - startedAt: now, - completedAt: null, - terminationReason: null, - completionSummary: null, - workspaceId: null, - hostSessionId: null, - loopId: 1, - projectId: 'test-project', - } - - mockLoopsRepo.listByStatus = () => [noContainerRow] - mockLoopsRepo.getLarge = () => ({ lastAuditResult: null }) - - const notifyCalls: Array<{ reason: string; loopName: string }> = [] - const notify: LoopChangeNotifier = (reason, loopName, _hint) => { - notifyCalls.push({ reason, loopName }) - } - - const loop = createLoop({ - loopsRepo: mockLoopsRepo, - plansRepo: mockPlansRepo, - reviewFindingsRepo: mockReviewFindingsRepo, - projectId: 'test-project', - logger: mockLogger, - client: {} as any, - v2Client: {} as any, - getConfig: () => ({} as any), - notify, - }) - - const isSandboxLive = mock(async () => true) - const result = await loop.reconcileStale({ isSandboxLive }) - - expect(isSandboxLive).not.toHaveBeenCalled() - expect(notifyCalls.length).toBe(1) - expect(notifyCalls[0].reason).toBe('reconcile') - expect(notifyCalls[0].loopName).toBe('no-container-loop') - expect(result.cancelled).toBe(1) - expect(result.preserved).toEqual([]) - }) - }) - describe('default no-op notifier', () => { it('should work without notifier (default no-op)', () => { const { mockLoopsRepo, mockPlansRepo, mockReviewFindingsRepo, mockLogger } = createMockRepos() diff --git a/test/loop-status-tool.test.ts b/test/loop-status-tool.test.ts index 3e5edfef09..a288900545 100644 --- a/test/loop-status-tool.test.ts +++ b/test/loop-status-tool.test.ts @@ -214,7 +214,7 @@ describe('loop-status tool restart path', () => { db.close() }) - function makeState(active: boolean): Partial & Pick { + function makeState(active: boolean, status?: LoopState['status']): Partial & Pick { return { active, sessionId: active ? 'old-session-active' : 'old-session-done', @@ -229,6 +229,7 @@ describe('loop-status tool restart path', () => { phase: active ? 'auditing' : ('coding' as const), errorCount: 0, auditCount: active ? 1 : 0, + status: status ?? (active ? 'running' : 'completed'), worktree: true, sandbox: false, executionModel: 'test-model', @@ -464,7 +465,7 @@ describe('loop-status tool restart path', () => { // Create an inactive loop loopService.setState(loopName, { - ...makeState(false), + ...makeState(false, 'cancelled'), sessionId: oldSessionId, completedAt: new Date().toISOString(), terminationReason: 'cancelled', @@ -538,6 +539,7 @@ describe('loop-status tool restart path', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'errored', worktree: true, sandbox: false, executionModel: 'test-model', @@ -583,7 +585,7 @@ describe('loop-status tool restart path', () => { expect(callWithPermission![0].permission).toEqual(buildLoopPermissionRuleset()) }) - test('non-force restart of final_audit_retry_exhausted returns conflict', async () => { + test('non-force restart of final_audit_retry_exhausted succeeds without force', async () => { const mockApi = createMockTuiApi() const v2Client = mockApi.client as unknown as OpencodeClient const logger = createLogger({ enabled: false, file: '' }) @@ -607,14 +609,15 @@ describe('loop-status tool restart path', () => { iteration: 2, maxIterations: 5, startedAt: new Date().toISOString(), - prompt: 'Test prompt', + prompt: 'Test prompt for final audit restart', phase: 'final_auditing', errorCount: 0, auditCount: 1, + status: 'errored', worktree: true, sandbox: false, - executionModel: 'test-model', - auditorModel: 'test-auditor', + executionModel: 'provider/execution-model', + auditorModel: 'provider/auditor-model', workspaceId, hostSessionId, currentSectionIndex: 1, @@ -645,16 +648,34 @@ describe('loop-status tool restart path', () => { force: false, }, { sessionID: 'test-session' } as any) - expect(result).toContain('terminated during final audit retry exhaustion') - expect(result).toContain('Use force=true to restart') + expect(result).toContain('Restarted loop') - // No new session.create should have been called - const createCalls = ((v2Client.session.create as any)).mock.calls - expect(createCalls.length).toBe(0) + // Verify persisted state + const newState = loopService.getActiveState(loopName) + expect(newState).toBeDefined() + expect(newState?.active).toBe(true) + expect(newState?.phase).toBe('final_auditing') + expect(newState?.terminationReason).toBeFalsy() + expect(newState?.completedAt).toBeFalsy() + expect(newState?.currentSectionIndex).toBe(1) + expect(newState?.totalSections).toBe(2) + expect(newState?.finalAuditDone).toBe(false) - // Loop should remain inactive - const state = loopService.getActiveState(loopName) - expect(state).toBeNull() + // Verify promptAsync was called with auditor-loop agent using auditor model + const promptCalls = ((v2Client.session.promptAsync as any)).mock.calls + expect(promptCalls.length).toBeGreaterThan(0) + const lastPromptCall = promptCalls[promptCalls.length - 1][0] + expect(lastPromptCall.agent).toBe('auditor-loop') + expect(lastPromptCall.model).toEqual({ providerID: 'provider', modelID: 'auditor-model' }) + + // Verify session creation uses audit permissions, not loop permissions + const createCalls = ((v2Client.session.create as any)).mock.calls + expect(createCalls.length).toBeGreaterThan(0) + const callWithPermission = createCalls.find((call: any[]) => + call[0]?.permission !== undefined + ) + expect(callWithPermission).toBeDefined() + expect(callWithPermission![0].permission).toEqual(buildAuditSessionPermissionRuleset()) }) test('forced restart of final_audit_retry_exhausted resumes at final_auditing', async () => { @@ -685,6 +706,7 @@ describe('loop-status tool restart path', () => { phase: 'final_auditing', errorCount: 0, auditCount: 1, + status: 'errored', worktree: true, sandbox: false, executionModel: 'provider/execution-model', @@ -846,6 +868,7 @@ describe('loop-status cumulative usage', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'completed', worktree: true, sandbox: false, executionModel: 'test-model', @@ -919,6 +942,7 @@ describe('loop-status cumulative usage', () => { // Create active loop loopService.setState(loopName, { active: true, + status: 'running', sessionId: 'session-active', loopName, worktreeDir, @@ -1012,6 +1036,7 @@ describe('loop-status cumulative usage', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'completed', worktree: true, sandbox: false, executionModel: 'model-a', @@ -1103,6 +1128,7 @@ describe('loop-status cumulative usage', () => { // Active loop with current session NOT persisted loopService.setState(loopName, { active: true, + status: 'running', sessionId: 'session-current-live', loopName, worktreeDir, @@ -1190,6 +1216,7 @@ describe('loop-status cumulative usage', () => { // Active loop with current session ALREADY persisted loopService.setState(loopName, { active: true, + status: 'running', sessionId: 'session-current-persisted', loopName, worktreeDir, @@ -1295,6 +1322,7 @@ describe('loop-status cumulative usage', () => { // Active loop with NO persisted usage loopService.setState(loopName, { active: true, + status: 'running', sessionId: 'session-live-only', loopName, worktreeDir, @@ -1377,6 +1405,7 @@ describe('loop-status cumulative usage', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'completed', worktree: true, sandbox: false, executionModel: 'test-model', @@ -1466,6 +1495,7 @@ describe('loop-status cumulative usage', () => { phase: 'auditing', errorCount: 0, auditCount: 1, + status: 'completed', worktree: true, sandbox: false, executionModel: 'test-model', @@ -1562,6 +1592,7 @@ describe('loop-status cumulative usage', () => { // Active loop in coding phase with dialog-selected model loopService.setState(loopName, { active: true, + status: 'running', sessionId: 'session-live-attrib', loopName, worktreeDir, @@ -1631,6 +1662,7 @@ describe('loop-status cumulative usage', () => { // Active loop in auditing phase with distinct auditor model loopService.setState(loopName, { active: true, + status: 'running', sessionId: 'session-live-audit-attrib', loopName, worktreeDir, @@ -1680,3 +1712,434 @@ describe('loop-status cumulative usage', () => { expect(result).not.toContain('default/session model') }) }) + +describe('loop-status restartability display', () => { + let db: Database + let dbPath: string + const projectId = 'test-project' + const loopName = 'test-loop-restart' + + beforeEach(() => { + const result = createTestDb() + db = result.db + dbPath = result.path + }) + + afterEach(() => { + db.close() + }) + + function createMockV2ClientWithWorktreeCheck(worktreeDir: string): OpencodeClient { + return { + session: { + create: vi.fn(async (params) => ({ + data: { id: 'mock-session-' + Date.now(), title: params.title }, + error: null, + })), + promptAsync: vi.fn(async () => ({ data: {}, error: null })), + abort: vi.fn(async () => ({ data: {}, error: null })), + status: vi.fn(async () => ({ data: {}, error: null })), + delete: vi.fn(async () => ({ data: {}, error: null })), + messages: vi.fn(async () => ({ data: [], error: null })), + get: vi.fn(async () => ({ data: {}, error: null })), + }, + worktree: { + create: vi.fn(async () => ({ data: { name: 'mock', directory: '/tmp/mock', branch: 'main' }, error: null })), + remove: vi.fn(async () => ({ data: {}, error: null })), + }, + experimental: { + workspace: { + create: vi.fn(async () => ({ + data: { id: 'mock-workspace-' + Date.now(), directory: TEST_DIR + '/worktree', branch: 'opencode/loop-test' }, + error: null, + })), + warp: vi.fn(async () => ({ data: {}, error: null })), + list: vi.fn(async () => ({ data: [], error: null })), + status: vi.fn(async () => ({ data: [], error: null })), + syncList: vi.fn(async () => ({ data: {}, error: null })), + remove: vi.fn(async () => ({ data: {}, error: null })), + }, + }, + tui: { + selectSession: vi.fn(async () => ({ data: {}, error: null })), + publish: vi.fn(async () => ({ data: {}, error: null })), + }, + } as unknown as OpencodeClient + } + + test('inactive cancelled loop with worktree shows Restart: available', async () => { + const mockApi = createMockTuiApi() + const v2Client = mockApi.client as unknown as OpencodeClient + const logger = createLogger({ enabled: false, file: '' }) + + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger) + + const worktreeDir = `${TEST_DIR}/worktree-restart-cancelled` + mkdirSync(worktreeDir, { recursive: true }) + + loopService.setState(loopName, { + active: false, + sessionId: 'session-cancelled', + loopName, + worktreeDir, + projectDir: TEST_DIR, + worktreeBranch: 'opencode/loop-test-restart', + iteration: 2, + maxIterations: 5, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding', + errorCount: 0, + auditCount: 0, + status: 'cancelled', + worktree: true, + sandbox: false, + executionModel: 'test-model', + auditorModel: 'test-auditor', + workspaceId: 'ws-123', + hostSessionId: 'host-456', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + terminationReason: 'cancelled', + completedAt: new Date().toISOString(), + } as any) + + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const tools = createLoopTools({ + v2: v2Client, + directory: TEST_DIR, + config: {}, + loopService, + loopHandler, + logger, + plansRepo, + loopsRepo, + projectId, + dataDir: dbPath, + loop: loopHandler.loop, + } as any) + + const result = await tools['loop-status'].execute({ + name: loopName, + }, { sessionID: 'test-session' } as any) + + expect(result).toContain('Restart: available with loop-status name=test-loop-restart restart=true') + }) + + test('inactive errored loop with worktree shows Restart: available', async () => { + const mockApi = createMockTuiApi() + const v2Client = mockApi.client as unknown as OpencodeClient + const logger = createLogger({ enabled: false, file: '' }) + + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger) + + const worktreeDir = `${TEST_DIR}/worktree-restart-errored` + mkdirSync(worktreeDir, { recursive: true }) + + loopService.setState(loopName, { + active: false, + sessionId: 'session-errored', + loopName, + worktreeDir, + projectDir: TEST_DIR, + worktreeBranch: 'opencode/loop-test-errored', + iteration: 2, + maxIterations: 5, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding', + errorCount: 3, + auditCount: 0, + status: 'errored', + worktree: true, + sandbox: false, + executionModel: 'test-model', + auditorModel: 'test-auditor', + workspaceId: 'ws-123', + hostSessionId: 'host-456', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + terminationReason: 'error_max_retries: test error', + completedAt: new Date().toISOString(), + } as any) + + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const tools = createLoopTools({ + v2: v2Client, + directory: TEST_DIR, + config: {}, + loopService, + loopHandler, + logger, + plansRepo, + loopsRepo, + projectId, + dataDir: dbPath, + loop: loopHandler.loop, + } as any) + + const result = await tools['loop-status'].execute({ + name: loopName, + }, { sessionID: 'test-session' } as any) + + expect(result).toContain('Restart: available with loop-status name=test-loop-restart restart=true') + }) + + test('inactive stalled loop with worktree shows Restart: available', async () => { + const mockApi = createMockTuiApi() + const v2Client = mockApi.client as unknown as OpencodeClient + const logger = createLogger({ enabled: false, file: '' }) + + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger) + + const worktreeDir = `${TEST_DIR}/worktree-restart-stalled` + mkdirSync(worktreeDir, { recursive: true }) + + loopService.setState(loopName, { + active: false, + sessionId: 'session-stalled', + loopName, + worktreeDir, + projectDir: TEST_DIR, + worktreeBranch: 'opencode/loop-test-stalled', + iteration: 2, + maxIterations: 5, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding', + errorCount: 0, + auditCount: 0, + status: 'stalled', + worktree: true, + sandbox: false, + executionModel: 'test-model', + auditorModel: 'test-auditor', + workspaceId: 'ws-123', + hostSessionId: 'host-456', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + terminationReason: 'stalled', + completedAt: new Date().toISOString(), + } as any) + + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const tools = createLoopTools({ + v2: v2Client, + directory: TEST_DIR, + config: {}, + loopService, + loopHandler, + logger, + plansRepo, + loopsRepo, + projectId, + dataDir: dbPath, + loop: loopHandler.loop, + } as any) + + const result = await tools['loop-status'].execute({ + name: loopName, + }, { sessionID: 'test-session' } as any) + + expect(result).toContain('Restart: available with loop-status name=test-loop-restart restart=true') + }) + + test('completed loop shows Restart: not available (completed)', async () => { + const mockApi = createMockTuiApi() + const v2Client = mockApi.client as unknown as OpencodeClient + const logger = createLogger({ enabled: false, file: '' }) + + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger) + + const worktreeDir = `${TEST_DIR}/worktree-restart-completed` + mkdirSync(worktreeDir, { recursive: true }) + + loopService.setState(loopName, { + active: false, + sessionId: 'session-completed', + loopName, + worktreeDir, + projectDir: TEST_DIR, + worktreeBranch: 'opencode/loop-test-completed', + iteration: 3, + maxIterations: 5, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding', + errorCount: 0, + auditCount: 0, + status: 'completed', + worktree: true, + sandbox: false, + executionModel: 'test-model', + auditorModel: 'test-auditor', + workspaceId: 'ws-789', + hostSessionId: 'host-012', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + terminationReason: 'completed', + completedAt: new Date().toISOString(), + } as any) + + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const tools = createLoopTools({ + v2: v2Client, + directory: TEST_DIR, + config: {}, + loopService, + loopHandler, + logger, + plansRepo, + loopsRepo, + projectId, + dataDir: dbPath, + loop: loopHandler.loop, + } as any) + + const result = await tools['loop-status'].execute({ + name: loopName, + }, { sessionID: 'test-session' } as any) + + expect(result).toContain('Restart: not available (completed)') + }) + + test('loop with missing worktree shows Restart blocked message', async () => { + const mockApi = createMockTuiApi() + const v2Client = mockApi.client as unknown as OpencodeClient + const logger = createLogger({ enabled: false, file: '' }) + + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger) + + const worktreeDir = `${TEST_DIR}/worktree-restart-missing` + // Do NOT create directory - simulate missing worktree + + loopService.setState(loopName, { + active: false, + sessionId: 'session-missing-wt', + loopName, + worktreeDir, + projectDir: TEST_DIR, + worktreeBranch: 'opencode/loop-test-missing', + iteration: 2, + maxIterations: 5, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding', + errorCount: 0, + auditCount: 0, + status: 'errored', + worktree: true, + sandbox: false, + executionModel: 'test-model', + auditorModel: 'test-auditor', + workspaceId: 'ws-missing', + hostSessionId: 'host-missing', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + terminationReason: 'error_max_retries: test', + completedAt: new Date().toISOString(), + } as any) + + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const tools = createLoopTools({ + v2: v2Client, + directory: TEST_DIR, + config: {}, + loopService, + loopHandler, + logger, + plansRepo, + loopsRepo, + projectId, + dataDir: dbPath, + loop: loopHandler.loop, + } as any) + + const result = await tools['loop-status'].execute({ + name: loopName, + }, { sessionID: 'test-session' } as any) + + expect(result).toContain(`Restart blocked: worktree directory no longer exists at ${worktreeDir}`) + }) + + test('active loop shows Restart: available with force=true', async () => { + const mockApi = createMockTuiApi() + const v2Client = mockApi.client as unknown as OpencodeClient + const logger = createLogger({ enabled: false, file: '' }) + + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + const reviewFindingsRepo = createReviewFindingsRepo(db) + const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger) + + const worktreeDir = `${TEST_DIR}/worktree-restart-active` + mkdirSync(worktreeDir, { recursive: true }) + + loopService.setState(loopName, { + active: true, + status: 'running', + sessionId: 'session-active-restart', + loopName, + worktreeDir, + projectDir: TEST_DIR, + worktreeBranch: 'opencode/loop-test-active', + iteration: 2, + maxIterations: 5, + startedAt: new Date().toISOString(), + prompt: 'Test prompt active', + phase: 'auditing', + errorCount: 0, + auditCount: 1, + worktree: true, + sandbox: false, + executionModel: 'test-model', + auditorModel: 'test-auditor', + workspaceId: 'ws-active', + hostSessionId: 'host-active', + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + } as any) + + const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockApi as any, v2Client, logger, () => ({}), undefined, dbPath) + const tools = createLoopTools({ + v2: v2Client, + directory: TEST_DIR, + config: {}, + loopService, + loopHandler, + logger, + plansRepo, + loopsRepo, + projectId, + dataDir: dbPath, + loop: loopHandler.loop, + } as any) + + const result = await tools['loop-status'].execute({ + name: loopName, + }, { sessionID: 'test-session' } as any) + + expect(result).toContain('Restart: available with force=true') + }) +}) diff --git a/test/loop/cancel.test.ts b/test/loop/cancel.test.ts index a1a8016bc1..3cdc514623 100644 --- a/test/loop/cancel.test.ts +++ b/test/loop/cancel.test.ts @@ -226,6 +226,7 @@ describe('Loop Runtime cancel()', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'running', worktree: false, modelFailed: false, sandbox: false, diff --git a/test/loop/runtime.test.ts b/test/loop/runtime.test.ts index 3892e16043..3f7081d400 100644 --- a/test/loop/runtime.test.ts +++ b/test/loop/runtime.test.ts @@ -294,6 +294,7 @@ describe('Loop Runtime', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'running', worktree: true, modelFailed: false, sandbox: false, diff --git a/test/loop/start.test.ts b/test/loop/start.test.ts index 667da643b4..cafd7b3eef 100644 --- a/test/loop/start.test.ts +++ b/test/loop/start.test.ts @@ -216,6 +216,7 @@ describe('Loop Runtime start()', () => { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'running', worktree: true, modelFailed: false, sandbox: false, diff --git a/test/parent-session-lookup.test.ts b/test/parent-session-lookup.test.ts index dc9191e8af..c9d77cbe78 100644 --- a/test/parent-session-lookup.test.ts +++ b/test/parent-session-lookup.test.ts @@ -23,7 +23,7 @@ function createMockV2Client(responses: Map) { +function createMockLoop(activeLoops: Array<{ loopName: string; worktreeDir: string }>) { return { listActive: () => activeLoops.map((l) => ({ ...l, active: true, sandbox: false, sessionId: '', startedAt: '', iteration: 0, maxIterations: 0, phase: 'coding' as const, audit: false, errorCount: 0, auditCount: 0, worktree: false })), resolveLoopName: () => null, @@ -38,12 +38,12 @@ describe('createParentSessionLookup', () => { const v2 = createMockV2Client( new Map([[sessionId, { data: { parentID: parentId } }]]), ) - const loopService = createMockLoopService([]) + const loop = createMockLoop([]) const lookup = createParentSessionLookup({ v2, directory: '/host', - loopService: loopService as any, + loop: loop as any, logger: mockLogger, }) @@ -70,12 +70,12 @@ describe('createParentSessionLookup', () => { ], ]), ) - const loopService = createMockLoopService([]) + const loop = createMockLoop([]) const lookup = createParentSessionLookup({ v2, directory: '/host', - loopService: loopService as any, + loop: loop as any, logger: mockLogger, negativeTtlMs: 100, }) @@ -108,12 +108,12 @@ describe('createParentSessionLookup', () => { ], ]), ) - const loopService = createMockLoopService([]) + const loop = createMockLoop([]) const lookup = createParentSessionLookup({ v2, directory: '/host', - loopService: loopService as any, + loop: loop as any, logger: mockLogger, negativeTtlMs: 50, }) @@ -143,12 +143,12 @@ describe('createParentSessionLookup', () => { }, } - const loopService = createMockLoopService([{ loopName: 'test-loop', worktreeDir }]) + const loop = createMockLoop([{ loopName: 'test-loop', worktreeDir }]) const lookup = createParentSessionLookup({ v2: v2 as any, directory: '/host', - loopService: loopService as any, + loop: loop as any, logger: mockLogger, negativeTtlMs: 10, }) diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index 57347d7bdc..368480f8be 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -91,12 +91,15 @@ describe('Plan Approval Tool Interception', () => { startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, - + status: 'running' as const, errorCount: 0, auditCount: 0, worktree: true, + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, } - loopService.setState(loopName, state) + loopService.setState(loopName, state as any) } if (tool === 'question') { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 431c574f57..fda67a6902 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, rmSync, existsSync, writeFileSync } from 'fs' import { join } from 'path' import type { PluginConfig } from '../src/types' import type { PluginInput } from '@opencode-ai/plugin' +import { initializeDatabase, closeDatabase, createLoopsRepo, createPlansRepo } from '../src/storage' const TEST_DIR = '/tmp/opencode-manager-memory-test-' + Date.now() @@ -311,6 +312,148 @@ describe('createForgePlugin', () => { expect(typeof adapter.target).toBe('function') }) + test('does not mutate persisted running loops on plugin initialization', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + } + + const plugin = createForgePlugin(config) + + const db = initializeDatabase(config.dataDir!) + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + + const preInsertRow = { + projectId: TEST_PROJECT_ID, + loopName: 'interrupted-loop', + status: 'running' as const, + currentSessionId: 'old-session', + worktree: false, + worktreeDir: testDir, + worktreeBranch: null, + projectDir: testDir, + maxIterations: 50, + iteration: 3, + auditCount: 0, + errorCount: 0, + phase: 'coding' as const, + executionModel: null, + auditorModel: null, + modelFailed: false, + sandbox: false, + sandboxContainer: null, + startedAt: Date.now() - 10000, + completedAt: null, + terminationReason: null, + completionSummary: null, + workspaceId: null, + hostSessionId: null, + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: 0, + } + + loopsRepo.insert(preInsertRow, { lastAuditResult: null }) + closeDatabase(db) + + const mockInput = { + directory: testDir, + worktree: testDir, + client: {} as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput) + currentHooks = hooks as { getCleanup?: () => Promise } + + const dbAfter = initializeDatabase(config.dataDir!) + const loopsRepoAfter = createLoopsRepo(dbAfter) + const rowAfter = loopsRepoAfter.get(TEST_PROJECT_ID, 'interrupted-loop') + + expect(rowAfter).not.toBeNull() + expect(rowAfter!.status).toBe('running') + expect(rowAfter!.currentSessionId).toBe('old-session') + expect(rowAfter!.iteration).toBe(3) + expect(rowAfter!.terminationReason).toBeNull() + expect(rowAfter!.completedAt).toBeNull() + + closeDatabase(dbAfter) + }) + + test('does not restore or mutate persisted running sandbox loops on plugin initialization', async () => { + const config: PluginConfig = { + dataDir: `${testDir}/.opencode/memory`, + } + + const plugin = createForgePlugin(config) + + const db = initializeDatabase(config.dataDir!) + const loopsRepo = createLoopsRepo(db) + const plansRepo = createPlansRepo(db) + + const preInsertRow = { + projectId: TEST_PROJECT_ID, + loopName: 'sandbox-loop', + status: 'running' as const, + currentSessionId: 'sandbox-session', + worktree: true, + worktreeDir: testDir, + worktreeBranch: null, + projectDir: testDir, + maxIterations: 50, + iteration: 2, + auditCount: 0, + errorCount: 0, + phase: 'coding' as const, + executionModel: null, + auditorModel: null, + modelFailed: false, + sandbox: true, + sandboxContainer: 'pre-existing-container-name', + startedAt: Date.now() - 10000, + completedAt: null, + terminationReason: null, + completionSummary: null, + workspaceId: null, + hostSessionId: null, + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: 0, + } + + loopsRepo.insert(preInsertRow, { lastAuditResult: null }) + closeDatabase(db) + + const mockInput = { + directory: testDir, + worktree: testDir, + client: {} as never, + project: { id: TEST_PROJECT_ID } as never, + serverUrl: new URL('http://localhost:5551'), + $: {} as never, + } + + const hooks = await plugin(mockInput) + currentHooks = hooks as { getCleanup?: () => Promise } + + const dbAfter = initializeDatabase(config.dataDir!) + const loopsRepoAfter = createLoopsRepo(dbAfter) + const rowAfter = loopsRepoAfter.get(TEST_PROJECT_ID, 'sandbox-loop') + + expect(rowAfter).not.toBeNull() + expect(rowAfter!.status).toBe('running') + expect(rowAfter!.currentSessionId).toBe('sandbox-session') + expect(rowAfter!.iteration).toBe(2) + expect(rowAfter!.terminationReason).toBeNull() + expect(rowAfter!.completedAt).toBeNull() + expect(rowAfter!.sandbox).toBe(true) + expect(rowAfter!.sandboxContainer).toBe('pre-existing-container-name') + + closeDatabase(dbAfter) + }) + }) describe('PluginConfig', () => { @@ -411,6 +554,11 @@ describe('messages.transform hook', () => { const text = userMsg.parts[1].text as string expect(text).toContain('system-reminder') expect(text).toContain('READ-ONLY mode') + // New explicit rules + expect(text).toContain('exactly one') + expect(text).toContain('## Phase') + expect(text).toContain('Do not insert') + expect(text).toContain('### Files') }) test('does NOT inject for non-architect agents', async () => { diff --git a/test/sandbox/reconcile.test.ts b/test/sandbox/reconcile.test.ts index c16c4e3c97..0710565b9c 100644 --- a/test/sandbox/reconcile.test.ts +++ b/test/sandbox/reconcile.test.ts @@ -4,6 +4,7 @@ import type { SandboxManager } from '../../src/sandbox/manager' import type { LoopService } from '../../src/loop/service' import type { Logger } from '../../src/types' import type { LoopRow } from '../../src/storage' +import { loopRegistry } from '../../src/utils/loop-registry' describe('reconcileSandboxes', () => { let mockSandboxManager: Partial @@ -12,6 +13,9 @@ describe('reconcileSandboxes', () => { let deps: ReconcileSandboxesDeps beforeEach(() => { + // Clear registry before each test to avoid cross-test contamination + loopRegistry.clear() + mockSandboxManager = { isActive: mock(), isLive: mock(async () => true), @@ -74,6 +78,7 @@ describe('reconcileSandboxes', () => { it('should not call start when container is already active', async () => { const state = createBaseState({ sandboxContainer: 'forge-test-loop' }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) mockSandboxManager.isActive.mockReturnValue(true) mockSandboxManager.isLive.mockResolvedValue(true) @@ -93,6 +98,7 @@ describe('reconcileSandboxes', () => { it('should backfill sandboxContainer when container is active but name is missing', async () => { const state = createBaseState({ sandboxContainer: null }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) mockSandboxManager.isActive.mockReturnValue(true) mockSandboxManager.isLive.mockResolvedValue(true) @@ -111,6 +117,7 @@ describe('reconcileSandboxes', () => { it('should call restore when container name exists but container is not active', async () => { const state = createBaseState({ sandboxContainer: 'forge-test-loop' }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) mockSandboxManager.isActive.mockReturnValue(false) @@ -123,6 +130,7 @@ describe('reconcileSandboxes', () => { it('should call start when no container name exists', async () => { const state = createBaseState({ sandboxContainer: null }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) mockLoopService.getActiveState.mockReturnValue(state) mockSandboxManager.isActive.mockReturnValue(false) @@ -137,6 +145,7 @@ describe('reconcileSandboxes', () => { it('should skip loops without sandbox enabled', async () => { const state = createBaseState({ sandbox: false }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) await reconcileSandboxes(deps) @@ -149,6 +158,7 @@ describe('reconcileSandboxes', () => { it('should skip loops without worktreeDir', async () => { const state = createBaseState({ worktreeDir: '' }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) await reconcileSandboxes(deps) @@ -160,6 +170,7 @@ describe('reconcileSandboxes', () => { it('should correct stale sandboxContainer when it differs from manager value', async () => { const state = createBaseState({ sandboxContainer: 'forge-stale-name' }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) mockLoopService.getActiveState.mockReturnValue(state) mockSandboxManager.isActive.mockReturnValue(true) @@ -179,6 +190,7 @@ describe('reconcileSandboxes', () => { it('should not set state when container is active and name already matches', async () => { const state = createBaseState({ sandboxContainer: 'forge-test-loop' }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) mockSandboxManager.isActive.mockReturnValue(true) mockSandboxManager.getActive.mockReturnValue({ @@ -196,6 +208,8 @@ describe('reconcileSandboxes', () => { const state1 = createBaseState({ loopName: 'test-loop-1' }) const state2 = createBaseState({ loopName: 'test-loop-2' }) + loopRegistry.add('test-loop-1') + loopRegistry.add('test-loop-2') mockLoopService.listActive.mockReturnValue([state1, state2]) mockLoopService.getActiveState.mockImplementation((loopName) => { if (loopName === 'test-loop-1') return state1 @@ -224,6 +238,7 @@ describe('reconcileSandboxes', () => { it('should prevent concurrent execution (re-entrancy guard)', async () => { const state = createBaseState() + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) // Track when isActive is called @@ -253,6 +268,7 @@ describe('reconcileSandboxes', () => { it('should restore container when map is stale but Docker reports container missing', async () => { const state = createBaseState({ sandboxContainer: 'forge-test-loop' }) + loopRegistry.add('test-loop') mockLoopService.listActive.mockReturnValue([state]) // Map says active, but isLive will check Docker mockSandboxManager.isActive.mockReturnValue(true) diff --git a/test/services/attach-loop.test.ts b/test/services/attach-loop.test.ts index 46ba2de107..a85ba0e361 100644 --- a/test/services/attach-loop.test.ts +++ b/test/services/attach-loop.test.ts @@ -370,6 +370,7 @@ describe('attachLoopToSession', () => { maxIterations: 50, startedAt: new Date(Date.now() - 100000).toISOString(), phase: 'coding' as const, + status: 'cancelled' as const, worktree: true, auditCount: 0, errorCount: 0, @@ -431,6 +432,7 @@ describe('attachLoopToSession', () => { maxIterations: 50, startedAt: new Date().toISOString(), phase: 'coding', + status: 'running' as const, worktree: true, auditCount: 0, errorCount: 0, diff --git a/test/services/execution-restart.test.ts b/test/services/execution-restart.test.ts index fd79e79603..3391c1df46 100644 --- a/test/services/execution-restart.test.ts +++ b/test/services/execution-restart.test.ts @@ -1,5 +1,4 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' -import Database from 'better-sqlite3' import { mkdtempSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' @@ -14,6 +13,8 @@ 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 { LoopService } from '../../src/loop/service' +const Database = require('better-sqlite3') +type Database = ReturnType const mockLogger: Logger = { log: () => {}, @@ -31,6 +32,16 @@ describe('handleLoopRestart from stall_timeout', () => { let sectionPlansRepo: SectionPlansRepo let loopService: LoopService + const mockWorkspaceStatusRegistry = { + awaitConnected: async () => ({ connected: true }), + } + + const mockPendingTeardowns = { + register: () => {}, + unregister: () => {}, + get: () => undefined, + } + beforeEach(() => { const tempDir = mkdtempSync(join(tmpdir(), 'exec-restart-test-')) db = new Database(join(tempDir, 'test.db')) @@ -292,6 +303,8 @@ describe('handleLoopRestart from stall_timeout', () => { loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + pendingTeardowns: mockPendingTeardowns as any, }) const result = await service.dispatch( @@ -402,6 +415,8 @@ describe('handleLoopRestart from stall_timeout', () => { loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + pendingTeardowns: mockPendingTeardowns as any, }) const result = await service.dispatch( @@ -494,6 +509,8 @@ describe('handleLoopRestart from stall_timeout', () => { loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + pendingTeardowns: mockPendingTeardowns as any, }) const result = await service.dispatch( @@ -602,6 +619,8 @@ describe('handleLoopRestart from stall_timeout', () => { loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + pendingTeardowns: mockPendingTeardowns as any, }) const result = await service.dispatch( @@ -702,6 +721,8 @@ describe('handleLoopRestart from stall_timeout', () => { loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + pendingTeardowns: mockPendingTeardowns as any, }) const result = await service.dispatch( @@ -813,6 +834,8 @@ describe('handleLoopRestart from stall_timeout', () => { loop: mockLoopService as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + pendingTeardowns: mockPendingTeardowns as any, }) const result = await service.dispatch( @@ -844,3 +867,408 @@ describe('handleLoopRestart from stall_timeout', () => { expect(abortCalls[0].sessionID).toBe('session-old') }) }) + +describe('handleLoopRestart restartability rules', () => { + let db: Database + let loopsRepo: LoopsRepo + let plansRepo: PlansRepo + let reviewFindingsRepo: ReviewFindingsRepo + let sectionPlansRepo: SectionPlansRepo + let loopService: LoopService + + beforeEach(() => { + const tempDir = mkdtempSync(join(tmpdir(), 'exec-restart-rules-test-')) + db = new Database(join(tempDir, 'test.db')) + + db.exec(` + CREATE TABLE loops ( + project_id TEXT NOT NULL, + loop_name TEXT NOT NULL, + status TEXT NOT NULL, + current_session_id TEXT NOT NULL, + worktree INTEGER NOT NULL, + worktree_dir TEXT NOT NULL, + session_directory TEXT, + worktree_branch TEXT, + project_dir TEXT NOT NULL, + max_iterations INTEGER NOT NULL, + iteration INTEGER NOT NULL DEFAULT 0, + audit_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + phase TEXT NOT NULL, + execution_model TEXT, + auditor_model TEXT, + model_failed INTEGER NOT NULL DEFAULT 0, + sandbox INTEGER NOT NULL DEFAULT 0, + sandbox_container TEXT, + started_at INTEGER NOT NULL, + completed_at INTEGER, + termination_reason TEXT, + completion_summary TEXT, + workspace_id TEXT, + host_session_id TEXT, + audit_session_id TEXT, + current_section_index INTEGER NOT NULL DEFAULT 0, + total_sections INTEGER NOT NULL DEFAULT 0, + final_audit_done INTEGER NOT NULL DEFAULT 0, + final_audit_attempts INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (project_id, loop_name) + ) + `) + + db.exec(` + CREATE TABLE loop_large_fields ( + project_id TEXT NOT NULL, + loop_name TEXT NOT NULL, + last_audit_result TEXT, + PRIMARY KEY (project_id, loop_name), + FOREIGN KEY (project_id, loop_name) REFERENCES loops(project_id, loop_name) ON DELETE CASCADE + ) + `) + + db.exec(` + CREATE TABLE plans ( + project_id TEXT NOT NULL, + loop_name TEXT, + session_id TEXT, + content TEXT NOT NULL, + updated_at INTEGER NOT NULL, + CHECK (loop_name IS NOT NULL OR session_id IS NOT NULL), + CHECK (NOT (loop_name IS NOT NULL AND session_id IS NOT NULL)), + UNIQUE (project_id, loop_name), + UNIQUE (project_id, session_id) + ) + `) + + db.exec(` + CREATE TABLE review_findings ( + project_id TEXT NOT NULL, + loop_name TEXT NOT NULL DEFAULT '', + file TEXT NOT NULL, + line INTEGER NOT NULL, + severity TEXT NOT NULL, + description TEXT NOT NULL, + scenario TEXT, + created_at INTEGER NOT NULL, + section_index INTEGER, + PRIMARY KEY (project_id, loop_name, file, line, section_index) + ) + `) + + db.exec(` + CREATE TABLE section_plans ( + project_id TEXT NOT NULL, + loop_name TEXT NOT NULL, + section_index INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','in_progress','completed','failed')), + attempts INTEGER NOT NULL DEFAULT 0, + started_at INTEGER, + completed_at INTEGER, + summary_done TEXT, + summary_deviations TEXT, + summary_follow_ups TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (project_id, loop_name, section_index) + ) + `) + + loopsRepo = createLoopsRepo(db) + plansRepo = createPlansRepo(db) + reviewFindingsRepo = createReviewFindingsRepo(db) + sectionPlansRepo = createSectionPlansRepo(db) + loopService = createLoopService( + loopsRepo, + plansRepo, + reviewFindingsRepo, + PROJECT_ID, + mockLogger, + undefined, + undefined, + undefined, + sectionPlansRepo, + ) + }) + + afterEach(() => { + try { db.close() } catch {} + }) + + function insertLoop(overrides: Partial<{ + loopName: string + phase: string + currentSectionIndex: number + totalSections: number + iteration: number + status: string + terminationReason: string | null + active: boolean + worktree: boolean + worktreeDir: string + workspaceId: string | null + }> = {}) { + const defaults = { + loopName: 'test-loop', + phase: 'coding', + currentSectionIndex: 0, + totalSections: 0, + iteration: 1, + status: 'cancelled', + terminationReason: 'user_aborted', + active: false, + worktree: false, + worktreeDir: '/tmp/test-worktree', + workspaceId: null as string | null, + } + const opts = { ...defaults, ...overrides } + loopsRepo.insert({ + projectId: PROJECT_ID, + loopName: opts.loopName, + status: opts.status as any, + currentSessionId: 'session-old', + worktree: opts.worktree, + worktreeDir: opts.worktreeDir, + worktreeBranch: null, + projectDir: '/tmp', + maxIterations: 10, + iteration: opts.iteration, + auditCount: 0, + errorCount: 0, + phase: opts.phase as any, + executionModel: null, + auditorModel: null, + modelFailed: false, + sandbox: false, + sandboxContainer: null, + startedAt: Date.now(), + completedAt: null, + terminationReason: opts.terminationReason, + completionSummary: null, + workspaceId: opts.workspaceId, + hostSessionId: null, + currentSectionIndex: opts.currentSectionIndex, + totalSections: opts.totalSections, + finalAuditDone: 0, + }, { lastAuditResult: null }) + } + + async function createMockService() { + const noopFn = () => {} + const mockLoopService: Partial = { + listActive: () => loopService.listActive(), + listRecent: () => loopService.listRecent(), + 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', + generateUniqueLoopName: (name) => name, + } + + const sessionCreateSpy = vi.fn().mockResolvedValue({ data: { id: 'new-session-restart' } }) + const sessionPromptAsyncSpy = vi.fn().mockResolvedValue({}) + const workspaceCreateSpy = vi.fn().mockResolvedValue({ data: { id: 'ws-new', directory: '/tmp', branch: 'main' } }) + + const mockV2Client = { + session: { + create: sessionCreateSpy, + get: async () => ({ data: {} }), + promptAsync: sessionPromptAsyncSpy, + abort: async () => ({}), + delete: async () => ({}), + messages: async () => ({ data: [] }), + status: async () => ({ data: {} }), + }, + experimental: { + workspace: { + list: async () => ({ data: [] }), + remove: async () => ({}), + create: workspaceCreateSpy, + warp: async () => ({}), + syncList: async () => ({}), + }, + session: { list: async () => ({ data: [] }) }, + }, + tui: { publish: async () => ({}), selectSession: async () => ({}) }, + worktree: { create: async () => ({ data: { directory: '/tmp/wt', branch: 'main' } }) }, + } + + const mockLoopHandler = { + runExclusive: async (name: string, fn: () => Promise) => fn(), + startWatchdog: noopFn, + clearLoopTimers: noopFn, + } + + const mockWorkspaceStatusRegistry = { + awaitConnected: async () => ({ connected: true }), + } + + const mockPendingTeardowns = { + register: noopFn, + unregister: noopFn, + get: () => undefined, + } + + 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', + v2: mockV2Client as any, + plansRepo, + loopsRepo, + loop: mockLoopService as any, + loopHandler: mockLoopHandler as any, + sectionPlansRepo, + workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, + pendingTeardowns: mockPendingTeardowns as any, + }) + + return { + service, + sessionCreateSpy, + sessionPromptAsyncSpy, + workspaceCreateSpy, + } + } + + test.each([ + ['cancelled', 'user_aborted'], + ['errored', 'max_iterations'], + ['errored', 'error_max_retries'], + ['stalled', 'stall_timeout'], + ['errored', 'final_audit_retry_exhausted'], + ])( + 'restarts %s loop with terminationReason %s without force', + async (status, terminationReason) => { + const loopName = `restart-${status}-${terminationReason.replace(/:/g, '_')}` + insertLoop({ + loopName, + status, + terminationReason, + phase: 'coding', + }) + + const { service } = await createMockService() + const result = await service.dispatch( + { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + type: 'loop.restart' as const, + selector: { kind: 'exact' as const, name: loopName }, + }, + ) + + expect(result.ok).toBe(true) + if (!result.ok) return + + expect(result.data.loopName).toBe(loopName) + expect(result.data.previousSessionId).toBe('session-old') + expect(result.data.sessionId).toBe('new-session-restart') + expect(result.data.iteration).toBe(1) + + const newState = loopService.getActiveState(loopName) + expect(newState).not.toBeNull() + expect(newState?.terminationReason).toBeFalsy() + expect(newState?.completedAt).toBeFalsy() + expect(newState?.active).toBe(true) + }, + ) + + test('completed loop cannot restart', async () => { + insertLoop({ + loopName: 'completed-loop', + status: 'completed', + terminationReason: 'completed', + phase: 'coding', + }) + + const { service, sessionCreateSpy, sessionPromptAsyncSpy } = await createMockService() + const result = await service.dispatch( + { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + type: 'loop.restart' as const, + selector: { kind: 'exact' as const, name: 'completed-loop' }, + }, + ) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.message).toContain('completed successfully and cannot be restarted') + + expect(sessionCreateSpy).not.toHaveBeenCalled() + expect(sessionPromptAsyncSpy).not.toHaveBeenCalled() + + const newState = loopService.getActiveState('completed-loop') + expect(newState).toBeNull() + }) + + test('completed loop with null terminationReason cannot restart', async () => { + insertLoop({ + loopName: 'completed-loop-null-reason', + status: 'completed', + terminationReason: null, + phase: 'coding', + }) + + const { service, sessionCreateSpy, sessionPromptAsyncSpy } = await createMockService() + const result = await service.dispatch( + { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + type: 'loop.restart' as const, + selector: { kind: 'exact' as const, name: 'completed-loop-null-reason' }, + }, + ) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.message).toContain('completed successfully and cannot be restarted') + + expect(sessionCreateSpy).not.toHaveBeenCalled() + expect(sessionPromptAsyncSpy).not.toHaveBeenCalled() + + const newState = loopService.getActiveState('completed-loop-null-reason') + expect(newState).toBeNull() + }) + + test('missing worktree blocks restart', async () => { + const missingDir = join(tmpdir(), `missing-worktree-${Date.now()}`) + insertLoop({ + loopName: 'missing-worktree-loop', + status: 'cancelled', + terminationReason: 'user_aborted', + worktree: true, + worktreeDir: missingDir, + phase: 'coding', + }) + + const { service, sessionCreateSpy, sessionPromptAsyncSpy, workspaceCreateSpy } = await createMockService() + const result = await service.dispatch( + { surface: 'api', projectId: PROJECT_ID, directory: '/tmp/test' }, + { + type: 'loop.restart' as const, + selector: { kind: 'exact' as const, name: 'missing-worktree-loop' }, + }, + ) + + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.message).toContain('worktree directory no longer exists') + + expect(sessionCreateSpy).not.toHaveBeenCalled() + expect(sessionPromptAsyncSpy).not.toHaveBeenCalled() + expect(workspaceCreateSpy).not.toHaveBeenCalled() + + const newState = loopService.getActiveState('missing-worktree-loop') + expect(newState).toBeNull() + }) +}) diff --git a/test/watchdog.test.ts b/test/watchdog.test.ts index 9428364e89..88fd635ccb 100644 --- a/test/watchdog.test.ts +++ b/test/watchdog.test.ts @@ -14,6 +14,7 @@ function createState(overrides?: Partial): LoopState { phase: 'coding', errorCount: 0, auditCount: 0, + status: 'running', currentSectionIndex: 0, totalSections: 0, finalAuditDone: false, diff --git a/test/workspace/classify-stale.test.ts b/test/workspace/classify-stale.test.ts index 430284891b..c765ed2fab 100644 --- a/test/workspace/classify-stale.test.ts +++ b/test/workspace/classify-stale.test.ts @@ -190,7 +190,7 @@ describe('classifyForgeWorkspace', () => { expect(result).toEqual({ action: 'remove-fully', reason: 'completed', loopName: 'test-loop' }) }) - test('cancelled loop → remove-registration-only/restartable-terminal', () => { + test('cancelled loop → remove-registration-only/restartable', () => { const entry = { id: 'ws1', type: 'forge', @@ -203,10 +203,10 @@ describe('classifyForgeWorkspace', () => { get: vi.fn().mockReturnValue({ projectId, loopName: 'test-loop', status: 'cancelled' }), }) const result = classifyForgeWorkspace(entry, loopsRepo, projectId, projectDirectory) - expect(result).toEqual({ action: 'remove-registration-only', reason: 'restartable-terminal', loopName: 'test-loop' }) + expect(result).toEqual({ action: 'remove-registration-only', reason: 'restartable', loopName: 'test-loop' }) }) - test('errored loop → remove-registration-only/restartable-terminal', () => { + test('errored loop → remove-registration-only/restartable', () => { const entry = { id: 'ws1', type: 'forge', @@ -219,10 +219,10 @@ describe('classifyForgeWorkspace', () => { get: vi.fn().mockReturnValue({ projectId, loopName: 'test-loop', status: 'errored' }), }) const result = classifyForgeWorkspace(entry, loopsRepo, projectId, projectDirectory) - expect(result).toEqual({ action: 'remove-registration-only', reason: 'restartable-terminal', loopName: 'test-loop' }) + expect(result).toEqual({ action: 'remove-registration-only', reason: 'restartable', loopName: 'test-loop' }) }) - test('stalled loop → remove-registration-only/restartable-terminal', () => { + test('stalled loop → remove-registration-only/restartable', () => { const entry = { id: 'ws1', type: 'forge', @@ -235,6 +235,6 @@ describe('classifyForgeWorkspace', () => { get: vi.fn().mockReturnValue({ projectId, loopName: 'test-loop', status: 'stalled' }), }) const result = classifyForgeWorkspace(entry, loopsRepo, projectId, projectDirectory) - expect(result).toEqual({ action: 'remove-registration-only', reason: 'restartable-terminal', loopName: 'test-loop' }) + expect(result).toEqual({ action: 'remove-registration-only', reason: 'restartable', loopName: 'test-loop' }) }) }) diff --git a/vitest.config.ts b/vitest.config.ts index feef488f46..04fd1efcb2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -72,6 +72,7 @@ export default defineConfig({ 'test/loop-session-usage-repo.test.ts', 'test/storage-migrations.test.ts', 'test/worktree-log.test.ts', + 'test/plugin.test.ts', ], globals: true, },