@geckom/opencode-queue 0.1.2
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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/block-watcher.js +137 -0
- package/dist/constants.js +25 -0
- package/dist/file-lock.js +106 -0
- package/dist/formatters.js +108 -0
- package/dist/idle-detector.js +49 -0
- package/dist/index.js +1 -0
- package/dist/opencode-queue.js +1 -0
- package/dist/plugin.js +370 -0
- package/dist/queue-manager.js +438 -0
- package/dist/queue-processor.js +273 -0
- package/dist/schedule-manager.js +126 -0
- package/dist/session-greeter.js +81 -0
- package/dist/shared-state.js +105 -0
- package/dist/toast.js +11 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +29 -0
- package/package.json +58 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { LAST_ACTIVITY_FILE, LOCK_FILE, PROCESSING_LOCK_REFRESH_MS, PROCESSING_LOCK_STALE_MS } from "./constants.js";
|
|
3
|
+
import { BlockWatcher } from "./block-watcher.js";
|
|
4
|
+
import { FileLock } from "./file-lock.js";
|
|
5
|
+
import { IdleDetector } from "./idle-detector.js";
|
|
6
|
+
import { QueueManager } from "./queue-manager.js";
|
|
7
|
+
import { sleep } from "./utils.js";
|
|
8
|
+
/**
|
|
9
|
+
* QueueProcessor is the state machine executor. It owns session creation,
|
|
10
|
+
* completion polling, and retry transitions for a single coordinator.
|
|
11
|
+
*/
|
|
12
|
+
export class QueueProcessor {
|
|
13
|
+
isProcessing = false;
|
|
14
|
+
blockWatcher;
|
|
15
|
+
queueManager;
|
|
16
|
+
client;
|
|
17
|
+
idleDetector;
|
|
18
|
+
serverUrl;
|
|
19
|
+
constructor(queueManager, client, idleDetector, serverUrl) {
|
|
20
|
+
this.queueManager = queueManager;
|
|
21
|
+
this.client = client;
|
|
22
|
+
this.idleDetector = idleDetector;
|
|
23
|
+
this.serverUrl = serverUrl;
|
|
24
|
+
this.blockWatcher = new BlockWatcher(queueManager, client);
|
|
25
|
+
}
|
|
26
|
+
async processNext() {
|
|
27
|
+
if (!(await FileLock.acquire(LOCK_FILE, PROCESSING_LOCK_STALE_MS)))
|
|
28
|
+
return false;
|
|
29
|
+
const heartbeat = FileLock.startHeartbeat(LOCK_FILE, PROCESSING_LOCK_REFRESH_MS);
|
|
30
|
+
try {
|
|
31
|
+
const result = await this.processNextLocked();
|
|
32
|
+
return result.processed;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
FileLock.stopHeartbeat(heartbeat);
|
|
36
|
+
FileLock.release(LOCK_FILE);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async processNextLocked() {
|
|
40
|
+
if (this.isProcessing)
|
|
41
|
+
return { processed: false, continueQueue: false };
|
|
42
|
+
const item = await this.queueManager.getNextPending();
|
|
43
|
+
if (!item)
|
|
44
|
+
return { processed: false, continueQueue: false };
|
|
45
|
+
this.isProcessing = true;
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(item.workspace)) {
|
|
48
|
+
await this.queueManager.updateItem(item.id, {
|
|
49
|
+
status: "failed",
|
|
50
|
+
error: `Directory not found: ${item.workspace}`,
|
|
51
|
+
completedAt: new Date().toISOString(),
|
|
52
|
+
});
|
|
53
|
+
this.isProcessing = false;
|
|
54
|
+
return { processed: true, continueQueue: true };
|
|
55
|
+
}
|
|
56
|
+
const q = { directory: item.workspace };
|
|
57
|
+
const isFollowup = Boolean(item.followupMessage) && Boolean(item.sessionId);
|
|
58
|
+
let sessionId = item.sessionId;
|
|
59
|
+
if (!sessionId) {
|
|
60
|
+
const { data: session } = await this.client.session.create({
|
|
61
|
+
query: q,
|
|
62
|
+
body: { title: item.goal.substring(0, 100) },
|
|
63
|
+
});
|
|
64
|
+
if (!session) {
|
|
65
|
+
await this.queueManager.updateItem(item.id, {
|
|
66
|
+
status: "failed",
|
|
67
|
+
error: "Failed to create session",
|
|
68
|
+
});
|
|
69
|
+
this.isProcessing = false;
|
|
70
|
+
return { processed: true, continueQueue: true };
|
|
71
|
+
}
|
|
72
|
+
sessionId = session.id;
|
|
73
|
+
await this.queueManager.updateItem(item.id, {
|
|
74
|
+
sessionId: session.id,
|
|
75
|
+
sessionUrl: new URL(`/session/${session.id}`, this.serverUrl).toString(),
|
|
76
|
+
startedAt: new Date().toISOString(),
|
|
77
|
+
status: "running",
|
|
78
|
+
nextRetryAt: null,
|
|
79
|
+
error: null,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
await this.queueManager.updateItem(item.id, {
|
|
84
|
+
status: "running",
|
|
85
|
+
nextRetryAt: null,
|
|
86
|
+
error: null,
|
|
87
|
+
...(isFollowup ? { followupMessage: null } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (isFollowup) {
|
|
91
|
+
await this.queueManager.markDescendantsStale(item.id);
|
|
92
|
+
}
|
|
93
|
+
await this.client.session.promptAsync({
|
|
94
|
+
path: { id: sessionId },
|
|
95
|
+
query: q,
|
|
96
|
+
body: {
|
|
97
|
+
parts: [{ type: "text", text: isFollowup ? item.followupMessage : item.goal }],
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
await this.waitForCompletion(item.id, sessionId, q);
|
|
101
|
+
const current = this.queueManager.getItem(item.id);
|
|
102
|
+
return { processed: true, continueQueue: current?.status !== "blocked" };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
await this.handleSessionError(item.id, err);
|
|
106
|
+
return { processed: true, continueQueue: true };
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
this.isProcessing = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async continueSession(itemId, sessionId, workspace) {
|
|
113
|
+
const deadline = Date.now() + this.queueManager.getConfig().sessionTimeoutMinutes * 60 * 1000;
|
|
114
|
+
while (!(await FileLock.acquire(LOCK_FILE, PROCESSING_LOCK_STALE_MS))) {
|
|
115
|
+
if (Date.now() >= deadline)
|
|
116
|
+
return;
|
|
117
|
+
await sleep(1_000);
|
|
118
|
+
}
|
|
119
|
+
const heartbeat = FileLock.startHeartbeat(LOCK_FILE, PROCESSING_LOCK_REFRESH_MS);
|
|
120
|
+
try {
|
|
121
|
+
await this.waitForCompletion(itemId, sessionId, { directory: workspace });
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
FileLock.stopHeartbeat(heartbeat);
|
|
125
|
+
FileLock.release(LOCK_FILE);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async waitForCompletion(itemId, sessionId, q) {
|
|
129
|
+
try {
|
|
130
|
+
const maxWaitMs = this.queueManager.getConfig().sessionTimeoutMinutes * 60 * 1000;
|
|
131
|
+
const pollIntervalMs = 5_000;
|
|
132
|
+
const startTime = Date.now();
|
|
133
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
134
|
+
const item = this.queueManager.getItem(itemId);
|
|
135
|
+
if (!item)
|
|
136
|
+
return;
|
|
137
|
+
const blocked = await this.blockWatcher.checkForBlocks(item);
|
|
138
|
+
if (blocked)
|
|
139
|
+
return;
|
|
140
|
+
const { data: statusMap } = await this.client.session.status({ query: q });
|
|
141
|
+
if (statusMap && statusMap[sessionId]) {
|
|
142
|
+
const status = statusMap[sessionId];
|
|
143
|
+
if (status.type === "idle") {
|
|
144
|
+
await this.captureResult(itemId, sessionId, q);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (status.type === "retry" && status.next) {
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(status.next - Date.now(), pollIntervalMs)));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else if (statusMap && !statusMap[sessionId]) {
|
|
153
|
+
const { data: messages } = await this.client.session.messages({
|
|
154
|
+
path: { id: sessionId },
|
|
155
|
+
query: q,
|
|
156
|
+
});
|
|
157
|
+
const hasAssistantMessage = Boolean(messages?.some((message) => message.info.role === "assistant"));
|
|
158
|
+
if (hasAssistantMessage) {
|
|
159
|
+
await this.captureResult(itemId, sessionId, q);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
164
|
+
}
|
|
165
|
+
await this.queueManager.updateItem(itemId, {
|
|
166
|
+
status: "failed",
|
|
167
|
+
error: "Session timed out",
|
|
168
|
+
completedAt: new Date().toISOString(),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
await this.queueManager.updateItem(itemId, {
|
|
173
|
+
status: "failed",
|
|
174
|
+
error: err instanceof Error ? err.message : String(err),
|
|
175
|
+
completedAt: new Date().toISOString(),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async captureResult(itemId, sessionId, q) {
|
|
180
|
+
try {
|
|
181
|
+
const { data: messages } = await this.client.session.messages({
|
|
182
|
+
path: { id: sessionId },
|
|
183
|
+
query: q,
|
|
184
|
+
});
|
|
185
|
+
let result = "Task completed";
|
|
186
|
+
if (messages && messages.length > 0) {
|
|
187
|
+
const lastAssistant = [...messages].reverse().find((message) => message.info.role === "assistant");
|
|
188
|
+
if (lastAssistant) {
|
|
189
|
+
const textParts = lastAssistant.parts.filter((part) => part.type === "text");
|
|
190
|
+
if (textParts.length > 0) {
|
|
191
|
+
result = textParts.map((part) => part.text).join("\n").substring(0, 1000);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
await this.queueManager.updateItem(itemId, {
|
|
196
|
+
status: "review_pending",
|
|
197
|
+
result,
|
|
198
|
+
completedAt: null,
|
|
199
|
+
reviewedAt: null,
|
|
200
|
+
retryCount: 0,
|
|
201
|
+
nextRetryAt: null,
|
|
202
|
+
error: null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
await this.queueManager.updateItem(itemId, {
|
|
207
|
+
status: "review_pending",
|
|
208
|
+
result: "Task completed (could not fetch result)",
|
|
209
|
+
completedAt: null,
|
|
210
|
+
reviewedAt: null,
|
|
211
|
+
retryCount: 0,
|
|
212
|
+
nextRetryAt: null,
|
|
213
|
+
error: null,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async handleSessionError(itemId, err) {
|
|
218
|
+
const item = this.queueManager.getItem(itemId);
|
|
219
|
+
if (!item)
|
|
220
|
+
return;
|
|
221
|
+
const config = this.queueManager.getConfig();
|
|
222
|
+
const newRetryCount = item.retryCount + 1;
|
|
223
|
+
if (newRetryCount >= config.maxRetries) {
|
|
224
|
+
await this.queueManager.updateItem(itemId, {
|
|
225
|
+
status: "failed",
|
|
226
|
+
error: err instanceof Error ? err.message : String(err),
|
|
227
|
+
retryCount: newRetryCount,
|
|
228
|
+
completedAt: new Date().toISOString(),
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const delayMinutes = config.retryDelaysMinutes[newRetryCount - 1] || 15;
|
|
233
|
+
await this.queueManager.updateItem(itemId, {
|
|
234
|
+
status: "pending",
|
|
235
|
+
retryCount: newRetryCount,
|
|
236
|
+
nextRetryAt: new Date(Date.now() + delayMinutes * 60 * 1000).toISOString(),
|
|
237
|
+
error: err instanceof Error ? err.message : String(err),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
async processQueue() {
|
|
241
|
+
if (!(await FileLock.acquire(LOCK_FILE, PROCESSING_LOCK_STALE_MS)))
|
|
242
|
+
return;
|
|
243
|
+
const heartbeat = FileLock.startHeartbeat(LOCK_FILE, PROCESSING_LOCK_REFRESH_MS);
|
|
244
|
+
try {
|
|
245
|
+
let hasMore = true;
|
|
246
|
+
while (hasMore) {
|
|
247
|
+
const result = await this.processNextLocked();
|
|
248
|
+
if (!result.processed || !result.continueQueue) {
|
|
249
|
+
hasMore = false;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const lastActivity = (() => {
|
|
253
|
+
try {
|
|
254
|
+
return Number.parseInt(readFileSync(LAST_ACTIVITY_FILE, "utf-8").trim(), 10);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
})();
|
|
260
|
+
const stillIdle = Date.now() - lastActivity >= this.queueManager.getConfig().idleTimeoutSeconds * 1000;
|
|
261
|
+
if (!stillIdle)
|
|
262
|
+
hasMore = false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
FileLock.stopHeartbeat(heartbeat);
|
|
267
|
+
FileLock.release(LOCK_FILE);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
getBlockWatcher() {
|
|
271
|
+
return this.blockWatcher;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { CronJob } from "cron";
|
|
2
|
+
/**
|
|
3
|
+
* ScheduleManager is responsible only for CronJob lifecycle and translating
|
|
4
|
+
* schedule triggers into queue items via QueueManager APIs.
|
|
5
|
+
*/
|
|
6
|
+
export class ScheduleManager {
|
|
7
|
+
queueManager;
|
|
8
|
+
jobs = new Map();
|
|
9
|
+
constructor(queueManager) {
|
|
10
|
+
this.queueManager = queueManager;
|
|
11
|
+
}
|
|
12
|
+
createJob(schedule, onFire) {
|
|
13
|
+
return new CronJob(schedule.scheduledFor ? new Date(schedule.scheduledFor) : schedule.cronExpression, onFire, undefined, true, schedule.timezone, null, false, null, true);
|
|
14
|
+
}
|
|
15
|
+
start() {
|
|
16
|
+
const schedules = this.queueManager.listSchedules();
|
|
17
|
+
for (const schedule of schedules) {
|
|
18
|
+
if (schedule.enabled)
|
|
19
|
+
this.startJob(schedule);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
stop() {
|
|
23
|
+
for (const [id, job] of this.jobs) {
|
|
24
|
+
job.stop();
|
|
25
|
+
this.jobs.delete(id);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
startJob(schedule) {
|
|
29
|
+
if (this.jobs.has(schedule.id)) {
|
|
30
|
+
this.jobs.get(schedule.id).stop();
|
|
31
|
+
}
|
|
32
|
+
const onFire = () => void this.onTrigger(schedule.id);
|
|
33
|
+
if (schedule.scheduledFor) {
|
|
34
|
+
const fireDate = new Date(schedule.scheduledFor);
|
|
35
|
+
if (Number.isNaN(fireDate.getTime()))
|
|
36
|
+
return;
|
|
37
|
+
if (fireDate.getTime() <= Date.now()) {
|
|
38
|
+
void this.onTrigger(schedule.id);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const job = this.createJob(schedule, onFire);
|
|
42
|
+
this.jobs.set(schedule.id, job);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (schedule.cronExpression) {
|
|
46
|
+
try {
|
|
47
|
+
const job = this.createJob(schedule, onFire);
|
|
48
|
+
this.jobs.set(schedule.id, job);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Invalid cron expression. Leave the persisted schedule untouched.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async onTrigger(scheduleId) {
|
|
56
|
+
const result = await this.queueManager.triggerSchedule(scheduleId);
|
|
57
|
+
if (!result)
|
|
58
|
+
return;
|
|
59
|
+
if (!result.schedule.enabled && this.jobs.has(scheduleId)) {
|
|
60
|
+
this.jobs.get(scheduleId).stop();
|
|
61
|
+
this.jobs.delete(scheduleId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async addAndStart(schedule) {
|
|
65
|
+
const task = await this.queueManager.addSchedule(schedule);
|
|
66
|
+
if (task.cronExpression) {
|
|
67
|
+
try {
|
|
68
|
+
const job = new CronJob(task.cronExpression, () => { }, undefined, false, task.timezone);
|
|
69
|
+
const nextDate = job.nextDate();
|
|
70
|
+
await this.queueManager.updateSchedule(task.id, {
|
|
71
|
+
nextTriggerAt: nextDate ? nextDate.toISO() : null,
|
|
72
|
+
});
|
|
73
|
+
job.stop();
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
76
|
+
}
|
|
77
|
+
else if (task.scheduledFor) {
|
|
78
|
+
await this.queueManager.updateSchedule(task.id, { nextTriggerAt: task.scheduledFor });
|
|
79
|
+
}
|
|
80
|
+
const updated = this.queueManager.getSchedule(task.id);
|
|
81
|
+
if (updated.enabled)
|
|
82
|
+
this.startJob(updated);
|
|
83
|
+
return updated;
|
|
84
|
+
}
|
|
85
|
+
async removeAndStop(id) {
|
|
86
|
+
const job = this.jobs.get(id);
|
|
87
|
+
if (job) {
|
|
88
|
+
job.stop();
|
|
89
|
+
this.jobs.delete(id);
|
|
90
|
+
}
|
|
91
|
+
return this.queueManager.removeSchedule(id);
|
|
92
|
+
}
|
|
93
|
+
async pause(id) {
|
|
94
|
+
const job = this.jobs.get(id);
|
|
95
|
+
if (job) {
|
|
96
|
+
job.stop();
|
|
97
|
+
this.jobs.delete(id);
|
|
98
|
+
}
|
|
99
|
+
return this.queueManager.updateSchedule(id, { enabled: false, nextTriggerAt: null });
|
|
100
|
+
}
|
|
101
|
+
async resume(id) {
|
|
102
|
+
const updated = await this.queueManager.updateSchedule(id, { enabled: true });
|
|
103
|
+
if (!updated)
|
|
104
|
+
return updated;
|
|
105
|
+
if (updated.cronExpression) {
|
|
106
|
+
try {
|
|
107
|
+
const job = new CronJob(updated.cronExpression, () => { }, undefined, false, updated.timezone);
|
|
108
|
+
const nextDate = job.nextDate();
|
|
109
|
+
job.stop();
|
|
110
|
+
await this.queueManager.updateSchedule(updated.id, {
|
|
111
|
+
nextTriggerAt: nextDate ? nextDate.toISO() : null,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
await this.queueManager.updateSchedule(updated.id, { nextTriggerAt: null });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (updated.scheduledFor) {
|
|
119
|
+
await this.queueManager.updateSchedule(updated.id, { nextTriggerAt: updated.scheduledFor });
|
|
120
|
+
}
|
|
121
|
+
const resumed = this.queueManager.getSchedule(updated.id);
|
|
122
|
+
if (resumed)
|
|
123
|
+
this.startJob(resumed);
|
|
124
|
+
return resumed;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { QueueManager } from "./queue-manager.js";
|
|
2
|
+
import { safeToast } from "./toast.js";
|
|
3
|
+
export class SessionGreeter {
|
|
4
|
+
messageCounts = new Map();
|
|
5
|
+
blockedReminderTimer = null;
|
|
6
|
+
lastBlockedReminderAt = 0;
|
|
7
|
+
getConfig;
|
|
8
|
+
queueManager;
|
|
9
|
+
client;
|
|
10
|
+
constructor(getConfig, queueManager, client) {
|
|
11
|
+
this.getConfig = getConfig;
|
|
12
|
+
this.queueManager = queueManager;
|
|
13
|
+
this.client = client;
|
|
14
|
+
}
|
|
15
|
+
async onSessionCreated() {
|
|
16
|
+
this.showToast();
|
|
17
|
+
}
|
|
18
|
+
startBlockedReminders() {
|
|
19
|
+
if (this.blockedReminderTimer)
|
|
20
|
+
return;
|
|
21
|
+
this.blockedReminderTimer = setInterval(() => {
|
|
22
|
+
void this.checkBlockedReminder();
|
|
23
|
+
}, 60_000);
|
|
24
|
+
this.blockedReminderTimer.unref?.();
|
|
25
|
+
}
|
|
26
|
+
stop() {
|
|
27
|
+
if (this.blockedReminderTimer) {
|
|
28
|
+
clearInterval(this.blockedReminderTimer);
|
|
29
|
+
this.blockedReminderTimer = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async onMessageUpdated(sessionId) {
|
|
33
|
+
const count = (this.messageCounts.get(sessionId) || 0) + 1;
|
|
34
|
+
this.messageCounts.set(sessionId, count);
|
|
35
|
+
if (count >= this.getConfig().reminderIntervalMessages) {
|
|
36
|
+
this.messageCounts.set(sessionId, 0);
|
|
37
|
+
this.showToast();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async checkBlockedReminder() {
|
|
41
|
+
const blockedItems = this.queueManager.listItems("blocked");
|
|
42
|
+
if (blockedItems.length === 0) {
|
|
43
|
+
this.lastBlockedReminderAt = 0;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const intervalMs = this.getConfig().blockedReminderMinutes * 60 * 1000;
|
|
47
|
+
if (intervalMs > 0 && Date.now() - this.lastBlockedReminderAt < intervalMs) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const first = blockedItems[0];
|
|
51
|
+
const summary = first.blockedReason?.details || first.goal;
|
|
52
|
+
this.lastBlockedReminderAt = Date.now();
|
|
53
|
+
safeToast(this.client, `Queue blocked: ${blockedItems.length} item${blockedItems.length === 1 ? "" : "s"} waiting. ${summary.substring(0, 120)}`, "warning");
|
|
54
|
+
}
|
|
55
|
+
showToast() {
|
|
56
|
+
const counts = this.queueManager.countByStatus();
|
|
57
|
+
const pending = counts.pending || 0;
|
|
58
|
+
const blocked = counts.blocked || 0;
|
|
59
|
+
const reviewPending = counts.review_pending || 0;
|
|
60
|
+
const completed = counts.completed || 0;
|
|
61
|
+
const running = counts.running || 0;
|
|
62
|
+
const failed = counts.failed || 0;
|
|
63
|
+
const total = pending + blocked + reviewPending + completed + running + failed;
|
|
64
|
+
if (total === 0)
|
|
65
|
+
return;
|
|
66
|
+
const parts = [];
|
|
67
|
+
if (pending > 0)
|
|
68
|
+
parts.push(`${pending} pending`);
|
|
69
|
+
if (blocked > 0)
|
|
70
|
+
parts.push(`${blocked} blocked`);
|
|
71
|
+
if (reviewPending > 0)
|
|
72
|
+
parts.push(`${reviewPending} review`);
|
|
73
|
+
if (running > 0)
|
|
74
|
+
parts.push(`${running} running`);
|
|
75
|
+
if (completed > 0)
|
|
76
|
+
parts.push(`${completed} completed`);
|
|
77
|
+
if (failed > 0)
|
|
78
|
+
parts.push(`${failed} failed`);
|
|
79
|
+
safeToast(this.client, `Queue: ${parts.join(", ")}`, blocked > 0 ? "warning" : "info");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, watch } from "fs";
|
|
2
|
+
import { LOCK_FILE, QUEUE_FILE, SIGNAL_EXIT_CODE } from "./constants.js";
|
|
3
|
+
import { FileLock } from "./file-lock.js";
|
|
4
|
+
import { IdleDetector } from "./idle-detector.js";
|
|
5
|
+
import { QueueProcessor } from "./queue-processor.js";
|
|
6
|
+
import { QueueManager } from "./queue-manager.js";
|
|
7
|
+
import { ScheduleManager } from "./schedule-manager.js";
|
|
8
|
+
import { SessionGreeter } from "./session-greeter.js";
|
|
9
|
+
const SHARED_STATE_KEY = Symbol.for("opencode.queue.shared-state");
|
|
10
|
+
let activeFsWatcher;
|
|
11
|
+
export function createSharedState(client, serverUrl) {
|
|
12
|
+
const queueManager = new QueueManager();
|
|
13
|
+
const idleDetector = new IdleDetector(() => queueManager.getConfig(), async () => {
|
|
14
|
+
const processor = new QueueProcessor(queueManager, client, idleDetector, serverUrl);
|
|
15
|
+
await processor.processQueue();
|
|
16
|
+
});
|
|
17
|
+
const scheduleManager = new ScheduleManager(queueManager);
|
|
18
|
+
const sessionGreeter = new SessionGreeter(() => queueManager.getConfig(), queueManager, client);
|
|
19
|
+
let fsWatchTimer;
|
|
20
|
+
try {
|
|
21
|
+
if (existsSync(QUEUE_FILE)) {
|
|
22
|
+
activeFsWatcher = watch(QUEUE_FILE, () => {
|
|
23
|
+
if (fsWatchTimer)
|
|
24
|
+
clearTimeout(fsWatchTimer);
|
|
25
|
+
fsWatchTimer = setTimeout(() => {
|
|
26
|
+
fsWatchTimer = undefined;
|
|
27
|
+
const processor = new QueueProcessor(queueManager, client, idleDetector, serverUrl);
|
|
28
|
+
void processor.processQueue();
|
|
29
|
+
}, 500);
|
|
30
|
+
fsWatchTimer.unref?.();
|
|
31
|
+
});
|
|
32
|
+
activeFsWatcher.unref?.();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
return {
|
|
37
|
+
queueManager,
|
|
38
|
+
idleDetector,
|
|
39
|
+
scheduleManager,
|
|
40
|
+
sessionGreeter,
|
|
41
|
+
coordinatorClaimed: false,
|
|
42
|
+
initialized: queueManager.resetRunningToPending(),
|
|
43
|
+
cleanupHandlers: [],
|
|
44
|
+
cleanedUp: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function cleanupSharedState(shared) {
|
|
48
|
+
if (shared.cleanedUp)
|
|
49
|
+
return;
|
|
50
|
+
shared.cleanedUp = true;
|
|
51
|
+
shared.scheduleManager.stop();
|
|
52
|
+
shared.idleDetector.stop();
|
|
53
|
+
shared.sessionGreeter.stop();
|
|
54
|
+
try {
|
|
55
|
+
activeFsWatcher?.close();
|
|
56
|
+
}
|
|
57
|
+
catch { }
|
|
58
|
+
activeFsWatcher = undefined;
|
|
59
|
+
FileLock.release(LOCK_FILE);
|
|
60
|
+
}
|
|
61
|
+
export function registerProcessCleanup(shared) {
|
|
62
|
+
if (shared.cleanupHandlers.length > 0)
|
|
63
|
+
return;
|
|
64
|
+
const onExit = () => {
|
|
65
|
+
cleanupSharedState(shared);
|
|
66
|
+
};
|
|
67
|
+
const onBeforeExit = () => {
|
|
68
|
+
cleanupSharedState(shared);
|
|
69
|
+
};
|
|
70
|
+
const registerSignalHandler = (signal) => {
|
|
71
|
+
const handler = () => {
|
|
72
|
+
cleanupSharedState(shared);
|
|
73
|
+
process.exit(SIGNAL_EXIT_CODE[signal] ?? 0);
|
|
74
|
+
};
|
|
75
|
+
process.once(signal, handler);
|
|
76
|
+
shared.cleanupHandlers.push({ event: signal, handler });
|
|
77
|
+
};
|
|
78
|
+
process.once("exit", onExit);
|
|
79
|
+
shared.cleanupHandlers.push({ event: "exit", handler: onExit });
|
|
80
|
+
process.once("beforeExit", onBeforeExit);
|
|
81
|
+
shared.cleanupHandlers.push({ event: "beforeExit", handler: onBeforeExit });
|
|
82
|
+
registerSignalHandler("SIGINT");
|
|
83
|
+
registerSignalHandler("SIGTERM");
|
|
84
|
+
}
|
|
85
|
+
export function unregisterProcessCleanup(shared) {
|
|
86
|
+
for (const { event, handler } of shared.cleanupHandlers) {
|
|
87
|
+
process.removeListener(event, handler);
|
|
88
|
+
}
|
|
89
|
+
shared.cleanupHandlers = [];
|
|
90
|
+
}
|
|
91
|
+
export function getSharedState(client, serverUrl) {
|
|
92
|
+
const globalState = globalThis;
|
|
93
|
+
if (!globalState[SHARED_STATE_KEY]) {
|
|
94
|
+
globalState[SHARED_STATE_KEY] = createSharedState(client, serverUrl);
|
|
95
|
+
}
|
|
96
|
+
return globalState[SHARED_STATE_KEY];
|
|
97
|
+
}
|
|
98
|
+
export function resetSharedState() {
|
|
99
|
+
const globalState = globalThis;
|
|
100
|
+
if (globalState[SHARED_STATE_KEY]) {
|
|
101
|
+
unregisterProcessCleanup(globalState[SHARED_STATE_KEY]);
|
|
102
|
+
cleanupSharedState(globalState[SHARED_STATE_KEY]);
|
|
103
|
+
}
|
|
104
|
+
delete globalState[SHARED_STATE_KEY];
|
|
105
|
+
}
|
package/dist/toast.js
ADDED
package/dist/types.js
ADDED
package/dist/utils.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function sleep(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
export function createQueueItemFromSchedule(schedule, scheduleId) {
|
|
5
|
+
return {
|
|
6
|
+
id: crypto.randomUUID(),
|
|
7
|
+
workspace: schedule.workspace,
|
|
8
|
+
goal: schedule.goal,
|
|
9
|
+
status: "pending",
|
|
10
|
+
parentItemId: schedule.parentItemId,
|
|
11
|
+
dependencyMode: schedule.dependencyMode,
|
|
12
|
+
dependencySatisfiedAt: null,
|
|
13
|
+
dependencySourceStatus: null,
|
|
14
|
+
dependencyBlockedReason: schedule.parentItemId ? `Waiting for parent ${schedule.parentItemId} to start.` : null,
|
|
15
|
+
staleDependency: false,
|
|
16
|
+
sessionId: null,
|
|
17
|
+
createdAt: new Date().toISOString(),
|
|
18
|
+
startedAt: null,
|
|
19
|
+
completedAt: null,
|
|
20
|
+
reviewedAt: null,
|
|
21
|
+
blockedReason: null,
|
|
22
|
+
error: null,
|
|
23
|
+
result: null,
|
|
24
|
+
sessionUrl: null,
|
|
25
|
+
retryCount: 0,
|
|
26
|
+
nextRetryAt: null,
|
|
27
|
+
sourceScheduleId: scheduleId,
|
|
28
|
+
};
|
|
29
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@geckom/opencode-queue",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "OpenCode plugin that maintains a global task queue and processes queued work when OpenCode is idle.",
|
|
5
|
+
"author": "Geckom",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/opencode-queue.js",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/geckom/opencode-queue.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/geckom/opencode-queue/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/geckom/opencode-queue#readme",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": "./dist/opencode-queue.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist/*.js",
|
|
30
|
+
"!dist/testing.js",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"keywords": [
|
|
35
|
+
"opencode",
|
|
36
|
+
"opencode-plugin",
|
|
37
|
+
"queue",
|
|
38
|
+
"automation"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
42
|
+
"bundle": "esbuild src/opencode-queue.ts --bundle --platform=node --format=esm --external:@opencode-ai/* --outfile=dist/opencode-queue.bundled.js",
|
|
43
|
+
"build:runtime": "npm run build && npm run bundle && mkdir -p \"$HOME/.config/opencode/plugins\" && cp dist/opencode-queue.bundled.js \"$HOME/.config/opencode/plugins/opencode-queue.js\"",
|
|
44
|
+
"pack:check": "npm pack --dry-run",
|
|
45
|
+
"prepublishOnly": "npm test && npm run build",
|
|
46
|
+
"test": "node --test test/*.test.mjs"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@opencode-ai/plugin": "1.15.0",
|
|
50
|
+
"@opencode-ai/sdk": "1.15.0",
|
|
51
|
+
"cron": "^4.4.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^24.12.4",
|
|
55
|
+
"esbuild": "^0.28.0",
|
|
56
|
+
"typescript": "^5.9.3"
|
|
57
|
+
}
|
|
58
|
+
}
|