opencode-pilot 0.24.10 → 0.24.12

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.9.tar.gz"
5
- sha256 "b477a5677fb80456db3f4f6464dd2071d0efb56bdd29b8a8fc01e5d56638f588"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.11.tar.gz"
5
+ sha256 "c0c9e3cdb3b34275d7a1d63b128430dd7e6fdd00dc8fe9e3baa5f3124b955422"
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.10",
3
+ "version": "0.24.12",
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 directory handling
8
+ *
9
+ * POST /session?directory=X sets both session.directory (working dir) and
10
+ * session.projectID (derived from the git root of X). Sandbox directories
11
+ * are git worktrees of the parent repo — they share the same root commit,
12
+ * so OpenCode resolves the correct projectID automatically.
13
+ *
14
+ * This means we always POST with the workingDirectory. No PATCH-based
15
+ * "project re-scoping" is needed. See test/integration/real-server.test.js
16
+ * for verification against a real OpenCode server.
17
+ *
18
+ * Session isolation: worktree sessions skip findReusableSession entirely
19
+ * to prevent cross-PR contamination (each PR gets its own session).
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,30 +687,43 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
672
687
 
673
688
  /**
674
689
  * Create a session via the OpenCode HTTP API
675
- *
690
+ *
691
+ * Creates the session with sessionCtx.workingDirectory. OpenCode resolves
692
+ * the correct projectID from the git root of that directory — sandbox dirs
693
+ * (git worktrees) resolve to the same project as the parent repo, so no
694
+ * separate "project scoping" step is needed.
695
+ *
696
+ * Verified against real OpenCode server in test/integration/real-server.test.js.
697
+ *
676
698
  * @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
677
- * @param {string} directory - Working directory for file operations (may be a worktree)
699
+ * @param {SessionContext} sessionCtx - Carries both directories (see session-context.js)
678
700
  * @param {string} prompt - The prompt/message to send
679
701
  * @param {object} [options] - Options
680
- * @param {string} [options.projectDirectory] - Project directory for session scoping (defaults to directory)
681
702
  * @param {string} [options.title] - Session title
682
703
  * @param {string} [options.agent] - Agent to use
683
704
  * @param {string} [options.model] - Model to use
684
705
  * @param {function} [options.fetch] - Custom fetch function (for testing)
685
706
  * @returns {Promise<object>} Result with sessionId, success, error
686
707
  */
