opencode-pilot 0.24.10 → 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.
|
|
5
|
-
sha256 "
|
|
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
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 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,32 +687,37 @@ 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 {
|
|
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,
|
|
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
|
-
|
|
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;
|
|
691
714
|
|
|
692
715
|
let session = null;
|
|
693
716
|
|
|
694
717
|
try {
|
|
695
|
-
// Step 1: Create session
|
|
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.
|
|
718
|
+
// Step 1: Create session scoped to the project directory
|
|
699
719
|
const sessionUrl = new URL('/session', serverUrl);
|
|
700
|
-
sessionUrl.searchParams.set('directory',
|
|
720
|
+
sessionUrl.searchParams.set('directory', projectDir);
|
|
701
721
|
|
|
702
722
|
const createResponse = await fetchFn(sessionUrl.toString(), {
|
|
703
723
|
method: 'POST',
|
|
@@ -713,20 +733,14 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
713
733
|
session = await createResponse.json();
|
|
714
734
|
debug(`createSessionViaApi: created session ${session.id} in ${directory}`);
|
|
715
735
|
|
|
716
|
-
// Step 2: Update session
|
|
717
|
-
|
|
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) {
|
|
736
|
+
// Step 2: Update session title if provided
|
|
737
|
+
if (options.title) {
|
|
722
738
|
const updateUrl = new URL(`/session/${session.id}`, serverUrl);
|
|
723
739
|
updateUrl.searchParams.set('directory', projectDir);
|
|
724
|
-
const patchBody = {};
|
|
725
|
-
if (options.title) patchBody.title = options.title;
|
|
726
740
|
await fetchFn(updateUrl.toString(), {
|
|
727
741
|
method: 'PATCH',
|
|
728
742
|
headers: { 'Content-Type': 'application/json' },
|
|
729
|
-
body: JSON.stringify(
|
|
743
|
+
body: JSON.stringify({ title: options.title }),
|
|
730
744
|
});
|
|
731
745
|
}
|
|
732
746
|
|
|
@@ -839,17 +853,25 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
839
853
|
}
|
|
840
854
|
|
|
841
855
|
/**
|
|
842
|
-
* Execute session creation/reuse
|
|
843
|
-
* Internal helper for executeAction - handles prompt building, session reuse, and API calls
|
|
844
|
-
*
|
|
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
|
+
*
|
|
845
865
|
* @param {string} serverUrl - OpenCode server URL
|
|
846
|
-
* @param {
|
|
866
|
+
* @param {SessionContext} sessionCtx - Carries both directories
|
|
847
867
|
* @param {object} item - Item to create session for
|
|
848
868
|
* @param {object} config - Repo config with action settings
|
|
849
869
|
* @param {object} [options] - Execution options
|
|
850
870
|
* @returns {Promise<object>} Result with command, success, sessionId, etc.
|
|
851
871
|
*/
|
|
852
|
-
async function executeInDirectory(serverUrl,
|
|
872
|
+
async function executeInDirectory(serverUrl, sessionCtx, item, config, options = {}) {
|
|
873
|
+
const cwd = sessionCtx.workingDirectory;
|
|
874
|
+
|
|
853
875
|
// Build prompt from template
|
|
854
876
|
const prompt = buildPromptFromTemplate(config.prompt || "default", item);
|
|
855
877
|
|
|
@@ -889,15 +911,12 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
|
|
|
889
911
|
}
|
|
890
912
|
}
|
|
891
913
|
|
|
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.
|
|
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.
|
|
897
917
|
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
898
|
-
const inWorktree = projectDirectory && projectDirectory !== cwd;
|
|
899
918
|
|
|
900
|
-
if (reuseActiveSession && !
|
|
919
|
+
if (reuseActiveSession && !sessionCtx.isWorktree && !options.dryRun) {
|
|
901
920
|
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
902
921
|
|
|
903
922
|
if (existingSession) {
|
|
@@ -934,8 +953,7 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
|
|
|
934
953
|
};
|
|
935
954
|
}
|
|
936
955
|
|
|
937
|
-
const result = await createSessionViaApi(serverUrl,
|
|
938
|
-
projectDirectory: projectDirectory || cwd,
|
|
956
|
+
const result = await createSessionViaApi(serverUrl, sessionCtx, prompt, {
|
|
939
957
|
title: sessionTitle,
|
|
940
958
|
agent: config.agent,
|
|
941
959
|
model: config.model,
|
|
@@ -994,27 +1012,29 @@ export async function executeAction(item, config, options = {}) {
|
|
|
994
1012
|
};
|
|
995
1013
|
}
|
|
996
1014
|
|
|
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
|
|
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.
|
|
999
1018
|
if (config.existing_directory) {
|
|
1000
1019
|
debug(`executeAction: using existing_directory=${config.existing_directory}`);
|
|
1001
1020
|
const cwd = expandPath(config.existing_directory);
|
|
1002
|
-
|
|
1021
|
+
const sessionCtx = SessionContext.forWorktree(baseCwd, cwd);
|
|
1022
|
+
return await executeInDirectory(serverUrl, sessionCtx, item, config, options);
|
|
1003
1023
|
}
|
|
1004
1024
|
|
|
1005
|
-
// Resolve worktree directory if configured
|
|
1006
|
-
// 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.
|
|
1007
1027
|
let worktreeMode = config.worktree;
|
|
1008
1028
|
|
|
1009
|
-
// If worktree_name is configured, enable worktree mode (explicit configuration)
|
|
1010
|
-
// 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.
|
|
1011
1031
|
if (!worktreeMode && config.worktree_name) {
|
|
1012
1032
|
debug(`executeAction: worktree_name configured, enabling worktree mode`);
|
|
1013
1033
|
worktreeMode = 'new';
|
|
1014
1034
|
}
|
|
1015
1035
|
|
|
1016
|
-
// Auto-detect worktree support: check if the project has sandboxes
|
|
1017
|
-
// 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.
|
|
1018
1038
|
if (!worktreeMode) {
|
|
1019
1039
|
// Look up project info for this specific directory (not just /project/current)
|
|
1020
1040
|
const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
|
|
@@ -1051,5 +1071,8 @@ export async function executeAction(item, config, options = {}) {
|
|
|
1051
1071
|
|
|
1052
1072
|
debug(`executeAction: using cwd=${cwd}`);
|
|
1053
1073
|
|
|
1054
|
-
|
|
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);
|
|
1055
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
|
+
});
|
|
@@ -564,9 +564,9 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
564
564
|
assert.ok(worktreeCreateCalled, "Should create worktree when worktree_name is configured");
|
|
565
565
|
assert.strictEqual(createdWorktreeName, "pr-42", "Should expand worktree_name template");
|
|
566
566
|
assert.ok(sessionCreated, "Should create session");
|
|
567
|
-
// Session creation uses the
|
|
568
|
-
// The
|
|
569
|
-
assert.strictEqual(sessionDirectory, "/
|
|
567
|
+
// Session creation uses the project directory (for correct projectID scoping)
|
|
568
|
+
// The worktree path is used for messages/commands (file operations)
|
|
569
|
+
assert.strictEqual(sessionDirectory, "/proj", "Session should be scoped to project directory");
|
|
570
570
|
});
|
|
571
571
|
|
|
572
572
|
it("reuses stored directory when reprocessing same item", async () => {
|
|
@@ -618,8 +618,8 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
618
618
|
assert.ok(result.success, "Action should succeed");
|
|
619
619
|
// Should NOT create a new worktree since we have existing_directory
|
|
620
620
|
assert.strictEqual(worktreeCreateCalled, false, "Should NOT create new worktree when existing_directory provided");
|
|
621
|
-
// Session creation uses the
|
|
622
|
-
assert.strictEqual(sessionDirectory,
|
|
621
|
+
// Session creation uses the project directory (for correct projectID scoping)
|
|
622
|
+
assert.strictEqual(sessionDirectory, "/proj", "Session should be scoped to project directory");
|
|
623
623
|
});
|
|
624
624
|
|
|
625
625
|
it("skips session reuse when working in a worktree", async () => {
|
|
@@ -631,7 +631,6 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
631
631
|
let sessionListQueried = false;
|
|
632
632
|
let sessionCreated = false;
|
|
633
633
|
let sessionCreateDirectory = null;
|
|
634
|
-
let patchDirectory = null;
|
|
635
634
|
|
|
636
635
|
const existingWorktreeDir = "/worktree/calm-wizard";
|
|
637
636
|
|
|
@@ -655,10 +654,7 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
655
654
|
sessionCreateDirectory = req.query?.directory;
|
|
656
655
|
return { body: { id: "ses_new" } };
|
|
657
656
|
},
|
|
658
|
-
"PATCH /session/ses_new": (
|
|
659
|
-
patchDirectory = req.query?.directory;
|
|
660
|
-
return { body: {} };
|
|
661
|
-
},
|
|
657
|
+
"PATCH /session/ses_new": () => ({ body: {} }),
|
|
662
658
|
"POST /session/ses_new/message": () => ({ body: { success: true } }),
|
|
663
659
|
"POST /session/ses_new/command": () => ({ body: { success: true } }),
|
|
664
660
|
});
|
|
@@ -678,13 +674,10 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
678
674
|
// Should NOT query for existing sessions when in a worktree
|
|
679
675
|
assert.strictEqual(sessionListQueried, false,
|
|
680
676
|
"Should skip session reuse entirely when in a worktree");
|
|
681
|
-
// Should create a new session
|
|
677
|
+
// Should create a new session scoped to the project directory
|
|
682
678
|
assert.ok(sessionCreated, "Should create a new session");
|
|
683
|
-
assert.strictEqual(sessionCreateDirectory,
|
|
684
|
-
"
|
|
685
|
-
// PATCH re-associates the session with the correct project
|
|
686
|
-
assert.strictEqual(patchDirectory, "/proj",
|
|
687
|
-
"PATCH should use project directory for correct project scoping");
|
|
679
|
+
assert.strictEqual(sessionCreateDirectory, "/proj",
|
|
680
|
+
"New session should be scoped to project directory");
|
|
688
681
|
});
|
|
689
682
|
});
|
|
690
683
|
|
|
@@ -974,3 +967,275 @@ describe("integration: stacked PR session reuse", () => {
|
|
|
974
967
|
}
|
|
975
968
|
});
|
|
976
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;
|
|
@@ -981,9 +982,9 @@ Check for bugs and security issues.`;
|
|
|
981
982
|
// Should NOT call worktree endpoints when existing_directory is provided
|
|
982
983
|
assert.strictEqual(worktreeListCalled, false, 'Should NOT list worktrees');
|
|
983
984
|
assert.strictEqual(worktreeCreateCalled, false, 'Should NOT create worktree');
|
|
984
|
-
// Session creation uses
|
|
985
|
-
assert.strictEqual(sessionDirectory, '/data/
|
|
986
|
-
'Session creation should use
|
|
985
|
+
// Session creation uses project directory (for correct projectID scoping)
|
|
986
|
+
assert.strictEqual(sessionDirectory, '/data/proj',
|
|
987
|
+
'Session creation should use project directory, not worktree');
|
|
987
988
|
// Result directory is the worktree (where file operations happen)
|
|
988
989
|
assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
|
|
989
990
|
'Result should include worktree directory');
|
|
@@ -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
|
);
|
|
@@ -1042,12 +1043,11 @@ Check for bugs and security issues.`;
|
|
|
1042
1043
|
assert.ok(messageUrl.includes('%2Fpath%2Fto%2Fproject'), 'Message URL should include encoded directory path');
|
|
1043
1044
|
});
|
|
1044
1045
|
|
|
1045
|
-
test('
|
|
1046
|
+
test('uses projectDirectory for session creation, working directory for messages', async () => {
|
|
1046
1047
|
const { createSessionViaApi } = await import('../../service/actions.js');
|
|
1047
1048
|
|
|
1048
1049
|
const mockSessionId = 'ses_test_proj';
|
|
1049
1050
|
let createUrl = null;
|
|
1050
|
-
let patchUrl = null;
|
|
1051
1051
|
let messageUrl = null;
|
|
1052
1052
|
|
|
1053
1053
|
const mockFetch = async (url, opts) => {
|
|
@@ -1058,11 +1058,6 @@ Check for bugs and security issues.`;
|
|
|
1058
1058
|
return { ok: true, json: async () => ({ id: mockSessionId }) };
|
|
1059
1059
|
}
|
|
1060
1060
|
|
|
1061
|
-
if (urlObj.pathname.includes(`/session/${mockSessionId}`) && opts?.method === 'PATCH') {
|
|
1062
|
-
patchUrl = url;
|
|
1063
|
-
return { ok: true, json: async () => ({}) };
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
1061
|
if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
|
|
1067
1062
|
messageUrl = url;
|
|
1068
1063
|
return { ok: true, json: async () => ({ success: true }) };
|
|
@@ -1073,26 +1068,21 @@ Check for bugs and security issues.`;
|
|
|
1073
1068
|
|
|
1074
1069
|
await createSessionViaApi(
|
|
1075
1070
|
'http://localhost:4096',
|
|
1076
|
-
|
|
1071
|
+
SessionContext.forWorktree(
|
|
1072
|
+
'/home/user/code/odin',
|
|
1073
|
+
'/home/user/.local/share/opencode/worktree/abc123/pr-415'
|
|
1074
|
+
),
|
|
1077
1075
|
'Fix the bug',
|
|
1078
|
-
{
|
|
1079
|
-
fetch: mockFetch,
|
|
1080
|
-
projectDirectory: '/home/user/code/odin',
|
|
1081
|
-
}
|
|
1076
|
+
{ fetch: mockFetch }
|
|
1082
1077
|
);
|
|
1083
1078
|
|
|
1084
|
-
// Session creation should use the
|
|
1085
|
-
assert.ok(createUrl.includes('
|
|
1086
|
-
'Session creation should use
|
|
1087
|
-
assert.ok(createUrl.includes('
|
|
1088
|
-
'Session creation should use the worktree path');
|
|
1079
|
+
// Session creation should use the project directory (for correct projectID scoping)
|
|
1080
|
+
assert.ok(createUrl.includes('%2Fhome%2Fuser%2Fcode%2Fodin'),
|
|
1081
|
+
'Session creation should use projectDirectory');
|
|
1082
|
+
assert.ok(!createUrl.includes('worktree'),
|
|
1083
|
+
'Session creation should NOT use the worktree path');
|
|
1089
1084
|
|
|
1090
|
-
//
|
|
1091
|
-
assert.ok(patchUrl, 'PATCH should be called for project scoping');
|
|
1092
|
-
assert.ok(patchUrl.includes('%2Fhome%2Fuser%2Fcode%2Fodin'),
|
|
1093
|
-
'PATCH should use projectDirectory for project scoping');
|
|
1094
|
-
|
|
1095
|
-
// Message should use the working directory (worktree)
|
|
1085
|
+
// Message should use the working directory (for file operations in the worktree)
|
|
1096
1086
|
assert.ok(messageUrl.includes('worktree'),
|
|
1097
1087
|
'Message should use the worktree working directory');
|
|
1098
1088
|
assert.ok(messageUrl.includes('pr-415'),
|
|
@@ -1110,7 +1100,7 @@ Check for bugs and security issues.`;
|
|
|
1110
1100
|
|
|
1111
1101
|
const result = await createSessionViaApi(
|
|
1112
1102
|
'http://localhost:4096',
|
|
1113
|
-
'/path/to/project',
|
|
1103
|
+
SessionContext.forProject('/path/to/project'),
|
|
1114
1104
|
'Fix the bug',
|
|
1115
1105
|
{ fetch: mockFetch }
|
|
1116
1106
|
);
|
|
@@ -1152,7 +1142,7 @@ Check for bugs and security issues.`;
|
|
|
1152
1142
|
|
|
1153
1143
|
await createSessionViaApi(
|
|
1154
1144
|
'http://localhost:4096',
|
|
1155
|
-
'/path/to/project',
|
|
1145
|
+
SessionContext.forProject('/path/to/project'),
|
|
1156
1146
|
'Fix the bug',
|
|
1157
1147
|
{
|
|
1158
1148
|
fetch: mockFetch,
|
|
@@ -1197,7 +1187,7 @@ Check for bugs and security issues.`;
|
|
|
1197
1187
|
|
|
1198
1188
|
const result = await createSessionViaApi(
|
|
1199
1189
|
'http://localhost:4096',
|
|
1200
|
-
'/path/to/project',
|
|
1190
|
+
SessionContext.forProject('/path/to/project'),
|
|
1201
1191
|
'Fix the bug',
|
|
1202
1192
|
{ fetch: mockFetch }
|
|
1203
1193
|
);
|
|
@@ -1256,7 +1246,7 @@ Check for bugs and security issues.`;
|
|
|
1256
1246
|
|
|
1257
1247
|
const result = await createSessionViaApi(
|
|
1258
1248
|
'http://localhost:4096',
|
|
1259
|
-
'/path/to/project',
|
|
1249
|
+
SessionContext.forProject('/path/to/project'),
|
|
1260
1250
|
'/review https://github.com/org/repo/pull/123',
|
|
1261
1251
|
{ fetch: mockFetch }
|
|
1262
1252
|
);
|
|
@@ -1309,7 +1299,7 @@ Check for bugs and security issues.`;
|
|
|
1309
1299
|
|
|
1310
1300
|
const result = await createSessionViaApi(
|
|
1311
1301
|
'http://localhost:4096',
|
|
1312
|
-
'/path/to/project',
|
|
1302
|
+
SessionContext.forProject('/path/to/project'),
|
|
1313
1303
|
'Fix the bug in the login page',
|
|
1314
1304
|
{ fetch: mockFetch }
|
|
1315
1305
|
);
|
|
@@ -1360,7 +1350,7 @@ Check for bugs and security issues.`;
|
|
|
1360
1350
|
|
|
1361
1351
|
const result = await createSessionViaApi(
|
|
1362
1352
|
'http://localhost:4096',
|
|
1363
|
-
'/path/to/project',
|
|
1353
|
+
SessionContext.forProject('/path/to/project'),
|
|
1364
1354
|
'Fix the bug',
|
|
1365
1355
|
{ fetch: mockFetch, title: 'Test Session', headerTimeout: 100 }
|
|
1366
1356
|
);
|
|
@@ -1407,7 +1397,7 @@ Check for bugs and security issues.`;
|
|
|
1407
1397
|
|
|
1408
1398
|
const result = await createSessionViaApi(
|
|
1409
1399
|
'http://localhost:4096',
|
|
1410
|
-
'/path/to/project',
|
|
1400
|
+
SessionContext.forProject('/path/to/project'),
|
|
1411
1401
|
'Fix the bug',
|
|
1412
1402
|
{ fetch: mockFetch }
|
|
1413
1403
|
);
|