@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.
Files changed (3) hide show
  1. package/dist/index.js +49 -18
  2. package/package.json +1 -1
  3. 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(Boolean);
5952
- const notIn = busyAgents.length ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))` : "";
5953
- const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
5954
- return db.query(sql).get(...busyAgents);
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: row.agent_id || "", startedAt: Date.now(), child: null, worktreePath: "" };
5963
- const issueKey = row.issue_id || row.id;
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(issueKey);
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 entry = running.get(issue_id);
6018
- if (!entry) {
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.stopped = true;
6024
- entry.stopReason = "user requested";
6025
- try {
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("SIGKILL");
6054
+ entry.child?.kill("SIGTERM");
6031
6055
  } catch {}
6032
- }, 5000);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
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(Boolean) as string[];
268
- // Emulate NOT IN (:list) via string construction (safe: only ids from DB).
269
- const notIn = busyAgents.length
270
- ? `AND (agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`
271
- : '';
272
- const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${notIn} ORDER BY created_at ASC LIMIT 1`;
273
- return db.query(sql).get(...busyAgents) as any;
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: row.agent_id || '', startedAt: Date.now(), child: null, worktreePath: '' };
282
- const issueKey = row.issue_id || row.id;
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(issueKey);
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 entry = running.get(issue_id);
338
- if (!entry) {
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.stopped = true;
344
- entry.stopReason = 'user requested';
345
- try { entry.child?.kill('SIGTERM'); } catch {}
346
- setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
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