opencode-pilot 0.24.9 → 0.24.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  class OpencodePilot < Formula
2
2
  desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
3
3
  homepage "https://github.com/athal7/opencode-pilot"
4
- url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.8.tar.gz"
5
- sha256 "23b550fd74fee8f2fd8c60c8358f67065ee77af921fdc3bc2ff26a6cfef68ae6"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.10.tar.gz"
5
+ sha256 "09c76da7756c11bfd192fb74c3b9d7e0335678258cfaebf8e7ef3473f7f74e55"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.24.9",
3
+ "version": "0.24.11",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -3,6 +3,20 @@
3
3
  *
4
4
  * Starts OpenCode sessions with configurable prompts.
5
5
  * Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
6
+ *
7
+ * ## Session creation invariants
8
+ *
9
+ * Every session created by this module MUST satisfy all three invariants.
10
+ * See service/session-context.js for full documentation and rationale.
11
+ *
12
+ * A. Project scoping – session's projectID ≠ 'global' → visible in UI
13
+ * B. Working directory – agent operates in the worktree, not the main repo
14
+ * C. Session isolation – reuse never crosses PR/issue boundaries
15
+ *
16
+ * The SessionContext value object carries both directories together so no
17
+ * call site can accidentally pass the wrong one. When you change session
18
+ * creation logic, verify all three invariants in the integration tests under
19
+ * "session creation invariants".
6
20
  */
7
21
 
8
22
  import { execSync } from "child_process";
@@ -11,6 +25,7 @@ import { debug } from "./logger.js";
11
25
  import { getNestedValue } from "./utils.js";
12
26
  import { getServerPort } from "./repo-config.js";
13
27
  import { resolveWorktreeDirectory, getProjectInfo, getProjectInfoForDirectory } from "./worktree.js";
28
+ import { SessionContext } from "./session-context.js";
14
29
  import path from "path";
15
30
  import os from "os";
16
31
 
@@ -672,24 +687,30 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
672
687
 
673
688
  /**
674
689
  * Create a session via the OpenCode HTTP API
675
- *
690
+ *
691
+ * Satisfies session creation invariants A and B (see module header):
692
+ * A. Creates the session scoped to sessionCtx.projectDirectory so OpenCode
693
+ * assigns the correct projectID (session visible in desktop UI).
694
+ * B. Sends the first message with sessionCtx.workingDirectory so the agent
695
+ * operates in the worktree, not the main repo.
696
+ *
676
697
  * @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
677
- * @param {string} directory - Working directory for file operations (may be a worktree)
698
+ * @param {SessionContext} sessionCtx - Carries both directories (see session-context.js)
678
699
  * @param {string} prompt - The prompt/message to send
679
700
  * @param {object} [options] - Options
680
- * @param {string} [options.projectDirectory] - Project directory for session scoping (defaults to directory)
681
701
  * @param {string} [options.title] - Session title
682
702
  * @param {string} [options.agent] - Agent to use
683
703
  * @param {string} [options.model] - Model to use
684
704
  * @param {function} [options.fetch] - Custom fetch function (for testing)
685
705
  * @returns {Promise<object>} Result with sessionId, success, error
686
706
  */
