opencode-pilot 0.20.4 → 0.20.5
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/package.json +1 -1
- package/service/actions.js +85 -59
- package/service/poll-service.js +12 -0
- package/service/poller.js +9 -0
- package/test/integration/session-reuse.test.js +53 -0
- package/test/unit/actions.test.js +69 -0
- package/test/unit/poller.test.js +23 -0
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -799,6 +799,82 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
799
799
|
}
|
|
800
800
|
}
|
|
801
801
|
|
|
802
|
+
/**
|
|
803
|
+
* Execute session creation/reuse in a specific directory
|
|
804
|
+
* Internal helper for executeAction - handles prompt building, session reuse, and API calls
|
|
805
|
+
*
|
|
806
|
+
* @param {string} serverUrl - OpenCode server URL
|
|
807
|
+
* @param {string} cwd - Working directory for the session
|
|
808
|
+
* @param {object} item - Item to create session for
|
|
809
|
+
* @param {object} config - Repo config with action settings
|
|
810
|
+
* @param {object} [options] - Execution options
|
|
811
|
+
* @returns {Promise<object>} Result with command, success, sessionId, etc.
|
|
812
|
+
*/
|
|
813
|
+
async function executeInDirectory(serverUrl, cwd, item, config, options = {}) {
|
|
814
|
+
// Build prompt from template
|
|
815
|
+
const prompt = buildPromptFromTemplate(config.prompt || "default", item);
|
|
816
|
+
|
|
817
|
+
// Build session title
|
|
818
|
+
const sessionTitle = config.session?.name
|
|
819
|
+
? buildSessionName(config.session.name, item)
|
|
820
|
+
: (item.title || `session-${Date.now()}`);
|
|
821
|
+
|
|
822
|
+
// Check if we should try to reuse an existing session
|
|
823
|
+
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
824
|
+
|
|
825
|
+
if (reuseActiveSession && !options.dryRun) {
|
|
826
|
+
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
827
|
+
|
|
828
|
+
if (existingSession) {
|
|
829
|
+
debug(`executeInDirectory: found reusable session ${existingSession.id} for ${cwd}`);
|
|
830
|
+
|
|
831
|
+
const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
|
|
832
|
+
|
|
833
|
+
const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
|
|
834
|
+
title: sessionTitle,
|
|
835
|
+
agent: config.agent,
|
|
836
|
+
model: config.model,
|
|
837
|
+
fetch: options.fetch,
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
command: reuseCommand,
|
|
842
|
+
success: result.success,
|
|
843
|
+
sessionId: result.sessionId,
|
|
844
|
+
directory: cwd,
|
|
845
|
+
sessionReused: true,
|
|
846
|
+
error: result.error,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
852
|
+
debug(`executeInDirectory: using HTTP API - ${apiCommand}`);
|
|
853
|
+
|
|
854
|
+
if (options.dryRun) {
|
|
855
|
+
return {
|
|
856
|
+
command: apiCommand,
|
|
857
|
+
directory: cwd,
|
|
858
|
+
dryRun: true,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const result = await createSessionViaApi(serverUrl, cwd, prompt, {
|
|
863
|
+
title: sessionTitle,
|
|
864
|
+
agent: config.agent,
|
|
865
|
+
model: config.model,
|
|
866
|
+
fetch: options.fetch,
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
return {
|
|
870
|
+
command: apiCommand,
|
|
871
|
+
success: result.success,
|
|
872
|
+
sessionId: result.sessionId,
|
|
873
|
+
directory: cwd,
|
|
874
|
+
error: result.error,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
802
878
|
/**
|
|
803
879
|
* Execute an action
|
|
804
880
|
* @param {object} item - Item to create session for
|
|
@@ -841,6 +917,14 @@ export async function executeAction(item, config, options = {}) {
|
|
|
841
917
|
};
|
|
842
918
|
}
|
|
843
919
|
|
|
920
|
+
// If existing_directory is provided (reprocessing same item), use it directly
|
|
921
|
+
// This preserves the worktree from the previous run even if its name doesn't match the template
|
|
922
|
+
if (config.existing_directory) {
|
|
923
|
+
debug(`executeAction: using existing_directory=${config.existing_directory}`);
|
|
924
|
+
const cwd = expandPath(config.existing_directory);
|
|
925
|
+
return await executeInDirectory(serverUrl, cwd, item, config, options);
|
|
926
|
+
}
|
|
927
|
+
|
|
844
928
|
// Resolve worktree directory if configured
|
|
845
929
|
// This allows creating sessions in isolated worktrees instead of the main project
|
|
846
930
|
let worktreeMode = config.worktree;
|
|
@@ -890,63 +974,5 @@ export async function executeAction(item, config, options = {}) {
|
|
|
890
974
|
|
|
891
975
|
debug(`executeAction: using cwd=${cwd}`);
|
|
892
976
|
|
|
893
|
-
|
|
894
|
-
const prompt = buildPromptFromTemplate(config.prompt || "default", item);
|
|
895
|
-
|
|
896
|
-
// Build session title
|
|
897
|
-
const sessionTitle = config.session?.name
|
|
898
|
-
? buildSessionName(config.session.name, item)
|
|
899
|
-
: (item.title || `session-${Date.now()}`);
|
|
900
|
-
|
|
901
|
-
// Check if we should try to reuse an existing session
|
|
902
|
-
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
903
|
-
|
|
904
|
-
if (reuseActiveSession && !options.dryRun) {
|
|
905
|
-
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
906
|
-
|
|
907
|
-
if (existingSession) {
|
|
908
|
-
debug(`executeAction: found reusable session ${existingSession.id} for ${cwd}`);
|
|
909
|
-
|
|
910
|
-
const reuseCommand = `[API] POST ${serverUrl}/session/${existingSession.id}/message (reusing session)`;
|
|
911
|
-
|
|
912
|
-
const result = await sendMessageToSession(serverUrl, existingSession.id, cwd, prompt, {
|
|
913
|
-
title: sessionTitle,
|
|
914
|
-
agent: config.agent,
|
|
915
|
-
model: config.model,
|
|
916
|
-
fetch: options.fetch,
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
return {
|
|
920
|
-
command: reuseCommand,
|
|
921
|
-
success: result.success,
|
|
922
|
-
sessionId: result.sessionId,
|
|
923
|
-
sessionReused: true,
|
|
924
|
-
error: result.error,
|
|
925
|
-
};
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
const apiCommand = `[API] POST ${serverUrl}/session?directory=${cwd}`;
|
|
930
|
-
debug(`executeAction: using HTTP API - ${apiCommand}`);
|
|
931
|
-
|
|
932
|
-
if (options.dryRun) {
|
|
933
|
-
return {
|
|
934
|
-
command: apiCommand,
|
|
935
|
-
dryRun: true,
|
|
936
|
-
};
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
const result = await createSessionViaApi(serverUrl, cwd, prompt, {
|
|
940
|
-
title: sessionTitle,
|
|
941
|
-
agent: config.agent,
|
|
942
|
-
model: config.model,
|
|
943
|
-
fetch: options.fetch,
|
|
944
|
-
});
|
|
945
|
-
|
|
946
|
-
return {
|
|
947
|
-
command: apiCommand,
|
|
948
|
-
success: result.success,
|
|
949
|
-
sessionId: result.sessionId,
|
|
950
|
-
error: result.error,
|
|
951
|
-
};
|
|
977
|
+
return await executeInDirectory(serverUrl, cwd, item, config, options);
|
|
952
978
|
}
|
package/service/poll-service.js
CHANGED
|
@@ -200,10 +200,14 @@ export async function pollOnce(options = {}) {
|
|
|
200
200
|
debug(`Processing ${sortedItems.length} sorted items`);
|
|
201
201
|
for (const item of sortedItems) {
|
|
202
202
|
// Check if already processed
|
|
203
|
+
let existingDirectory = null;
|
|
203
204
|
if (pollerInstance && pollerInstance.isProcessed(item.id)) {
|
|
204
205
|
// Check if item should be reprocessed (reopened, status changed, etc.)
|
|
205
206
|
if (pollerInstance.shouldReprocess(item, { reprocessOn })) {
|
|
206
207
|
debug(`Reprocessing ${item.id} - state changed`);
|
|
208
|
+
// Get the stored directory before clearing state (for worktree reuse)
|
|
209
|
+
const prevMeta = pollerInstance.getProcessedMeta(item.id);
|
|
210
|
+
existingDirectory = prevMeta?.directory || null;
|
|
207
211
|
pollerInstance.clearProcessed(item.id);
|
|
208
212
|
console.log(`[poll] Reprocessing ${item.id} (reopened or updated)`);
|
|
209
213
|
} else {
|
|
@@ -215,6 +219,12 @@ export async function pollOnce(options = {}) {
|
|
|
215
219
|
debug(`Executing action for ${item.id}`);
|
|
216
220
|
// Build action config from source and item (resolves repo from item fields)
|
|
217
221
|
const actionConfig = buildActionConfigForItem(source, item);
|
|
222
|
+
|
|
223
|
+
// Pass existing directory for worktree reuse when reprocessing
|
|
224
|
+
if (existingDirectory) {
|
|
225
|
+
actionConfig.existing_directory = existingDirectory;
|
|
226
|
+
debug(`Reusing existing directory: ${existingDirectory}`);
|
|
227
|
+
}
|
|
218
228
|
|
|
219
229
|
// Skip items with no valid local path (prevents sessions in home directory)
|
|
220
230
|
const hasLocalPath = actionConfig.working_dir || actionConfig.path || actionConfig.repo_path;
|
|
@@ -244,11 +254,13 @@ export async function pollOnce(options = {}) {
|
|
|
244
254
|
if (result.success) {
|
|
245
255
|
// Mark as processed to avoid re-triggering
|
|
246
256
|
// Store item state for detecting reopened/updated items
|
|
257
|
+
// Store directory for worktree reuse when reprocessing
|
|
247
258
|
if (pollerInstance) {
|
|
248
259
|
pollerInstance.markProcessed(item.id, {
|
|
249
260
|
repoKey: item.repo_key,
|
|
250
261
|
command: result.command,
|
|
251
262
|
source: sourceName,
|
|
263
|
+
directory: result.directory || null,
|
|
252
264
|
itemState: item.state || item.status || null,
|
|
253
265
|
itemUpdatedAt: item.updated_at || null,
|
|
254
266
|
});
|
package/service/poller.js
CHANGED
|
@@ -722,6 +722,15 @@ export function createPoller(options = {}) {
|
|
|
722
722
|
return processedItems.has(itemId);
|
|
723
723
|
},
|
|
724
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Get metadata for a processed item
|
|
727
|
+
* @param {string} itemId - Item ID
|
|
728
|
+
* @returns {object|null} Metadata or null if not processed
|
|
729
|
+
*/
|
|
730
|
+
getProcessedMeta(itemId) {
|
|
731
|
+
return processedItems.get(itemId) || null;
|
|
732
|
+
},
|
|
733
|
+
|
|
725
734
|
/**
|
|
726
735
|
* Mark an item as processed
|
|
727
736
|
*/
|
|
@@ -566,4 +566,57 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
566
566
|
assert.ok(sessionCreated, "Should create session");
|
|
567
567
|
assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be in worktree directory");
|
|
568
568
|
});
|
|
569
|
+
|
|
570
|
+
it("reuses stored directory when reprocessing same item", async () => {
|
|
571
|
+
// This tests the scenario where:
|
|
572
|
+
// 1. Item was processed before, worktree created with random name (e.g., "calm-wizard")
|
|
573
|
+
// 2. Item triggers again (e.g., new feedback)
|
|
574
|
+
// 3. We should reuse the stored directory, not create a new worktree
|
|
575
|
+
|
|
576
|
+
let worktreeListCalled = false;
|
|
577
|
+
let worktreeCreateCalled = false;
|
|
578
|
+
let sessionDirectory = null;
|
|
579
|
+
|
|
580
|
+
// Existing worktree has a random name, not "pr-42"
|
|
581
|
+
const existingWorktreeDir = "/worktree/calm-wizard";
|
|
582
|
+
|
|
583
|
+
const mockServer = await createMockServer({
|
|
584
|
+
"GET /experimental/worktree": () => {
|
|
585
|
+
worktreeListCalled = true;
|
|
586
|
+
// Return existing worktree with random name
|
|
587
|
+
return { body: [existingWorktreeDir] };
|
|
588
|
+
},
|
|
589
|
+
"POST /experimental/worktree": () => {
|
|
590
|
+
worktreeCreateCalled = true;
|
|
591
|
+
return { body: { name: "pr-42", directory: "/worktree/pr-42" } };
|
|
592
|
+
},
|
|
593
|
+
"GET /session": () => ({ body: [] }),
|
|
594
|
+
"GET /session/status": () => ({ body: {} }),
|
|
595
|
+
"POST /session": (req) => {
|
|
596
|
+
sessionDirectory = req.query?.directory;
|
|
597
|
+
return { body: { id: "ses_reprocess" } };
|
|
598
|
+
},
|
|
599
|
+
"PATCH /session/ses_reprocess": () => ({ body: {} }),
|
|
600
|
+
"POST /session/ses_reprocess/message": () => ({ body: { success: true } }),
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// Simulate reprocessing with a stored directory from previous run
|
|
604
|
+
const result = await executeAction(
|
|
605
|
+
{ number: 42, title: "Review PR" },
|
|
606
|
+
{
|
|
607
|
+
path: "/proj",
|
|
608
|
+
prompt: "review",
|
|
609
|
+
worktree_name: "pr-{number}",
|
|
610
|
+
// This is the key: pass the directory we used last time
|
|
611
|
+
existing_directory: existingWorktreeDir,
|
|
612
|
+
},
|
|
613
|
+
{ discoverServer: async () => mockServer.url }
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
assert.ok(result.success, "Action should succeed");
|
|
617
|
+
// Should NOT create a new worktree since we have existing_directory
|
|
618
|
+
assert.strictEqual(worktreeCreateCalled, false, "Should NOT create new worktree when existing_directory provided");
|
|
619
|
+
// Session should be created in the existing directory
|
|
620
|
+
assert.strictEqual(sessionDirectory, existingWorktreeDir, "Session should use existing directory");
|
|
621
|
+
});
|
|
569
622
|
});
|
|
@@ -888,6 +888,75 @@ Check for bugs and security issues.`;
|
|
|
888
888
|
assert.ok(result.command.includes('/data/worktree/proj/pr-123'),
|
|
889
889
|
'Should use new worktree directory');
|
|
890
890
|
});
|
|
891
|
+
|
|
892
|
+
test('uses existing_directory without creating new worktree', async () => {
|
|
893
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
894
|
+
|
|
895
|
+
const item = { number: 123, title: 'Review PR #123' };
|
|
896
|
+
const config = {
|
|
897
|
+
path: '/data/proj',
|
|
898
|
+
prompt: 'review',
|
|
899
|
+
worktree_name: 'pr-{number}',
|
|
900
|
+
// This is the key - pass an existing directory from a previous run
|
|
901
|
+
existing_directory: '/data/worktree/calm-wizard',
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
905
|
+
|
|
906
|
+
let worktreeListCalled = false;
|
|
907
|
+
let worktreeCreateCalled = false;
|
|
908
|
+
let sessionDirectory = null;
|
|
909
|
+
|
|
910
|
+
const mockFetch = async (url, opts) => {
|
|
911
|
+
const urlObj = new URL(url);
|
|
912
|
+
|
|
913
|
+
// Track if worktree endpoints are called (they shouldn't be)
|
|
914
|
+
if (urlObj.pathname === '/experimental/worktree') {
|
|
915
|
+
if (opts?.method === 'POST') {
|
|
916
|
+
worktreeCreateCalled = true;
|
|
917
|
+
} else {
|
|
918
|
+
worktreeListCalled = true;
|
|
919
|
+
}
|
|
920
|
+
return { ok: true, json: async () => [] };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// No existing sessions
|
|
924
|
+
if (urlObj.pathname === '/session' && !opts?.method) {
|
|
925
|
+
return { ok: true, json: async () => [] };
|
|
926
|
+
}
|
|
927
|
+
if (urlObj.pathname === '/session/status') {
|
|
928
|
+
return { ok: true, json: async () => ({}) };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Session creation - capture the directory
|
|
932
|
+
if (urlObj.pathname === '/session' && opts?.method === 'POST') {
|
|
933
|
+
sessionDirectory = urlObj.searchParams.get('directory');
|
|
934
|
+
return { ok: true, json: async () => ({ id: 'ses_test' }) };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Other session endpoints
|
|
938
|
+
if (urlObj.pathname.includes('/session/')) {
|
|
939
|
+
return { ok: true, json: async () => ({}) };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return { ok: false, text: async () => 'Not found' };
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
const result = await executeAction(item, config, {
|
|
946
|
+
discoverServer: mockDiscoverServer,
|
|
947
|
+
fetch: mockFetch
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
assert.ok(result.success);
|
|
951
|
+
// Should NOT call worktree endpoints when existing_directory is provided
|
|
952
|
+
assert.strictEqual(worktreeListCalled, false, 'Should NOT list worktrees');
|
|
953
|
+
assert.strictEqual(worktreeCreateCalled, false, 'Should NOT create worktree');
|
|
954
|
+
// Should use the existing directory
|
|
955
|
+
assert.strictEqual(sessionDirectory, '/data/worktree/calm-wizard',
|
|
956
|
+
'Should use existing_directory for session');
|
|
957
|
+
assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
|
|
958
|
+
'Result should include directory');
|
|
959
|
+
});
|
|
891
960
|
});
|
|
892
961
|
|
|
893
962
|
describe('createSessionViaApi', () => {
|
package/test/unit/poller.test.js
CHANGED
|
@@ -136,6 +136,29 @@ describe('poller.js', () => {
|
|
|
136
136
|
assert.strictEqual(poller.getProcessedIds().length, 1);
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
test('getProcessedMeta returns stored metadata', async () => {
|
|
140
|
+
const { createPoller } = await import('../../service/poller.js');
|
|
141
|
+
|
|
142
|
+
const poller = createPoller({ stateFile });
|
|
143
|
+
|
|
144
|
+
// Not processed yet
|
|
145
|
+
assert.strictEqual(poller.getProcessedMeta('item-1'), null);
|
|
146
|
+
|
|
147
|
+
// Mark as processed with metadata including directory
|
|
148
|
+
poller.markProcessed('item-1', {
|
|
149
|
+
source: 'test',
|
|
150
|
+
directory: '/worktree/pr-123',
|
|
151
|
+
itemState: 'open',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const meta = poller.getProcessedMeta('item-1');
|
|
155
|
+
assert.ok(meta);
|
|
156
|
+
assert.strictEqual(meta.source, 'test');
|
|
157
|
+
assert.strictEqual(meta.directory, '/worktree/pr-123');
|
|
158
|
+
assert.strictEqual(meta.itemState, 'open');
|
|
159
|
+
assert.ok(meta.processedAt); // Should have timestamp
|
|
160
|
+
});
|
|
161
|
+
|
|
139
162
|
test('persists state across instances', async () => {
|
|
140
163
|
const { createPoller } = await import('../../service/poller.js');
|
|
141
164
|
|