@shipers-dev/multi 0.9.2 → 0.9.3
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/dist/index.js +49 -18
- package/package.json +1 -1
- package/src/index.ts +51 -17
package/dist/index.js
CHANGED
|
@@ -5947,21 +5947,48 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5947
5947
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
5948
5948
|
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
|
|
5949
5949
|
const running = new Map;
|
|
5950
|
+
function resolvePayloadIds(row) {
|
|
5951
|
+
let agent_id = row.agent_id;
|
|
5952
|
+
let issue_id = row.issue_id;
|
|
5953
|
+
if (!agent_id || !issue_id) {
|
|
5954
|
+
try {
|
|
5955
|
+
const p = JSON.parse(row.payload);
|
|
5956
|
+
agent_id ??= p?.agent_id ?? null;
|
|
5957
|
+
issue_id ??= p?.issue_id ?? null;
|
|
5958
|
+
} catch {}
|
|
5959
|
+
}
|
|
5960
|
+
return { agent_id, issue_id };
|
|
5961
|
+
}
|
|
5950
5962
|
function pickNext() {
|
|
5951
|
-
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(
|
|
5952
|
-
const
|
|
5953
|
-
const
|
|
5954
|
-
|
|
5963
|
+
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v) => !!v);
|
|
5964
|
+
const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v) => !!v);
|
|
5965
|
+
const clauses = [];
|
|
5966
|
+
const binds = [];
|
|
5967
|
+
if (busyAgents.length) {
|
|
5968
|
+
clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))`);
|
|
5969
|
+
binds.push(...busyAgents);
|
|
5970
|
+
}
|
|
5971
|
+
if (busyIssues.length) {
|
|
5972
|
+
clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => "?").join(",")}))`);
|
|
5973
|
+
binds.push(...busyIssues);
|
|
5974
|
+
}
|
|
5975
|
+
const where = clauses.length ? `AND ${clauses.join(" AND ")}` : "";
|
|
5976
|
+
const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
|
|
5977
|
+
return db.query(sql).get(...binds);
|
|
5955
5978
|
}
|
|
5956
5979
|
function schedule() {
|
|
5957
5980
|
while (running.size < MAX_DEVICE) {
|
|
5958
5981
|
const row = pickNext();
|
|
5959
5982
|
if (!row)
|
|
5960
5983
|
return;
|
|
5984
|
+
const ids = resolvePayloadIds(row);
|
|
5985
|
+
if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id))
|
|
5986
|
+
return;
|
|
5987
|
+
if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id))
|
|
5988
|
+
return;
|
|
5961
5989
|
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
5962
|
-
const entry = { agentId:
|
|
5963
|
-
|
|
5964
|
-
running.set(issueKey, entry);
|
|
5990
|
+
const entry = { agentId: ids.agent_id || "", issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: "" };
|
|
5991
|
+
running.set(row.id, entry);
|
|
5965
5992
|
(async () => {
|
|
5966
5993
|
try {
|
|
5967
5994
|
const task = JSON.parse(row.payload);
|
|
@@ -5971,7 +5998,7 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5971
5998
|
log(`task ${row.id} error: ${String(e)}`);
|
|
5972
5999
|
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
5973
6000
|
} finally {
|
|
5974
|
-
running.delete(
|
|
6001
|
+
running.delete(row.id);
|
|
5975
6002
|
queueMicrotask(() => schedule());
|
|
5976
6003
|
}
|
|
5977
6004
|
})();
|
|
@@ -6014,22 +6041,24 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6014
6041
|
const { issue_id } = await req.json();
|
|
6015
6042
|
if (!issue_id)
|
|
6016
6043
|
return Response.json({ error: "issue_id required" }, { status: 400 });
|
|
6017
|
-
const
|
|
6018
|
-
if (!
|
|
6044
|
+
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
6045
|
+
if (!entries.length) {
|
|
6019
6046
|
db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
|
|
6020
6047
|
await markStopped(apiUrl, issue_id, "stopped before start");
|
|
6021
6048
|
return Response.json({ ok: true, state: "queued-cancelled" });
|
|
6022
6049
|
}
|
|
6023
|
-
entry
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
entry.child?.kill("SIGTERM");
|
|
6027
|
-
} catch {}
|
|
6028
|
-
setTimeout(() => {
|
|
6050
|
+
for (const entry of entries) {
|
|
6051
|
+
entry.stopped = true;
|
|
6052
|
+
entry.stopReason = "user requested";
|
|
6029
6053
|
try {
|
|
6030
|
-
entry.child?.kill("
|
|
6054
|
+
entry.child?.kill("SIGTERM");
|
|
6031
6055
|
} catch {}
|
|
6032
|
-
|
|
6056
|
+
setTimeout(() => {
|
|
6057
|
+
try {
|
|
6058
|
+
entry.child?.kill("SIGKILL");
|
|
6059
|
+
} catch {}
|
|
6060
|
+
}, 5000);
|
|
6061
|
+
}
|
|
6033
6062
|
return Response.json({ ok: true, state: "running-signalled" });
|
|
6034
6063
|
} catch (e) {
|
|
6035
6064
|
return Response.json({ error: String(e) }, { status: 400 });
|
|
@@ -7288,6 +7317,8 @@ function openTasksDb() {
|
|
|
7288
7317
|
CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
|
|
7289
7318
|
`);
|
|
7290
7319
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
|
|
7320
|
+
db.run("UPDATE tasks SET agent_id = json_extract(payload, '$.agent_id') WHERE agent_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
|
|
7321
|
+
db.run("UPDATE tasks SET issue_id = json_extract(payload, '$.issue_id') WHERE issue_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
|
|
7291
7322
|
return db;
|
|
7292
7323
|
}
|
|
7293
7324
|
function loadConfig() {
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -261,26 +261,52 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
261
261
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
262
262
|
|
|
263
263
|
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? '3', 10) || 3);
|
|
264
|
+
// Keyed by task row id — unique per dispatch. Duplicate issue_id / agent_id still
|
|
265
|
+
// serialize via busyAgents/busyIssues filters.
|
|
264
266
|
const running = new Map<string, RunEntry>();
|
|
265
267
|
|
|
268
|
+
function resolvePayloadIds(row: { payload: string; agent_id: string | null; issue_id: string | null }): { agent_id: string | null; issue_id: string | null } {
|
|
269
|
+
let agent_id = row.agent_id;
|
|
270
|
+
let issue_id = row.issue_id;
|
|
271
|
+
if (!agent_id || !issue_id) {
|
|
272
|
+
try {
|
|
273
|
+
const p = JSON.parse(row.payload) as any;
|
|
274
|
+
agent_id ??= p?.agent_id ?? null;
|
|
275
|
+
issue_id ??= p?.issue_id ?? null;
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
return { agent_id, issue_id };
|
|
279
|
+
}
|
|
280
|
+
|
|
266
281
|
function pickNext(): { id: string; payload: string; agent_id: string | null; issue_id: string | null } | null {
|
|
267
|
-
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
282
|
+
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v): v is string => !!v);
|
|
283
|
+
const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v): v is string => !!v);
|
|
284
|
+
const clauses: string[] = [];
|
|
285
|
+
const binds: string[] = [];
|
|
286
|
+
if (busyAgents.length) {
|
|
287
|
+
clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`);
|
|
288
|
+
binds.push(...busyAgents);
|
|
289
|
+
}
|
|
290
|
+
if (busyIssues.length) {
|
|
291
|
+
clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => '?').join(',')}))`);
|
|
292
|
+
binds.push(...busyIssues);
|
|
293
|
+
}
|
|
294
|
+
const where = clauses.length ? `AND ${clauses.join(' AND ')}` : '';
|
|
295
|
+
const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
|
|
296
|
+
return db.query(sql).get(...binds) as any;
|
|
274
297
|
}
|
|
275
298
|
|
|
276
299
|
function schedule() {
|
|
277
300
|
while (running.size < MAX_DEVICE) {
|
|
278
301
|
const row = pickNext();
|
|
279
302
|
if (!row) return;
|
|
303
|
+
const ids = resolvePayloadIds(row);
|
|
304
|
+
// Defensive: if DB saw NULL agent/issue but payload has them, skip if busy.
|
|
305
|
+
if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id)) return;
|
|
306
|
+
if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id)) return;
|
|
280
307
|
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
281
|
-
const entry: RunEntry = { agentId:
|
|
282
|
-
|
|
283
|
-
running.set(issueKey, entry);
|
|
308
|
+
const entry: RunEntry = { agentId: ids.agent_id || '', issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: '' };
|
|
309
|
+
running.set(row.id, entry);
|
|
284
310
|
void (async () => {
|
|
285
311
|
try {
|
|
286
312
|
const task = JSON.parse(row.payload);
|
|
@@ -290,7 +316,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
290
316
|
log(`task ${row.id} error: ${String(e)}`);
|
|
291
317
|
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
292
318
|
} finally {
|
|
293
|
-
running.delete(
|
|
319
|
+
running.delete(row.id);
|
|
294
320
|
queueMicrotask(() => schedule());
|
|
295
321
|
}
|
|
296
322
|
})();
|
|
@@ -334,16 +360,18 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
334
360
|
try {
|
|
335
361
|
const { issue_id } = await req.json() as { issue_id: string };
|
|
336
362
|
if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
|
|
337
|
-
const
|
|
338
|
-
if (!
|
|
363
|
+
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
364
|
+
if (!entries.length) {
|
|
339
365
|
db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
|
|
340
366
|
await markStopped(apiUrl, issue_id, 'stopped before start');
|
|
341
367
|
return Response.json({ ok: true, state: 'queued-cancelled' });
|
|
342
368
|
}
|
|
343
|
-
entry
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
369
|
+
for (const entry of entries) {
|
|
370
|
+
entry.stopped = true;
|
|
371
|
+
entry.stopReason = 'user requested';
|
|
372
|
+
try { entry.child?.kill('SIGTERM'); } catch {}
|
|
373
|
+
setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
|
|
374
|
+
}
|
|
347
375
|
return Response.json({ ok: true, state: 'running-signalled' });
|
|
348
376
|
} catch (e) {
|
|
349
377
|
return Response.json({ error: String(e) }, { status: 400 });
|
|
@@ -462,6 +490,7 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
|
|
|
462
490
|
|
|
463
491
|
interface RunEntry {
|
|
464
492
|
agentId: string;
|
|
493
|
+
issueId: string | null;
|
|
465
494
|
startedAt: number;
|
|
466
495
|
child: any | null;
|
|
467
496
|
worktreePath: string;
|
|
@@ -1458,6 +1487,11 @@ function openTasksDb(): Database {
|
|
|
1458
1487
|
`);
|
|
1459
1488
|
// Old rows used 'pending'; normalize to 'queued'.
|
|
1460
1489
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
|
|
1490
|
+
// Backfill agent_id/issue_id from payload JSON for rows predating the columns.
|
|
1491
|
+
// Without this, the scheduler cannot serialize per-agent / per-issue and fires
|
|
1492
|
+
// concurrent dispatches for the same agent on the same issue.
|
|
1493
|
+
db.run("UPDATE tasks SET agent_id = json_extract(payload, '$.agent_id') WHERE agent_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
|
|
1494
|
+
db.run("UPDATE tasks SET issue_id = json_extract(payload, '$.issue_id') WHERE issue_id IS NULL AND payload IS NOT NULL AND json_valid(payload)");
|
|
1461
1495
|
return db;
|
|
1462
1496
|
}
|
|
1463
1497
|
|