687
- export async function createSessionViaApi(serverUrl, directory, prompt, options = {}) {
707
+ export async function createSessionViaApi(serverUrl, sessionCtx, prompt, options = {}) {
688
708
  const fetchFn = options.fetch || fetch;
689
709
  const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
690
- // Use project directory for session creation (determines projectID in OpenCode).
691
- // The working directory (which may be a worktree) is used for messages/commands.
692
- const projectDir = options.projectDirectory || directory;
710
+ // Invariant A: use projectDirectory so OpenCode assigns the correct projectID.
711
+ // Invariant B: use workingDirectory for messages so the agent operates in the worktree.
712
+ const projectDir = sessionCtx.projectDirectory;
713
+ const directory = sessionCtx.workingDirectory;
693
714
 
694
715
  let session = null;
695
716
 
@@ -832,17 +853,25 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
832
853
  }
833
854
 
834
855
  /**
835
- * Execute session creation/reuse in a specific directory
836
- * Internal helper for executeAction - handles prompt building, session reuse, and API calls
837
- *
856
+ * Execute session creation/reuse for the given session context.
857
+ * Internal helper for executeAction - handles prompt building, session reuse, and API calls.
858
+ *
859
+ * Enforces invariant C (session isolation): session reuse is skipped entirely
860
+ * when sessionCtx.isWorktree is true. Each PR/issue running in a worktree must
861
+ * get its own session because:
862
+ * - Querying by workingDirectory finds old sessions with projectID='global'
863
+ * - Querying by projectDirectory finds sessions for unrelated PRs in the same project
864
+ *
838
865
  * @param {string} serverUrl - OpenCode server URL
839
- * @param {string} cwd - Working directory for the session
866
+ * @param {SessionContext} sessionCtx - Carries both directories
840
867
  * @param {object} item - Item to create session for
841
868
  * @param {object} config - Repo config with action settings
842
869
  * @param {object} [options] - Execution options
843
870
  * @returns {Promise<object>} Result with command, success, sessionId, etc.
844
871
  */
845
- async function executeInDirectory(serverUrl, cwd, item, config, options = {}, projectDirectory = null) {
872
+ async function executeInDirectory(serverUrl, sessionCtx, item, config, options = {}) {
873
+ const cwd = sessionCtx.workingDirectory;
874
+
846
875
  // Build prompt from template
847
876
  const prompt = buildPromptFromTemplate(config.prompt || "default", item);
848
877
 
@@ -882,15 +911,12 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
882
911
  }
883
912
  }
884
913
 
885
- // Check if we should try to reuse an existing session.
886
- // Skip reuse when working in a worktree (projectDirectory differs from cwd),
887
- // because querying by worktree dir finds old sessions with projectID "global",
888
- // and querying by project dir finds unrelated sessions for other PRs.
889
- // Each worktree should get its own correctly-scoped session.
914
+ // Invariant C: skip findReusableSession when in a worktree.
915
+ // Each worktree (= each PR/issue) must get its own session to prevent
916
+ // cross-PR contamination. See session-context.js for full rationale.
890
917
  const reuseActiveSession = config.reuse_active_session !== false; // default true
891
- const inWorktree = projectDirectory && projectDirectory !== cwd;
892
918
 
893
- if (reuseActiveSession && !inWorktree && !options.dryRun) {
919
+ if (reuseActiveSession && !sessionCtx.isWorktree && !options.dryRun) {
894
920
  const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
895
921
 
896
922
  if (existingSession) {
@@ -927,8 +953,7 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
927
953
  };
928
954
  }
929
955
 
930
- const result = await createSessionViaApi(serverUrl, cwd, prompt, {
931
- projectDirectory: projectDirectory || cwd,
956
+ const result = await createSessionViaApi(serverUrl, sessionCtx, prompt, {
932
957
  title: sessionTitle,
933
958
  agent: config.agent,
934
959
  model: config.model,
@@ -987,27 +1012,29 @@ export async function executeAction(item, config, options = {}) {
987
1012
  };
988
1013
  }
989
1014
 
990
- // If existing_directory is provided (reprocessing same item), use it directly
991
- // This preserves the worktree from the previous run even if its name doesn't match the template
1015
+ // If existing_directory is provided (reprocessing same item), use it directly.
1016
+ // This preserves the worktree from the previous run even if its name doesn't match the template.
1017
+ // Build a worktree SessionContext since existing_directory is a worktree path.
992
1018
  if (config.existing_directory) {
993
1019
  debug(`executeAction: using existing_directory=${config.existing_directory}`);
994
1020
  const cwd = expandPath(config.existing_directory);
995
- return await executeInDirectory(serverUrl, cwd, item, config, options, baseCwd);
1021
+ const sessionCtx = SessionContext.forWorktree(baseCwd, cwd);
1022
+ return await executeInDirectory(serverUrl, sessionCtx, item, config, options);
996
1023
  }
997
1024
 
998
- // Resolve worktree directory if configured
999
- // This allows creating sessions in isolated worktrees instead of the main project
1025
+ // Resolve worktree directory if configured.
1026
+ // This allows creating sessions in isolated worktrees instead of the main project.
1000
1027
  let worktreeMode = config.worktree;
1001
1028
 
1002
- // If worktree_name is configured, enable worktree mode (explicit configuration)
1003
- // This allows presets to specify worktree isolation without requiring existing sandboxes
1029
+ // If worktree_name is configured, enable worktree mode (explicit configuration).
1030
+ // This allows presets to specify worktree isolation without requiring existing sandboxes.
1004
1031
  if (!worktreeMode && config.worktree_name) {
1005
1032
  debug(`executeAction: worktree_name configured, enabling worktree mode`);
1006
1033
  worktreeMode = 'new';
1007
1034
  }
1008
1035
 
1009
- // Auto-detect worktree support: check if the project has sandboxes
1010
- // This is a fallback for when worktree isn't explicitly configured
1036
+ // Auto-detect worktree support: check if the project has sandboxes.
1037
+ // This is a fallback for when worktree isn't explicitly configured.
1011
1038
  if (!worktreeMode) {
1012
1039
  // Look up project info for this specific directory (not just /project/current)
1013
1040
  const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
@@ -1044,5 +1071,8 @@ export async function executeAction(item, config, options = {}) {
1044
1071
 
1045
1072
  debug(`executeAction: using cwd=${cwd}`);
1046
1073
 
1047
- return await executeInDirectory(serverUrl, cwd, item, config, options, baseCwd);
1074
+ // Build a SessionContext so downstream functions always have both directories.
1075
+ // forWorktree correctly sets isWorktree=true when cwd differs from baseCwd.
1076
+ const sessionCtx = SessionContext.forWorktree(baseCwd, cwd);
1077
+ return await executeInDirectory(serverUrl, sessionCtx, item, config, options);
1048
1078
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * session-context.js - SessionContext value object
3
+ *
4
+ * Encapsulates the two-directory problem that repeatedly caused session
5
+ * creation regressions (v0.24.7 through v0.24.10).
6
+ *
7
+ * ## The Problem
8
+ *
9
+ * OpenCode's POST /session API accepts a single `directory` parameter that
10
+ * conflates two distinct concerns:
11
+ *
12
+ * 1. **Project scoping** (projectID) — which project the session belongs to
13
+ * in the desktop UI. Requires the *project directory* (the main git repo).
14
+ *
15
+ * 2. **Working directory** — where the agent executes file operations.
16
+ * Requires the *worktree directory* when using git worktrees.
17
+ *
18
+ * When a worktree is active, these directories differ. Passing the wrong one
19
+ * satisfies one requirement but silently breaks the other.
20
+ *
21
+ * ## The Three Invariants
22
+ *
23
+ * Any correct session creation MUST satisfy all three:
24
+ *
25
+ * A. **Project scoping**: Session's projectID matches the project (not
26
+ * 'global'). The session is visible in the desktop app.
27
+ * → Requires POST /session?directory=<projectDirectory>
28
+ *
29
+ * B. **Working directory**: Agent operates in the correct location.
30
+ * In worktree mode the session's working dir must be the worktree path,
31
+ * so file reads/writes go to the right branch.
32
+ * → Requires per-message directory=<workingDirectory>
33
+ * (or PATCH /session/:id to update the session working dir)
34
+ *
35
+ * C. **Session isolation**: Session reuse only finds sessions for the
36
+ * *same work item* (same PR/issue), not other PRs sharing the project.
37
+ * → Worktree sessions must NOT be reused across items; each PR gets
38
+ * its own session.
39
+ *
40
+ * ## The Solution
41
+ *
42
+ * - Create sessions with `projectDirectory` (satisfies A).
43
+ * - Send messages with `workingDirectory` (satisfies B).
44
+ * - Skip `findReusableSession` entirely when in a worktree (satisfies C),
45
+ * because neither the worktree path nor the project path can safely scope
46
+ * reuse to a single PR.
47
+ *
48
+ * ## Worktree Detection
49
+ *
50
+ * A session is "in a worktree" when `workingDirectory !== projectDirectory`.
51
+ * Non-worktree sessions have both set to the same value.
52
+ */
53
+
54
+ export class SessionContext {
55
+ /**
56
+ * @param {string} projectDirectory - Base git repo path. Used for:
57
+ * - POST /session?directory=... (sets projectID for UI visibility)
58
+ * - PATCH /session/:id?directory=... (if post-creation re-scoping needed)
59
+ * - listSessions query (for session reuse lookup in non-worktree mode)
60
+ *
61
+ * @param {string} workingDirectory - Directory where the agent does work. Used for:
62
+ * - POST /session/:id/message?directory=... (file operations)
63
+ * - POST /session/:id/command?directory=... (slash commands)
64
+ * Equals projectDirectory when not using worktrees.
65
+ */
66
+ constructor(projectDirectory, workingDirectory) {
67
+ if (!projectDirectory) throw new Error('SessionContext: projectDirectory is required');
68
+ if (!workingDirectory) throw new Error('SessionContext: workingDirectory is required');
69
+ this.projectDirectory = projectDirectory;
70
+ this.workingDirectory = workingDirectory;
71
+ Object.freeze(this);
72
+ }
73
+
74
+ /**
75
+ * True when the session runs in a worktree separate from the main repo.
76
+ * Worktree sessions must NOT participate in findReusableSession (invariant C):
77
+ * - Querying by workingDirectory finds old sessions scoped to 'global' (wrong projectID)
78
+ * - Querying by projectDirectory finds sessions for other PRs in the same project
79
+ */
80
+ get isWorktree() {
81
+ return this.projectDirectory !== this.workingDirectory;
82
+ }
83
+
84
+ /**
85
+ * Factory: use this when there is no worktree (single-directory case).
86
+ */
87
+ static forProject(directory) {
88
+ return new SessionContext(directory, directory);
89
+ }
90
+
91
+ /**
92
+ * Factory: use this when a worktree has been resolved.
93
+ */
94
+ static forWorktree(projectDirectory, worktreeDirectory) {
95
+ return new SessionContext(projectDirectory, worktreeDirectory);
96
+ }
97
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Integration tests for pollOnce end-to-end orchestration
3
+ *
4
+ * Tests the full wiring: config → source → readiness → dedup → executeAction.
5
+ * Uses executeAction + createPoller directly (simulating what pollOnce does
6
+ * after fetching items) with a mock OpenCode server to capture API calls.
7
+ *
8
+ * These tests verify two properties NOT covered by the invariant tests:
9
+ *
10
+ * 1. Two PRs in the same project each get their own session (dedup does not
11
+ * collapse them — they have different IDs).
12
+ *
13
+ * 2. A PR already processed in a previous poll cycle is skipped on the next
14
+ * call (dedup state persists across pollOnce calls).
15
+ *
16
+ * The invariant tests (session-reuse.test.js "session creation invariants")
17
+ * cover WHICH directories are used. These tests cover WHETHER sessions are
18
+ * created at all.
19
+ */
20
+ import { describe, it, beforeEach, afterEach } from "node:test";
21
+ import assert from "node:assert";
22
+ import { createServer } from "node:http";
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ import { executeAction } from "../../service/actions.js";
28
+ import { createPoller } from "../../service/poller.js";
29
+
30
+ // ─── Mock server ────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Create a mock OpenCode server for testing.
34
+ * Handlers are keyed by "METHOD /path" (e.g., "POST /session").
35
+ * A `default` handler catches anything without an exact match.
36
+ */
37
+ function createMockServer(handlers = {}) {
38
+ const server = createServer((req, res) => {
39
+ const url = new URL(req.url, `http://${req.headers.host}`);
40
+ const pathname = url.pathname;
41
+ const method = req.method;
42
+
43
+ let body = "";
44
+ req.on("data", (chunk) => (body += chunk));
45
+ req.on("end", () => {
46
+ const request = {
47
+ method,
48
+ path: pathname,
49
+ directory: url.searchParams.get("directory"),
50
+ query: Object.fromEntries(url.searchParams),
51
+ body: body ? JSON.parse(body) : null,
52
+ };
53
+
54
+ // Find matching handler — try exact match first, then wildcard
55
+ const exactKey = `${method} ${pathname}`;
56
+ let handler = handlers[exactKey];
57
+
58
+ if (!handler) {
59
+ for (const [key, h] of Object.entries(handlers)) {
60
+ if (key === "default") continue;
61
+ const regexStr = "^" + key.replace(/\*/g, "[^/]+") + "$";
62
+ if (new RegExp(regexStr).test(exactKey)) {
63
+ handler = h;
64
+ break;
65
+ }
66
+ }
67
+ }
68
+
69
+ if (!handler) handler = handlers.default;
70
+
71
+ if (handler) {
72
+ const result = handler(request);
73
+ res.writeHead(result.status || 200, { "Content-Type": "application/json" });
74
+ res.end(JSON.stringify(result.body));
75
+ } else {
76
+ res.writeHead(404, { "Content-Type": "application/json" });
77
+ res.end(JSON.stringify({ error: `No handler for ${exactKey}` }));
78
+ }
79
+ });
80
+ });
81
+
82
+ return new Promise((resolve) => {
83
+ server.listen(0, "127.0.0.1", () => {
84
+ const { port } = server.address();
85
+ resolve({
86
+ url: `http://127.0.0.1:${port}`,
87
+ server,
88
+ close: () => new Promise((r) => server.close(r)),
89
+ });
90
+ });
91
+ });
92
+ }
93
+
94
+ // ─── Fetch interceptor ───────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Build a fetch interceptor that:
98
+ * - Records which sessions were created and which directories were used
99
+ * - Short-circuits POST /session/:id/message and /command to avoid
100
+ * AbortController race with the mock server (see session-reuse.test.js
101
+ * for the full explanation of this race condition)
102
+ * - Forwards everything else to the real mock server
103
+ */
104
+ function makeFetchInterceptor(calls) {
105
+ return async (url, opts) => {
106
+ const u = new URL(url);
107
+ const method = opts?.method || "GET";
108
+
109
+ // Short-circuit message/command — return mock 200 directly
110
+ if (method === "POST" && /^\/session\/[^/]+\/(message|command)$/.test(u.pathname)) {
111
+ const sessionId = u.pathname.split("/")[2];
112
+ calls.messages = calls.messages || [];
113
+ calls.messages.push({
114
+ sessionId,
115
+ directory: u.searchParams.get("directory"),
116
+ });
117
+ return new Response(JSON.stringify({ success: true }), {
118
+ status: 200,
119
+ headers: { "Content-Type": "application/json" },
120
+ });
121
+ }
122
+
123
+ if (method === "POST" && u.pathname === "/session") {
124
+ calls.sessionsCreated = (calls.sessionsCreated || 0) + 1;
125
+ }
126
+
127
+ return fetch(url, opts);
128
+ };
129
+ }
130
+
131
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
132
+
133
+ let sessionCounter = 0;
134
+
135
+ /**
136
+ * Build a minimal mock server that handles the OpenCode API calls made by
137
+ * executeAction for a worktree-based PR source.
138
+ */
139
+ async function buildWorktreeMockServer(calls) {
140
+ return createMockServer({
141
+ "GET /project": () => ({
142
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
143
+ }),
144
+ "GET /experimental/worktree": () => ({ body: [] }),
145
+ "POST /experimental/worktree": (req) => ({
146
+ body: { name: req.body?.name, directory: `/worktree/${req.body?.name}` },
147
+ }),
148
+ "GET /session": () => ({ body: [] }),
149
+ "GET /session/status": () => ({ body: {} }),
150
+ "POST /session": () => {
151
+ const id = `ses_poll_${++sessionCounter}`;
152
+ calls.createdSessionIds = calls.createdSessionIds || [];
153
+ calls.createdSessionIds.push(id);
154
+ return { body: { id } };
155
+ },
156
+ default: (req) => {
157
+ // PATCH /session/:id — title update
158
+ if (req.method === "PATCH" && req.path.startsWith("/session/")) {
159
+ return { body: {} };
160
+ }
161
+ return { status: 404, body: { error: `Unhandled: ${req.method} ${req.path}` } };
162
+ },
163
+ });
164
+ }
165
+
166
+ // ─── Tests ───────────────────────────────────────────────────────────────────
167
+
168
+ describe("integration: pollOnce end-to-end", () => {
169
+ let mockServer;
170
+ let tmpDir;
171
+ let stateFile;
172
+
173
+ beforeEach(() => {
174
+ tmpDir = mkdtempSync(join(tmpdir(), "pilot-poll-test-"));
175
+ stateFile = join(tmpDir, "poll-state.json");
176
+ sessionCounter = 0;
177
+ });
178
+
179
+ afterEach(async () => {
180
+ if (mockServer) {
181
+ await mockServer.close();
182
+ mockServer = null;
183
+ }
184
+ try {
185
+ rmSync(tmpDir, { recursive: true, force: true });
186
+ } catch {
187
+ // ignore
188
+ }
189
+ });
190
+
191
+ it("two PRs in the same project each get their own session", async () => {
192
+ // Two PRs, same project, worktree_name configured.
193
+ // Both are ready (no readiness filter). Each must get a separate session.
194
+ // This exercises: source → readiness → executeAction × 2 → two sessions.
195
+
196
+ const calls = {};
197
+ mockServer = await buildWorktreeMockServer(calls);
198
+ const serverUrl = mockServer.url;
199
+
200
+ const items = [
201
+ { id: "pr-101", number: 101, title: "PR #101", state: "open" },
202
+ { id: "pr-102", number: 102, title: "PR #102", state: "open" },
203
+ ];
204
+
205
+ const poller = createPoller({ stateFile });
206
+ const fetchFn = makeFetchInterceptor(calls);
207
+ const results = [];
208
+
209
+ for (const item of items) {
210
+ if (poller.isProcessed(item.id)) continue;
211
+
212
+ const actionConfig = {
213
+ path: "/proj",
214
+ prompt: "review",
215
+ worktree_name: "pr-{number}",
216
+ };
217
+
218
+ const result = await executeAction(item, actionConfig, {
219
+ discoverServer: async () => serverUrl,
220
+ fetch: fetchFn,
221
+ });
222
+
223
+ results.push({ item, result });
224
+
225
+ if (result.success) {
226
+ poller.markProcessed(item.id, {
227
+ source: "test-prs",
228
+ sessionId: result.sessionId,
229
+ directory: result.directory,
230
+ });
231
+ }
232
+ }
233
+
234
+ // Both PRs should succeed
235
+ assert.strictEqual(results.length, 2, "Both PRs should be processed");
236
+ assert.ok(results[0].result.success, "PR #101 should succeed");
237
+ assert.ok(results[1].result.success, "PR #102 should succeed");
238
+
239
+ // Each PR must get its own session
240
+ assert.strictEqual(
241
+ calls.createdSessionIds?.length,
242
+ 2,
243
+ "Two separate sessions must be created — one per PR"
244
+ );
245
+ assert.notStrictEqual(
246
+ results[0].result.sessionId,
247
+ results[1].result.sessionId,
248
+ "Session IDs must differ between PRs"
249
+ );
250
+
251
+ // Both are now marked as processed
252
+ assert.ok(poller.isProcessed("pr-101"), "PR #101 should be marked processed");
253
+ assert.ok(poller.isProcessed("pr-102"), "PR #102 should be marked processed");
254
+ });
255
+
256
+ it("already-processed PR is skipped on second poll cycle", async () => {
257
+ // One PR, processed in cycle 1. On cycle 2 it must be skipped.
258
+ // This exercises the dedup path: poller.isProcessed() → continue.
259
+
260
+ const calls = {};
261
+ mockServer = await buildWorktreeMockServer(calls);
262
+ const serverUrl = mockServer.url;
263
+
264
+ const item = { id: "pr-200", number: 200, title: "PR #200", state: "open" };
265
+ const poller = createPoller({ stateFile });
266
+ const fetchFn = makeFetchInterceptor(calls);
267
+
268
+ const actionConfig = {
269
+ path: "/proj",
270
+ prompt: "review",
271
+ worktree_name: "pr-{number}",
272
+ };
273
+
274
+ // ── Cycle 1: process the PR ──────────────────────────────────────────
275
+ const result1 = await executeAction(item, actionConfig, {
276
+ discoverServer: async () => serverUrl,
277
+ fetch: fetchFn,
278
+ });
279
+
280
+ assert.ok(result1.success, "Cycle 1: PR should be processed successfully");
281
+ poller.markProcessed(item.id, {
282
+ source: "test-prs",
283
+ sessionId: result1.sessionId,
284
+ directory: result1.directory,
285
+ });
286
+
287
+ const sessionsAfterCycle1 = calls.createdSessionIds?.length || 0;
288
+ assert.strictEqual(sessionsAfterCycle1, 1, "Cycle 1: exactly one session created");
289
+
290
+ // ── Cycle 2: same PR appears again — must be skipped ─────────────────
291
+ let cycle2Executed = false;
292
+
293
+ if (!poller.isProcessed(item.id)) {
294
+ cycle2Executed = true;
295
+ await executeAction(item, actionConfig, {
296
+ discoverServer: async () => serverUrl,
297
+ fetch: fetchFn,
298
+ });
299
+ }
300
+
301
+ assert.strictEqual(cycle2Executed, false, "Cycle 2: PR must be skipped (already processed)");
302
+ assert.strictEqual(
303
+ calls.createdSessionIds?.length || 0,
304
+ 1,
305
+ "Cycle 2: no new session must be created"
306
+ );
307
+ });
308
+ });
@@ -967,3 +967,275 @@ describe("integration: stacked PR session reuse", () => {
967
967
  }
968
968
  });
969
969
  });
970
+
971
+ /**
972
+ * Session creation invariants
973
+ *
974
+ * These tests encode the three correctness requirements for every session
975
+ * created by pilot. They assert OUTCOMES (which directory was used for which
976
+ * API call), not implementation details (which function was called or how
977
+ * parameters were threaded).
978
+ *
979
+ * DO NOT CHANGE these tests when refactoring session creation internals.
980
+ * They should only change if the desired behavior changes.
981
+ *
982
+ * See service/session-context.js for the full invariant documentation.
983
+ *
984
+ * A. Project scoping – POST /session uses projectDirectory
985
+ * B. Working directory – messages use workingDirectory (the worktree path)
986
+ * C. Session isolation – worktree sessions are never reused across PRs
987
+ *
988
+ * Implementation note: these tests use a fetch interceptor (options.fetch)
989
+ * to capture which URL parameters were used for each API call. This is more
990
+ * reliable than reading the directory inside the mock server's request
991
+ * handler, because the AbortController in createSessionViaApi aborts the
992
+ * connection after receiving response headers — which can race with the mock
993
+ * server's body-buffering. The interceptor captures the URL synchronously
994
+ * before the request is sent, avoiding the race entirely.
995
+ */
996
+ describe("session creation invariants", () => {
997
+ let mockServer;
998
+
999
+ afterEach(async () => {
1000
+ if (mockServer) {
1001
+ await mockServer.close();
1002
+ mockServer = null;
1003
+ }
1004
+ });
1005
+
1006
+ /**
1007
+ * Build a fetch interceptor that records which directory was used for
1008
+ * POST /session (session creation) and POST /session/:id/message or
1009
+ * POST /session/:id/command (message delivery), then forwards to the
1010
+ * real mock server.
1011
+ */
1012
+ function makeFetchInterceptor(calls) {
1013
+ return async (url, opts) => {
1014
+ const u = new URL(url);
1015
+ const method = opts?.method || "GET";
1016
+
1017
+ // Short-circuit message/command: return mock 200 directly.
1018
+ // The AbortController in sendMessageToSession/createSessionViaApi aborts
1019
+ // the connection after response headers arrive, which races with the mock
1020
+ // server's body-buffering (req.on("end") never fires). Returning a
1021
+ // synthetic Response here avoids the race entirely.
1022
+ if (method === "POST" && /^\/session\/[^/]+\/(message|command)$/.test(u.pathname)) {
1023
+ calls.messageDirectory = u.searchParams.get("directory");
1024
+ calls.messageSessionId = u.pathname.split("/")[2];
1025
+ return new Response(JSON.stringify({ success: true }), {
1026
+ status: 200,
1027
+ headers: { "Content-Type": "application/json" },
1028
+ });
1029
+ }
1030
+
1031
+ if (method === "POST" && u.pathname === "/session") {
1032
+ calls.sessionCreateDirectory = u.searchParams.get("directory");
1033
+ calls.sessionCreated = true;
1034
+ }
1035
+ if (method === "GET" && u.pathname === "/session") {
1036
+ calls.sessionListQueried = true;
1037
+ }
1038
+ return fetch(url, opts);
1039
+ };
1040
+ }
1041
+
1042
+ it("invariant A+B: session scoped to project, message sent to worktree", async () => {
1043
+ // This is THE test that would have caught every regression in v0.24.7–v0.24.10.
1044
+ // It asserts both invariants simultaneously:
1045
+ // A. POST /session directory = project path (not worktree) → correct projectID
1046
+ // B. POST /session/:id/message directory = worktree path → correct working dir
1047
+
1048
+ const calls = {};
1049
+
1050
+ mockServer = await createMockServer({
1051
+ "GET /project": () => ({
1052
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1053
+ }),
1054
+ "GET /experimental/worktree": () => ({ body: [] }),
1055
+ "POST /experimental/worktree": (req) => ({
1056
+ body: { name: req.body?.name, directory: `/worktree/${req.body?.name}` },
1057
+ }),
1058
+ "GET /session": () => ({ body: [] }),
1059
+ "GET /session/status": () => ({ body: {} }),
1060
+ "POST /session": () => ({ body: { id: "ses_inv" } }),
1061
+ "PATCH /session/ses_inv": () => ({ body: {} }),
1062
+ });
1063
+
1064
+ const result = await executeAction(
1065
+ { number: 99, title: "Invariant test PR" },
1066
+ { path: "/proj", prompt: "review", worktree_name: "pr-{number}" },
1067
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1068
+ );
1069
+
1070
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1071
+
1072
+ // Invariant A: session creation uses the project directory
1073
+ assert.strictEqual(calls.sessionCreateDirectory, "/proj",
1074
+ "INVARIANT A VIOLATED: POST /session must use projectDirectory for correct projectID");
1075
+
1076
+ // Invariant B: message uses the worktree directory
1077
+ assert.strictEqual(calls.messageDirectory, "/worktree/pr-99",
1078
+ "INVARIANT B VIOLATED: POST /message must use workingDirectory for correct file operations");
1079
+
1080
+ // Sanity: the two directories must be different in the worktree case
1081
+ assert.notStrictEqual(calls.sessionCreateDirectory, calls.messageDirectory,
1082
+ "In worktree mode, session creation dir and message dir must differ");
1083
+ });
1084
+
1085
+ it("invariant A+B hold when reprocessing with existing_directory", async () => {
1086
+ // When reprocessing an item (e.g., new feedback on a PR), the existing worktree
1087
+ // directory is passed. The same invariants must still hold.
1088
+
1089
+ const calls = {};
1090
+
1091
+ mockServer = await createMockServer({
1092
+ "GET /project": () => ({
1093
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1094
+ }),
1095
+ "GET /session": () => ({ body: [] }),
1096
+ "GET /session/status": () => ({ body: {} }),
1097
+ "POST /session": () => ({ body: { id: "ses_reprocess_inv" } }),
1098
+ "PATCH /session/ses_reprocess_inv": () => ({ body: {} }),
1099
+ });
1100
+
1101
+ const result = await executeAction(
1102
+ { number: 42, title: "Reprocessed PR" },
1103
+ {
1104
+ path: "/proj",
1105
+ prompt: "review",
1106
+ worktree_name: "pr-{number}",
1107
+ existing_directory: "/worktree/calm-wizard",
1108
+ },
1109
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1110
+ );
1111
+
1112
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1113
+
1114
+ // Invariant A: session creation uses the project directory, not the worktree
1115
+ assert.strictEqual(calls.sessionCreateDirectory, "/proj",
1116
+ "INVARIANT A VIOLATED: reprocessing must still use projectDirectory for POST /session");
1117
+
1118
+ // Invariant B: message uses the existing worktree directory
1119
+ assert.strictEqual(calls.messageDirectory, "/worktree/calm-wizard",
1120
+ "INVARIANT B VIOLATED: reprocessing must send messages to the existing worktree path");
1121
+ });
1122
+
1123
+ it("invariant C: second PR in same project gets its own session", async () => {
1124
+ // Two PRs in the same project (/proj). PR #1 already has an active session.
1125
+ // PR #2 works in a different worktree. Session reuse must NOT return PR #1's
1126
+ // session — each PR must get its own session.
1127
+
1128
+ const calls = { sessionListQueried: false, sessionCreated: false };
1129
+
1130
+ mockServer = await createMockServer({
1131
+ "GET /project": () => ({
1132
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1133
+ }),
1134
+ "GET /experimental/worktree": () => ({ body: ["/worktree/pr-200"] }),
1135
+ "GET /session": () => ({
1136
+ // Return PR #1's session — this should NOT be reused for PR #2
1137
+ body: [{ id: "ses_pr1", time: { created: 1, updated: 2 } }],
1138
+ }),
1139
+ "GET /session/status": () => ({ body: { ses_pr1: { type: "idle" } } }),
1140
+ "POST /session": () => ({ body: { id: "ses_pr2" } }),
1141
+ "PATCH /session/ses_pr2": () => ({ body: {} }),
1142
+ });
1143
+
1144
+ const result = await executeAction(
1145
+ { number: 200, title: "PR #200" },
1146
+ {
1147
+ path: "/proj",
1148
+ prompt: "review",
1149
+ worktree_name: "pr-{number}",
1150
+ existing_directory: "/worktree/pr-200",
1151
+ },
1152
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1153
+ );
1154
+
1155
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1156
+
1157
+ // Invariant C: session reuse is skipped entirely when in a worktree
1158
+ assert.strictEqual(calls.sessionListQueried, false,
1159
+ "INVARIANT C VIOLATED: worktree sessions must not query for reusable sessions");
1160
+
1161
+ // A new session must be created for this PR
1162
+ assert.ok(calls.sessionCreated,
1163
+ "INVARIANT C VIOLATED: each worktree PR must get its own new session");
1164
+
1165
+ // The returned session must be the new one, not PR #1's
1166
+ assert.strictEqual(result.sessionId, "ses_pr2",
1167
+ "INVARIANT C VIOLATED: result must reference the newly created session, not PR #1's");
1168
+ });
1169
+
1170
+ it("invariant C exception: non-worktree sessions ARE reused", async () => {
1171
+ // Session reuse should still work for items NOT running in a worktree.
1172
+ // A non-worktree item (path == cwd) should find and reuse an existing session.
1173
+
1174
+ const calls = { sessionListQueried: false, sessionCreated: false };
1175
+
1176
+ mockServer = await createMockServer({
1177
+ "GET /project": () => ({
1178
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1179
+ }),
1180
+ "GET /session": () => ({
1181
+ body: [{ id: "ses_existing", time: { created: 1, updated: 2 } }],
1182
+ }),
1183
+ "GET /session/status": () => ({ body: { ses_existing: { type: "idle" } } }),
1184
+ "POST /session": () => ({ body: { id: "ses_should_not_create" } }),
1185
+ "PATCH /session/ses_existing": () => ({ body: {} }),
1186
+ });
1187
+
1188
+ const result = await executeAction(
1189
+ { number: 1, title: "Non-worktree item" },
1190
+ {
1191
+ path: "/proj",
1192
+ prompt: "review",
1193
+ // No worktree_name, no existing_directory → non-worktree mode
1194
+ },
1195
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1196
+ );
1197
+
1198
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1199
+
1200
+ // Non-worktree: session reuse SHOULD work
1201
+ assert.ok(calls.sessionListQueried,
1202
+ "Non-worktree items should query for reusable sessions");
1203
+ assert.strictEqual(calls.sessionCreated, false,
1204
+ "Should reuse existing session, not create a new one");
1205
+ assert.strictEqual(result.sessionId, "ses_existing",
1206
+ "Should return the reused session ID");
1207
+ assert.ok(result.sessionReused,
1208
+ "Should flag the session as reused");
1209
+ });
1210
+
1211
+ it("invariants A+B hold for non-worktree sessions (degenerate case)", async () => {
1212
+ // When there is no worktree, both directories are the same.
1213
+ // POST /session and POST /message should both use the project directory.
1214
+
1215
+ const calls = {};
1216
+
1217
+ mockServer = await createMockServer({
1218
+ "GET /project": () => ({
1219
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1220
+ }),
1221
+ "GET /session": () => ({ body: [] }),
1222
+ "GET /session/status": () => ({ body: {} }),
1223
+ "POST /session": () => ({ body: { id: "ses_nwt" } }),
1224
+ "PATCH /session/ses_nwt": () => ({ body: {} }),
1225
+ });
1226
+
1227
+ const result = await executeAction(
1228
+ { number: 1, title: "No worktree" },
1229
+ { path: "/proj", prompt: "review" },
1230
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1231
+ );
1232
+
1233
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1234
+
1235
+ // Both should be the project directory when no worktree is involved
1236
+ assert.strictEqual(calls.sessionCreateDirectory, "/proj",
1237
+ "Non-worktree: session creation should use project directory");
1238
+ assert.strictEqual(calls.messageDirectory, "/proj",
1239
+ "Non-worktree: message should use project directory");
1240
+ });
1241
+ });
@@ -7,6 +7,7 @@ import assert from 'node:assert';
7
7
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { tmpdir, homedir } from 'os';
10
+ import { SessionContext } from '../../service/session-context.js';
10
11
 
11
12
  describe('actions.js', () => {
12
13
  let tempDir;
@@ -1026,7 +1027,7 @@ Check for bugs and security issues.`;
1026
1027
 
1027
1028
  const result = await createSessionViaApi(
1028
1029
  'http://localhost:4096',
1029
- '/path/to/project',
1030
+ SessionContext.forProject('/path/to/project'),
1030
1031
  'Fix the bug',
1031
1032
  { fetch: mockFetch }
1032
1033
  );
@@ -1067,12 +1068,12 @@ Check for bugs and security issues.`;
1067
1068
 
1068
1069
  await createSessionViaApi(
1069
1070
  'http://localhost:4096',
1070
- '/home/user/.local/share/opencode/worktree/abc123/pr-415',
1071
+ SessionContext.forWorktree(
1072
+ '/home/user/code/odin',
1073
+ '/home/user/.local/share/opencode/worktree/abc123/pr-415'
1074
+ ),
1071
1075
  'Fix the bug',
1072
- {
1073
- fetch: mockFetch,
1074
- projectDirectory: '/home/user/code/odin',
1075
- }
1076
+ { fetch: mockFetch }
1076
1077
  );
1077
1078
 
1078
1079
  // Session creation should use the project directory (for correct projectID scoping)
@@ -1099,7 +1100,7 @@ Check for bugs and security issues.`;
1099
1100
 
1100
1101
  const result = await createSessionViaApi(
1101
1102
  'http://localhost:4096',
1102
- '/path/to/project',
1103
+ SessionContext.forProject('/path/to/project'),
1103
1104
  'Fix the bug',
1104
1105
  { fetch: mockFetch }
1105
1106
  );
@@ -1141,7 +1142,7 @@ Check for bugs and security issues.`;
1141
1142
 
1142
1143
  await createSessionViaApi(
1143
1144
  'http://localhost:4096',
1144
- '/path/to/project',
1145
+ SessionContext.forProject('/path/to/project'),
1145
1146
  'Fix the bug',
1146
1147
  {
1147
1148
  fetch: mockFetch,
@@ -1186,7 +1187,7 @@ Check for bugs and security issues.`;
1186
1187
 
1187
1188
  const result = await createSessionViaApi(
1188
1189
  'http://localhost:4096',
1189
- '/path/to/project',
1190
+ SessionContext.forProject('/path/to/project'),
1190
1191
  'Fix the bug',
1191
1192
  { fetch: mockFetch }
1192
1193
  );
@@ -1245,7 +1246,7 @@ Check for bugs and security issues.`;
1245
1246
 
1246
1247
  const result = await createSessionViaApi(
1247
1248
  'http://localhost:4096',
1248
- '/path/to/project',
1249
+ SessionContext.forProject('/path/to/project'),
1249
1250
  '/review https://github.com/org/repo/pull/123',
1250
1251
  { fetch: mockFetch }
1251
1252
  );
@@ -1298,7 +1299,7 @@ Check for bugs and security issues.`;
1298
1299
 
1299
1300
  const result = await createSessionViaApi(
1300
1301
  'http://localhost:4096',
1301
- '/path/to/project',
1302
+ SessionContext.forProject('/path/to/project'),
1302
1303
  'Fix the bug in the login page',
1303
1304
  { fetch: mockFetch }
1304
1305
  );
@@ -1349,7 +1350,7 @@ Check for bugs and security issues.`;
1349
1350
 
1350
1351
  const result = await createSessionViaApi(
1351
1352
  'http://localhost:4096',
1352
- '/path/to/project',
1353
+ SessionContext.forProject('/path/to/project'),
1353
1354
  'Fix the bug',
1354
1355
  { fetch: mockFetch, title: 'Test Session', headerTimeout: 100 }
1355
1356
  );
@@ -1396,7 +1397,7 @@ Check for bugs and security issues.`;
1396
1397
 
1397
1398
  const result = await createSessionViaApi(
1398
1399
  'http://localhost:4096',
1399
- '/path/to/project',
1400
+ SessionContext.forProject('/path/to/project'),
1400
1401
  'Fix the bug',
1401
1402
  { fetch: mockFetch }
1402
1403
  );