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.
- package/Formula/opencode-pilot.rb +2 -2
- package/package.json +1 -1
- package/service/actions.js +76 -43
- package/service/session-context.js +83 -0
- package/test/integration/poll-once.test.js +308 -0
- package/test/integration/real-server.test.js +274 -0
- package/test/integration/session-reuse.test.js +280 -15
- package/test/unit/actions.test.js +27 -27
|
@@ -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.
|
|
5
|
-
sha256 "
|
|
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
package/service/actions.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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
|
|
696
|
-
// This
|
|
697
|
-
//
|
|
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:
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
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',
|
|
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(
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
-
//
|
|
893
|
-
//
|
|
894
|
-
//
|
|
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 && !
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|