687
- export async function createSessionViaApi(serverUrl, directory, prompt, options = {}) {
708
+ export async function createSessionViaApi(serverUrl, sessionCtx, prompt, options = {}) {
688
709
  const fetchFn = options.fetch || fetch;
689
710
  const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
690
- const projectDir = options.projectDirectory || directory;
711
+ // POST /session?directory=X sets both session.directory and projectID.
712
+ // OpenCode resolves projectID from the git root of X — sandbox directories
713
+ // (git worktrees) share the same root commit as the parent repo, so they
714
+ // get the correct projectID automatically. No PATCH re-scoping needed.
715
+ //
716
+ // PATCH /session/:id only updates title/archived — the ?directory param
717
+ // is a routing parameter (determines which project to look in), NOT a
718
+ // mutation of session.directory.
719
+ const directory = sessionCtx.workingDirectory;
691
720
 
692
721
  let session = null;
693
722
 
694
723
  try {
695
- // Step 1: Create session with the working directory (may be a worktree).
696
- // This sets the session's working directory so the agent operates in the
697
- // correct location. For worktree sessions, the server may assign
698
- // projectID 'global' since sandbox paths don't match project worktrees.
724
+ // Step 1: Create session with the working directory.
725
+ // This is what determines where the agent actually operates (file reads,
726
+ // writes, tool execution). For worktree sessions this is the sandbox path.
699
727
  const sessionUrl = new URL('/session', serverUrl);
700
728
  sessionUrl.searchParams.set('directory', directory);
701
729
 
@@ -713,20 +741,16 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
713
741
  session = await createResponse.json();
714
742
  debug(`createSessionViaApi: created session ${session.id} in ${directory}`);
715
743
 
716
- // Step 2: Update session - always PATCH with project directory when it
717
- // differs from the working directory (worktree case). This re-associates
718
- // the session with the correct project so it appears in the UI.
719
- // Also set the title if provided.
720
- const needsProjectScoping = projectDir !== directory;
721
- if (options.title || needsProjectScoping) {
744
+ // Step 2: Set session title if provided.
745
+ // PATCH ?directory must match the session's directory so the server can
746
+ // find it (it's a routing param, not a mutation).
747
+ if (options.title) {
722
748
  const updateUrl = new URL(`/session/${session.id}`, serverUrl);
723
- updateUrl.searchParams.set('directory', projectDir);
724
- const patchBody = {};
725
- if (options.title) patchBody.title = options.title;
749
+ updateUrl.searchParams.set('directory', directory);
726
750
  await fetchFn(updateUrl.toString(), {
727
751
  method: 'PATCH',
728
752
  headers: { 'Content-Type': 'application/json' },
729
- body: JSON.stringify(patchBody),
753
+ body: JSON.stringify({ title: options.title }),
730
754
  });
731
755
  }
732
756
 
@@ -839,17 +863,25 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
839
863
  }
840
864
 
841
865
  /**
842
- * Execute session creation/reuse in a specific directory
843
- * Internal helper for executeAction - handles prompt building, session reuse, and API calls
844
- *
866
+ * Execute session creation/reuse for the given session context.
867
+ * Internal helper for executeAction - handles prompt building, session reuse, and API calls.
868
+ *
869
+ * Enforces invariant C (session isolation): session reuse is skipped entirely
870
+ * when sessionCtx.isWorktree is true. Each PR/issue running in a worktree must
871
+ * get its own session because:
872
+ * - Querying by workingDirectory finds old sessions with projectID='global'
873
+ * - Querying by projectDirectory finds sessions for unrelated PRs in the same project
874
+ *
845
875
  * @param {string} serverUrl - OpenCode server URL
846
- * @param {string} cwd - Working directory for the session
876
+ * @param {SessionContext} sessionCtx - Carries both directories
847
877
  * @param {object} item - Item to create session for
848
878
  * @param {object} config - Repo config with action settings
849
879
  * @param {object} [options] - Execution options
850
880
  * @returns {Promise<object>} Result with command, success, sessionId, etc.
851
881
  */
852
- async function executeInDirectory(serverUrl, cwd, item, config, options = {}, projectDirectory = null) {
882
+ async function executeInDirectory(serverUrl, sessionCtx, item, config, options = {}) {
883
+ const cwd = sessionCtx.workingDirectory;
884
+
853
885
  // Build prompt from template
854
886
  const prompt = buildPromptFromTemplate(config.prompt || "default", item);
855
887
 
@@ -889,15 +921,12 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
889
921
  }
890
922
  }
891
923
 
892
- // Check if we should try to reuse an existing session.
893
- // Skip reuse when working in a worktree (projectDirectory differs from cwd),
894
- // because querying by worktree dir finds old sessions with projectID "global",
895
- // and querying by project dir finds unrelated sessions for other PRs.
896
- // Each worktree should get its own correctly-scoped session.
924
+ // Invariant C: skip findReusableSession when in a worktree.
925
+ // Each worktree (= each PR/issue) must get its own session to prevent
926
+ // cross-PR contamination. See session-context.js for full rationale.
897
927
  const reuseActiveSession = config.reuse_active_session !== false; // default true
898
- const inWorktree = projectDirectory && projectDirectory !== cwd;
899
928
 
900
- if (reuseActiveSession && !inWorktree && !options.dryRun) {
929
+ if (reuseActiveSession && !sessionCtx.isWorktree && !options.dryRun) {
901
930
  const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
902
931
 
903
932
  if (existingSession) {
@@ -934,8 +963,7 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
934
963
  };
935
964
  }
936
965
 
937
- const result = await createSessionViaApi(serverUrl, cwd, prompt, {
938
- projectDirectory: projectDirectory || cwd,
966
+ const result = await createSessionViaApi(serverUrl, sessionCtx, prompt, {
939
967
  title: sessionTitle,
940
968
  agent: config.agent,
941
969
  model: config.model,
@@ -994,27 +1022,29 @@ export async function executeAction(item, config, options = {}) {
994
1022
  };
995
1023
  }
996
1024
 
997
- // If existing_directory is provided (reprocessing same item), use it directly
998
- // This preserves the worktree from the previous run even if its name doesn't match the template
1025
+ // If existing_directory is provided (reprocessing same item), use it directly.
1026
+ // This preserves the worktree from the previous run even if its name doesn't match the template.
1027
+ // Build a worktree SessionContext since existing_directory is a worktree path.
999
1028
  if (config.existing_directory) {
1000
1029
  debug(`executeAction: using existing_directory=${config.existing_directory}`);
1001
1030
  const cwd = expandPath(config.existing_directory);
1002
- return await executeInDirectory(serverUrl, cwd, item, config, options, baseCwd);
1031
+ const sessionCtx = SessionContext.forWorktree(baseCwd, cwd);
1032
+ return await executeInDirectory(serverUrl, sessionCtx, item, config, options);
1003
1033
  }
1004
1034
 
1005
- // Resolve worktree directory if configured
1006
- // This allows creating sessions in isolated worktrees instead of the main project
1035
+ // Resolve worktree directory if configured.
1036
+ // This allows creating sessions in isolated worktrees instead of the main project.
1007
1037
  let worktreeMode = config.worktree;
1008
1038
 
1009
- // If worktree_name is configured, enable worktree mode (explicit configuration)
1010
- // This allows presets to specify worktree isolation without requiring existing sandboxes
1039
+ // If worktree_name is configured, enable worktree mode (explicit configuration).
1040
+ // This allows presets to specify worktree isolation without requiring existing sandboxes.
1011
1041
  if (!worktreeMode && config.worktree_name) {
1012
1042
  debug(`executeAction: worktree_name configured, enabling worktree mode`);
1013
1043
  worktreeMode = 'new';
1014
1044
  }
1015
1045
 
1016
- // Auto-detect worktree support: check if the project has sandboxes
1017
- // This is a fallback for when worktree isn't explicitly configured
1046
+ // Auto-detect worktree support: check if the project has sandboxes.
1047
+ // This is a fallback for when worktree isn't explicitly configured.
1018
1048
  if (!worktreeMode) {
1019
1049
  // Look up project info for this specific directory (not just /project/current)
1020
1050
  const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
@@ -1051,5 +1081,8 @@ export async function executeAction(item, config, options = {}) {
1051
1081
 
1052
1082
  debug(`executeAction: using cwd=${cwd}`);
1053
1083
 
1054
- return await executeInDirectory(serverUrl, cwd, item, config, options, baseCwd);
1084
+ // Build a SessionContext so downstream functions always have both directories.
1085
+ // forWorktree correctly sets isWorktree=true when cwd differs from baseCwd.
1086
+ const sessionCtx = SessionContext.forWorktree(baseCwd, cwd);
1087
+ return await executeInDirectory(serverUrl, sessionCtx, item, config, options);
1055
1088
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * session-context.js - SessionContext value object
3
+ *
4
+ * Tracks both the project directory (main git repo) and the working directory
5
+ * (which may be a sandbox/worktree) for session creation.
6
+ *
7
+ * ## How OpenCode's API actually works
8
+ *
9
+ * Verified against a real OpenCode server (see test/integration/real-server.test.js):
10
+ *
11
+ * - POST /session?directory=X sets `session.directory = X` and derives
12
+ * `session.projectID` from the git root of X. Sandbox directories are
13
+ * git worktrees that share the same root commit as the parent repo, so
14
+ * they get the correct projectID automatically. There is NO need to
15
+ * create with the project directory for "project scoping".
16
+ *
17
+ * - PATCH /session/:id only updates title/archived. The ?directory param
18
+ * is a routing parameter (determines which project to look in), NOT a
19
+ * mutation of session.directory.
20
+ *
21
+ * - GET /session?directory=X uses ?directory for both project routing
22
+ * (middleware) and as an exact filter on session.directory (route handler).
23
+ * This means sessions created with a sandbox dir are only visible when
24
+ * querying with that sandbox dir — natural isolation per worktree.
25
+ *
26
+ * ## Why SessionContext still carries both directories
27
+ *
28
+ * Even though createSessionViaApi only needs workingDirectory, the project
29
+ * directory is still needed for:
30
+ * - Worktree detection (isWorktree) — to skip session reuse for sandbox
31
+ * sessions and prevent cross-PR contamination
32
+ * - resolveWorktreeDirectory — needs the base project dir to create/list
33
+ * worktrees via GET/POST /experimental/worktree?directory=<projectDir>
34
+ *
35
+ * ## Worktree Detection
36
+ *
37
+ * A session is "in a worktree" when `workingDirectory !== projectDirectory`.
38
+ * Non-worktree sessions have both set to the same value.
39
+ */
40
+
41
+ export class SessionContext {
42
+ /**
43
+ * @param {string} projectDirectory - Base git repo path. Used for:
44
+ * - Worktree detection (isWorktree check for session isolation)
45
+ * - Worktree API calls (GET/POST /experimental/worktree?directory=...)
46
+ *
47
+ * @param {string} workingDirectory - Directory where the agent does work. Used for:
48
+ * - POST /session?directory=... (sets session.directory AND projectID)
49
+ * - POST /session/:id/message?directory=... (file operations)
50
+ * - PATCH /session/:id?directory=... (routing for title updates)
51
+ * Equals projectDirectory when not using worktrees.
52
+ */
53
+ constructor(projectDirectory, workingDirectory) {
54
+ if (!projectDirectory) throw new Error('SessionContext: projectDirectory is required');
55
+ if (!workingDirectory) throw new Error('SessionContext: workingDirectory is required');
56
+ this.projectDirectory = projectDirectory;
57
+ this.workingDirectory = workingDirectory;
58
+ Object.freeze(this);
59
+ }
60
+
61
+ /**
62
+ * True when the session runs in a worktree separate from the main repo.
63
+ * Worktree sessions skip findReusableSession to prevent cross-PR
64
+ * contamination — each PR/issue in its own sandbox gets its own session.
65
+ */
66
+ get isWorktree() {
67
+ return this.projectDirectory !== this.workingDirectory;
68
+ }
69
+
70
+ /**
71
+ * Factory: use this when there is no worktree (single-directory case).
72
+ */
73
+ static forProject(directory) {
74
+ return new SessionContext(directory, directory);
75
+ }
76
+
77
+ /**
78
+ * Factory: use this when a worktree has been resolved.
79
+ */
80
+ static forWorktree(projectDirectory, worktreeDirectory) {
81
+ return new SessionContext(projectDirectory, worktreeDirectory);
82
+ }
83
+ }
@@ -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
+ });