@shipers-dev/multi 0.9.2 → 0.9.4
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 +156 -21
- package/package.json +1 -1
- package/src/index.ts +118 -18
package/dist/index.js
CHANGED
|
@@ -5700,15 +5700,35 @@ async function ensureWorktree(workingDir, issueKey) {
|
|
|
5700
5700
|
import { parseArgs } from "util";
|
|
5701
5701
|
import { mkdirSync as mkdirSync3, existsSync as existsSync3, writeFileSync as writeFileSync3, readFileSync as readFileSync3, appendFileSync as appendFileSync2, unlinkSync, readdirSync, statSync } from "fs";
|
|
5702
5702
|
import { join as join3, dirname as dirname2 } from "path";
|
|
5703
|
+
// package.json
|
|
5704
|
+
var package_default = {
|
|
5705
|
+
name: "@shipers-dev/multi",
|
|
5706
|
+
version: "0.9.4",
|
|
5707
|
+
type: "module",
|
|
5708
|
+
bin: {
|
|
5709
|
+
"multi-agent": "./dist/index.js"
|
|
5710
|
+
},
|
|
5711
|
+
scripts: {
|
|
5712
|
+
dev: "bun run src/index.ts",
|
|
5713
|
+
build: "bun build src/index.ts --outdir=dist --target=bun"
|
|
5714
|
+
},
|
|
5715
|
+
dependencies: {
|
|
5716
|
+
"@zed-industries/agent-client-protocol": "^0.4.5",
|
|
5717
|
+
"@zed-industries/claude-code-acp": "^0.16.2"
|
|
5718
|
+
}
|
|
5719
|
+
};
|
|
5720
|
+
|
|
5721
|
+
// src/index.ts
|
|
5703
5722
|
var HOME2 = process.env.HOME || process.env.USERPROFILE || ".";
|
|
5704
5723
|
var MULTI_DIR = join3(HOME2, ".multi");
|
|
5705
5724
|
var CONFIG_PATH = join3(MULTI_DIR, "config.json");
|
|
5706
5725
|
var PID_PATH = join3(MULTI_DIR, "agent.pid");
|
|
5726
|
+
var PORT_PATH = join3(MULTI_DIR, "agent.port");
|
|
5707
5727
|
var LOG_PATH2 = join3(MULTI_DIR, "logs", "agent.log");
|
|
5708
5728
|
var SKILLS_DIR = join3(MULTI_DIR, "skills");
|
|
5709
5729
|
var STOP_PATH = join3(MULTI_DIR, "stop.flag");
|
|
5710
5730
|
var TASKS_DB_PATH = join3(MULTI_DIR, "tasks.db");
|
|
5711
|
-
var VERSION =
|
|
5731
|
+
var VERSION = package_default.version;
|
|
5712
5732
|
var COMMANDS = {
|
|
5713
5733
|
setup: "Register this device with a workspace",
|
|
5714
5734
|
connect: "Connect device to realtime hub and execute assigned tasks",
|
|
@@ -5716,7 +5736,8 @@ var COMMANDS = {
|
|
|
5716
5736
|
status: "Show current status",
|
|
5717
5737
|
stop: "Stop the running daemon",
|
|
5718
5738
|
restart: "Stop and relaunch the daemon in background",
|
|
5719
|
-
logs: "View execution logs"
|
|
5739
|
+
logs: "View execution logs",
|
|
5740
|
+
reset: "Reset acpx session for an issue (--issue <id>)"
|
|
5720
5741
|
};
|
|
5721
5742
|
function ensureDirs() {
|
|
5722
5743
|
for (const d of [MULTI_DIR, join3(MULTI_DIR, "logs"), SKILLS_DIR]) {
|
|
@@ -5746,7 +5767,8 @@ async function main() {
|
|
|
5746
5767
|
name: { type: "string" },
|
|
5747
5768
|
workspace: { type: "string" },
|
|
5748
5769
|
agent: { type: "string" },
|
|
5749
|
-
api: { type: "string" }
|
|
5770
|
+
api: { type: "string" },
|
|
5771
|
+
issue: { type: "string" }
|
|
5750
5772
|
},
|
|
5751
5773
|
allowPositionals: true,
|
|
5752
5774
|
strict: false
|
|
@@ -5787,6 +5809,9 @@ async function main() {
|
|
|
5787
5809
|
case "logs":
|
|
5788
5810
|
await cmdLogs();
|
|
5789
5811
|
break;
|
|
5812
|
+
case "reset":
|
|
5813
|
+
await cmdReset(args.values.issue);
|
|
5814
|
+
break;
|
|
5790
5815
|
default:
|
|
5791
5816
|
console.error(`Unknown command: ${command}`);
|
|
5792
5817
|
printHelp();
|
|
@@ -5807,6 +5832,7 @@ Commands:
|
|
|
5807
5832
|
stop ${COMMANDS.stop}
|
|
5808
5833
|
restart ${COMMANDS.restart}
|
|
5809
5834
|
logs ${COMMANDS.logs}
|
|
5835
|
+
reset ${COMMANDS.reset}
|
|
5810
5836
|
|
|
5811
5837
|
Options:
|
|
5812
5838
|
--name <name> Device name
|
|
@@ -5947,21 +5973,48 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5947
5973
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
5948
5974
|
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
|
|
5949
5975
|
const running = new Map;
|
|
5976
|
+
function resolvePayloadIds(row) {
|
|
5977
|
+
let agent_id = row.agent_id;
|
|
5978
|
+
let issue_id = row.issue_id;
|
|
5979
|
+
if (!agent_id || !issue_id) {
|
|
5980
|
+
try {
|
|
5981
|
+
const p = JSON.parse(row.payload);
|
|
5982
|
+
agent_id ??= p?.agent_id ?? null;
|
|
5983
|
+
issue_id ??= p?.issue_id ?? null;
|
|
5984
|
+
} catch {}
|
|
5985
|
+
}
|
|
5986
|
+
return { agent_id, issue_id };
|
|
5987
|
+
}
|
|
5950
5988
|
function pickNext() {
|
|
5951
|
-
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(
|
|
5952
|
-
const
|
|
5953
|
-
const
|
|
5954
|
-
|
|
5989
|
+
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v) => !!v);
|
|
5990
|
+
const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v) => !!v);
|
|
5991
|
+
const clauses = [];
|
|
5992
|
+
const binds = [];
|
|
5993
|
+
if (busyAgents.length) {
|
|
5994
|
+
clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => "?").join(",")}))`);
|
|
5995
|
+
binds.push(...busyAgents);
|
|
5996
|
+
}
|
|
5997
|
+
if (busyIssues.length) {
|
|
5998
|
+
clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => "?").join(",")}))`);
|
|
5999
|
+
binds.push(...busyIssues);
|
|
6000
|
+
}
|
|
6001
|
+
const where = clauses.length ? `AND ${clauses.join(" AND ")}` : "";
|
|
6002
|
+
const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
|
|
6003
|
+
return db.query(sql).get(...binds);
|
|
5955
6004
|
}
|
|
5956
6005
|
function schedule() {
|
|
5957
6006
|
while (running.size < MAX_DEVICE) {
|
|
5958
6007
|
const row = pickNext();
|
|
5959
6008
|
if (!row)
|
|
5960
6009
|
return;
|
|
6010
|
+
const ids = resolvePayloadIds(row);
|
|
6011
|
+
if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id))
|
|
6012
|
+
return;
|
|
6013
|
+
if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id))
|
|
6014
|
+
return;
|
|
5961
6015
|
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);
|
|
6016
|
+
const entry = { agentId: ids.agent_id || "", issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: "" };
|
|
6017
|
+
running.set(row.id, entry);
|
|
5965
6018
|
(async () => {
|
|
5966
6019
|
try {
|
|
5967
6020
|
const task = JSON.parse(row.payload);
|
|
@@ -5971,7 +6024,7 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5971
6024
|
log(`task ${row.id} error: ${String(e)}`);
|
|
5972
6025
|
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
5973
6026
|
} finally {
|
|
5974
|
-
running.delete(
|
|
6027
|
+
running.delete(row.id);
|
|
5975
6028
|
queueMicrotask(() => schedule());
|
|
5976
6029
|
}
|
|
5977
6030
|
})();
|
|
@@ -6006,6 +6059,52 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6006
6059
|
}
|
|
6007
6060
|
})();
|
|
6008
6061
|
}
|
|
6062
|
+
if (url.pathname === "/reset_session" && req.method === "POST") {
|
|
6063
|
+
if (req.headers.get("authorization") !== expectedAuth)
|
|
6064
|
+
return new Response("unauthorized", { status: 401 });
|
|
6065
|
+
return (async () => {
|
|
6066
|
+
try {
|
|
6067
|
+
const { issue_id, agent_type } = await req.json();
|
|
6068
|
+
if (!issue_id)
|
|
6069
|
+
return Response.json({ error: "issue_id required" }, { status: 400 });
|
|
6070
|
+
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
6071
|
+
for (const entry of entries) {
|
|
6072
|
+
entry.stopped = true;
|
|
6073
|
+
entry.stopReason = "session reset";
|
|
6074
|
+
try {
|
|
6075
|
+
entry.child?.kill("SIGTERM");
|
|
6076
|
+
} catch {}
|
|
6077
|
+
setTimeout(() => {
|
|
6078
|
+
try {
|
|
6079
|
+
entry.child?.kill("SIGKILL");
|
|
6080
|
+
} catch {}
|
|
6081
|
+
}, 3000);
|
|
6082
|
+
}
|
|
6083
|
+
const acpxTypes = agent_type ? [agent_type] : ["pi", "codex", "openclaw"];
|
|
6084
|
+
const sessionName = `issue-${issue_id}`;
|
|
6085
|
+
const cwd = process.env.HOME || process.cwd();
|
|
6086
|
+
const results = [];
|
|
6087
|
+
for (const t of acpxTypes) {
|
|
6088
|
+
try {
|
|
6089
|
+
const rm = Bun.spawn(["acpx", "--ttl", "0", "--cwd", cwd, t, "sessions", "close", sessionName], {
|
|
6090
|
+
stdout: "pipe",
|
|
6091
|
+
stderr: "pipe",
|
|
6092
|
+
stdin: "ignore"
|
|
6093
|
+
});
|
|
6094
|
+
const err = await new Response(rm.stderr).text();
|
|
6095
|
+
const exit = await rm.exited;
|
|
6096
|
+
results.push({ agent: t, exit, stderr: err.slice(0, 200) });
|
|
6097
|
+
} catch (e) {
|
|
6098
|
+
results.push({ agent: t, exit: -1, stderr: String(e).slice(0, 200) });
|
|
6099
|
+
}
|
|
6100
|
+
}
|
|
6101
|
+
log(`\uD83D\uDD04 reset_session ${issue_id} (killed=${entries.length}) ${results.map((r) => `${r.agent}:${r.exit}`).join(" ")}`);
|
|
6102
|
+
return Response.json({ ok: true, killed: entries.length, sessions: results });
|
|
6103
|
+
} catch (e) {
|
|
6104
|
+
return Response.json({ error: String(e) }, { status: 400 });
|
|
6105
|
+
}
|
|
6106
|
+
})();
|
|
6107
|
+
}
|
|
6009
6108
|
if (url.pathname === "/stop" && req.method === "POST") {
|
|
6010
6109
|
if (req.headers.get("authorization") !== expectedAuth)
|
|
6011
6110
|
return new Response("unauthorized", { status: 401 });
|
|
@@ -6014,22 +6113,24 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6014
6113
|
const { issue_id } = await req.json();
|
|
6015
6114
|
if (!issue_id)
|
|
6016
6115
|
return Response.json({ error: "issue_id required" }, { status: 400 });
|
|
6017
|
-
const
|
|
6018
|
-
if (!
|
|
6116
|
+
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
6117
|
+
if (!entries.length) {
|
|
6019
6118
|
db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
|
|
6020
6119
|
await markStopped(apiUrl, issue_id, "stopped before start");
|
|
6021
6120
|
return Response.json({ ok: true, state: "queued-cancelled" });
|
|
6022
6121
|
}
|
|
6023
|
-
entry
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
entry.child?.kill("SIGTERM");
|
|
6027
|
-
} catch {}
|
|
6028
|
-
setTimeout(() => {
|
|
6122
|
+
for (const entry of entries) {
|
|
6123
|
+
entry.stopped = true;
|
|
6124
|
+
entry.stopReason = "user requested";
|
|
6029
6125
|
try {
|
|
6030
|
-
entry.child?.kill("
|
|
6126
|
+
entry.child?.kill("SIGTERM");
|
|
6031
6127
|
} catch {}
|
|
6032
|
-
|
|
6128
|
+
setTimeout(() => {
|
|
6129
|
+
try {
|
|
6130
|
+
entry.child?.kill("SIGKILL");
|
|
6131
|
+
} catch {}
|
|
6132
|
+
}, 5000);
|
|
6133
|
+
}
|
|
6033
6134
|
return Response.json({ ok: true, state: "running-signalled" });
|
|
6034
6135
|
} catch (e) {
|
|
6035
6136
|
return Response.json({ error: String(e) }, { status: 400 });
|
|
@@ -6040,6 +6141,9 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6040
6141
|
}
|
|
6041
6142
|
});
|
|
6042
6143
|
log(`\uD83C\uDF10 Local server: http://127.0.0.1:${port}`);
|
|
6144
|
+
try {
|
|
6145
|
+
writeFileSync3(PORT_PATH, String(port));
|
|
6146
|
+
} catch {}
|
|
6043
6147
|
const cf = Bun.spawn(["cloudflared", "tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`], {
|
|
6044
6148
|
stdout: "pipe",
|
|
6045
6149
|
stderr: "pipe",
|
|
@@ -6077,6 +6181,8 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6077
6181
|
unlinkSync(PID_PATH);
|
|
6078
6182
|
if (existsSync3(STOP_PATH))
|
|
6079
6183
|
unlinkSync(STOP_PATH);
|
|
6184
|
+
if (existsSync3(PORT_PATH))
|
|
6185
|
+
unlinkSync(PORT_PATH);
|
|
6080
6186
|
db.close();
|
|
6081
6187
|
log("\uD83D\uDC4B Disconnected");
|
|
6082
6188
|
process.exit(0);
|
|
@@ -7199,6 +7305,33 @@ async function cmdStop() {
|
|
|
7199
7305
|
console.log(`Sent SIGTERM to ${pid}`);
|
|
7200
7306
|
} catch {}
|
|
7201
7307
|
}
|
|
7308
|
+
async function cmdReset(issueId) {
|
|
7309
|
+
if (!issueId) {
|
|
7310
|
+
console.error("Usage: multi-agent reset --issue <issue_id>");
|
|
7311
|
+
process.exit(2);
|
|
7312
|
+
}
|
|
7313
|
+
if (!existsSync3(PORT_PATH)) {
|
|
7314
|
+
console.error("Daemon not running (no port file).");
|
|
7315
|
+
process.exit(1);
|
|
7316
|
+
}
|
|
7317
|
+
const port = Number(readFileSync3(PORT_PATH, "utf8").trim());
|
|
7318
|
+
const config = loadConfig();
|
|
7319
|
+
if (!config.dispatchSecret) {
|
|
7320
|
+
console.error("No dispatchSecret in config \u2014 run `multi-agent setup` first.");
|
|
7321
|
+
process.exit(1);
|
|
7322
|
+
}
|
|
7323
|
+
const res = await fetch(`http://127.0.0.1:${port}/reset_session`, {
|
|
7324
|
+
method: "POST",
|
|
7325
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${config.dispatchSecret}` },
|
|
7326
|
+
body: JSON.stringify({ issue_id: issueId })
|
|
7327
|
+
});
|
|
7328
|
+
const body = await res.text();
|
|
7329
|
+
if (!res.ok) {
|
|
7330
|
+
console.error(`Reset failed (${res.status}): ${body}`);
|
|
7331
|
+
process.exit(1);
|
|
7332
|
+
}
|
|
7333
|
+
console.log(body);
|
|
7334
|
+
}
|
|
7202
7335
|
async function cmdRestart(apiUrl) {
|
|
7203
7336
|
if (existsSync3(PID_PATH)) {
|
|
7204
7337
|
const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
|
|
@@ -7288,6 +7421,8 @@ function openTasksDb() {
|
|
|
7288
7421
|
CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
|
|
7289
7422
|
`);
|
|
7290
7423
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
|
|
7424
|
+
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)");
|
|
7425
|
+
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
7426
|
return db;
|
|
7292
7427
|
}
|
|
7293
7428
|
function loadConfig() {
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -9,16 +9,18 @@ import { ensureWorktree } from './worktree';
|
|
|
9
9
|
import { parseArgs } from 'util';
|
|
10
10
|
import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
11
11
|
import { join, dirname } from 'path';
|
|
12
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
12
13
|
|
|
13
14
|
const HOME = process.env.HOME || process.env.USERPROFILE || '.';
|
|
14
15
|
const MULTI_DIR = join(HOME, '.multi');
|
|
15
16
|
const CONFIG_PATH = join(MULTI_DIR, 'config.json');
|
|
16
17
|
const PID_PATH = join(MULTI_DIR, 'agent.pid');
|
|
18
|
+
const PORT_PATH = join(MULTI_DIR, 'agent.port');
|
|
17
19
|
const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
|
|
18
20
|
const SKILLS_DIR = join(MULTI_DIR, 'skills');
|
|
19
21
|
const STOP_PATH = join(MULTI_DIR, 'stop.flag');
|
|
20
22
|
const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
|
|
21
|
-
const VERSION =
|
|
23
|
+
const VERSION = (pkg as { version: string }).version;
|
|
22
24
|
|
|
23
25
|
const COMMANDS = {
|
|
24
26
|
setup: 'Register this device with a workspace',
|
|
@@ -28,6 +30,7 @@ const COMMANDS = {
|
|
|
28
30
|
stop: 'Stop the running daemon',
|
|
29
31
|
restart: 'Stop and relaunch the daemon in background',
|
|
30
32
|
logs: 'View execution logs',
|
|
33
|
+
reset: 'Reset acpx session for an issue (--issue <id>)',
|
|
31
34
|
} as const;
|
|
32
35
|
|
|
33
36
|
type Command = keyof typeof COMMANDS;
|
|
@@ -62,6 +65,7 @@ async function main() {
|
|
|
62
65
|
workspace: { type: 'string' },
|
|
63
66
|
agent: { type: 'string' },
|
|
64
67
|
api: { type: 'string' },
|
|
68
|
+
issue: { type: 'string' },
|
|
65
69
|
},
|
|
66
70
|
allowPositionals: true,
|
|
67
71
|
strict: false,
|
|
@@ -105,6 +109,9 @@ async function main() {
|
|
|
105
109
|
case 'logs':
|
|
106
110
|
await cmdLogs();
|
|
107
111
|
break;
|
|
112
|
+
case 'reset':
|
|
113
|
+
await cmdReset(args.values.issue);
|
|
114
|
+
break;
|
|
108
115
|
default:
|
|
109
116
|
console.error(`Unknown command: ${command}`);
|
|
110
117
|
printHelp();
|
|
@@ -126,6 +133,7 @@ Commands:
|
|
|
126
133
|
stop ${COMMANDS.stop}
|
|
127
134
|
restart ${COMMANDS.restart}
|
|
128
135
|
logs ${COMMANDS.logs}
|
|
136
|
+
reset ${COMMANDS.reset}
|
|
129
137
|
|
|
130
138
|
Options:
|
|
131
139
|
--name <name> Device name
|
|
@@ -261,26 +269,52 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
261
269
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
262
270
|
|
|
263
271
|
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? '3', 10) || 3);
|
|
272
|
+
// Keyed by task row id — unique per dispatch. Duplicate issue_id / agent_id still
|
|
273
|
+
// serialize via busyAgents/busyIssues filters.
|
|
264
274
|
const running = new Map<string, RunEntry>();
|
|
265
275
|
|
|
276
|
+
function resolvePayloadIds(row: { payload: string; agent_id: string | null; issue_id: string | null }): { agent_id: string | null; issue_id: string | null } {
|
|
277
|
+
let agent_id = row.agent_id;
|
|
278
|
+
let issue_id = row.issue_id;
|
|
279
|
+
if (!agent_id || !issue_id) {
|
|
280
|
+
try {
|
|
281
|
+
const p = JSON.parse(row.payload) as any;
|
|
282
|
+
agent_id ??= p?.agent_id ?? null;
|
|
283
|
+
issue_id ??= p?.issue_id ?? null;
|
|
284
|
+
} catch {}
|
|
285
|
+
}
|
|
286
|
+
return { agent_id, issue_id };
|
|
287
|
+
}
|
|
288
|
+
|
|
266
289
|
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
|
-
|
|
290
|
+
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter((v): v is string => !!v);
|
|
291
|
+
const busyIssues = Array.from(running.values()).map((e) => e.issueId).filter((v): v is string => !!v);
|
|
292
|
+
const clauses: string[] = [];
|
|
293
|
+
const binds: string[] = [];
|
|
294
|
+
if (busyAgents.length) {
|
|
295
|
+
clauses.push(`(agent_id IS NULL OR agent_id NOT IN (${busyAgents.map(() => '?').join(',')}))`);
|
|
296
|
+
binds.push(...busyAgents);
|
|
297
|
+
}
|
|
298
|
+
if (busyIssues.length) {
|
|
299
|
+
clauses.push(`(issue_id IS NULL OR issue_id NOT IN (${busyIssues.map(() => '?').join(',')}))`);
|
|
300
|
+
binds.push(...busyIssues);
|
|
301
|
+
}
|
|
302
|
+
const where = clauses.length ? `AND ${clauses.join(' AND ')}` : '';
|
|
303
|
+
const sql = `SELECT id, payload, agent_id, issue_id FROM tasks WHERE status = 'queued' ${where} ORDER BY created_at ASC LIMIT 1`;
|
|
304
|
+
return db.query(sql).get(...binds) as any;
|
|
274
305
|
}
|
|
275
306
|
|
|
276
307
|
function schedule() {
|
|
277
308
|
while (running.size < MAX_DEVICE) {
|
|
278
309
|
const row = pickNext();
|
|
279
310
|
if (!row) return;
|
|
311
|
+
const ids = resolvePayloadIds(row);
|
|
312
|
+
// Defensive: if DB saw NULL agent/issue but payload has them, skip if busy.
|
|
313
|
+
if (ids.agent_id && Array.from(running.values()).some((e) => e.agentId === ids.agent_id)) return;
|
|
314
|
+
if (ids.issue_id && Array.from(running.values()).some((e) => e.issueId === ids.issue_id)) return;
|
|
280
315
|
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);
|
|
316
|
+
const entry: RunEntry = { agentId: ids.agent_id || '', issueId: ids.issue_id, startedAt: Date.now(), child: null, worktreePath: '' };
|
|
317
|
+
running.set(row.id, entry);
|
|
284
318
|
void (async () => {
|
|
285
319
|
try {
|
|
286
320
|
const task = JSON.parse(row.payload);
|
|
@@ -290,7 +324,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
290
324
|
log(`task ${row.id} error: ${String(e)}`);
|
|
291
325
|
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
292
326
|
} finally {
|
|
293
|
-
running.delete(
|
|
327
|
+
running.delete(row.id);
|
|
294
328
|
queueMicrotask(() => schedule());
|
|
295
329
|
}
|
|
296
330
|
})();
|
|
@@ -328,22 +362,64 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
328
362
|
}
|
|
329
363
|
})();
|
|
330
364
|
}
|
|
365
|
+
if (url.pathname === '/reset_session' && req.method === 'POST') {
|
|
366
|
+
if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
|
|
367
|
+
return (async () => {
|
|
368
|
+
try {
|
|
369
|
+
const { issue_id, agent_type } = await req.json() as { issue_id: string; agent_type?: string };
|
|
370
|
+
if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
|
|
371
|
+
|
|
372
|
+
// Kill any running acpx child for this issue.
|
|
373
|
+
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
374
|
+
for (const entry of entries) {
|
|
375
|
+
entry.stopped = true;
|
|
376
|
+
entry.stopReason = 'session reset';
|
|
377
|
+
try { entry.child?.kill('SIGTERM'); } catch {}
|
|
378
|
+
setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 3000);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Close acpx session so next dispatch creates fresh. Only known acpx types.
|
|
382
|
+
const acpxTypes = agent_type ? [agent_type] : ['pi', 'codex', 'openclaw'];
|
|
383
|
+
const sessionName = `issue-${issue_id}`;
|
|
384
|
+
const cwd = process.env.HOME || process.cwd();
|
|
385
|
+
const results: { agent: string; exit: number; stderr: string }[] = [];
|
|
386
|
+
for (const t of acpxTypes) {
|
|
387
|
+
try {
|
|
388
|
+
const rm = Bun.spawn(['acpx', '--ttl', '0', '--cwd', cwd, t, 'sessions', 'close', sessionName], {
|
|
389
|
+
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
|
390
|
+
});
|
|
391
|
+
const err = await new Response(rm.stderr as any).text();
|
|
392
|
+
const exit = await rm.exited;
|
|
393
|
+
results.push({ agent: t, exit, stderr: err.slice(0, 200) });
|
|
394
|
+
} catch (e) {
|
|
395
|
+
results.push({ agent: t, exit: -1, stderr: String(e).slice(0, 200) });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
log(`🔄 reset_session ${issue_id} (killed=${entries.length}) ${results.map(r => `${r.agent}:${r.exit}`).join(' ')}`);
|
|
399
|
+
return Response.json({ ok: true, killed: entries.length, sessions: results });
|
|
400
|
+
} catch (e) {
|
|
401
|
+
return Response.json({ error: String(e) }, { status: 400 });
|
|
402
|
+
}
|
|
403
|
+
})();
|
|
404
|
+
}
|
|
331
405
|
if (url.pathname === '/stop' && req.method === 'POST') {
|
|
332
406
|
if (req.headers.get('authorization') !== expectedAuth) return new Response('unauthorized', { status: 401 });
|
|
333
407
|
return (async () => {
|
|
334
408
|
try {
|
|
335
409
|
const { issue_id } = await req.json() as { issue_id: string };
|
|
336
410
|
if (!issue_id) return Response.json({ error: 'issue_id required' }, { status: 400 });
|
|
337
|
-
const
|
|
338
|
-
if (!
|
|
411
|
+
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
412
|
+
if (!entries.length) {
|
|
339
413
|
db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
|
|
340
414
|
await markStopped(apiUrl, issue_id, 'stopped before start');
|
|
341
415
|
return Response.json({ ok: true, state: 'queued-cancelled' });
|
|
342
416
|
}
|
|
343
|
-
entry
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
417
|
+
for (const entry of entries) {
|
|
418
|
+
entry.stopped = true;
|
|
419
|
+
entry.stopReason = 'user requested';
|
|
420
|
+
try { entry.child?.kill('SIGTERM'); } catch {}
|
|
421
|
+
setTimeout(() => { try { entry.child?.kill('SIGKILL'); } catch {} }, 5000);
|
|
422
|
+
}
|
|
347
423
|
return Response.json({ ok: true, state: 'running-signalled' });
|
|
348
424
|
} catch (e) {
|
|
349
425
|
return Response.json({ error: String(e) }, { status: 400 });
|
|
@@ -354,6 +430,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
354
430
|
},
|
|
355
431
|
});
|
|
356
432
|
log(`🌐 Local server: http://127.0.0.1:${port}`);
|
|
433
|
+
try { writeFileSync(PORT_PATH, String(port)); } catch {}
|
|
357
434
|
|
|
358
435
|
// Spawn cloudflared quick tunnel
|
|
359
436
|
const cf = Bun.spawn(['cloudflared', 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
|
|
@@ -381,6 +458,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
381
458
|
try { await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'offline', tunnel_url: null }); } catch {}
|
|
382
459
|
if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
|
|
383
460
|
if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
|
|
461
|
+
if (existsSync(PORT_PATH)) unlinkSync(PORT_PATH);
|
|
384
462
|
db.close();
|
|
385
463
|
log('👋 Disconnected');
|
|
386
464
|
process.exit(0);
|
|
@@ -462,6 +540,7 @@ async function parseTunnelUrl(stream: ReadableStream<Uint8Array>): Promise<strin
|
|
|
462
540
|
|
|
463
541
|
interface RunEntry {
|
|
464
542
|
agentId: string;
|
|
543
|
+
issueId: string | null;
|
|
465
544
|
startedAt: number;
|
|
466
545
|
child: any | null;
|
|
467
546
|
worktreePath: string;
|
|
@@ -1377,6 +1456,22 @@ async function cmdStop() {
|
|
|
1377
1456
|
try { process.kill(pid, 'SIGTERM'); console.log(`Sent SIGTERM to ${pid}`); } catch {}
|
|
1378
1457
|
}
|
|
1379
1458
|
|
|
1459
|
+
async function cmdReset(issueId?: string) {
|
|
1460
|
+
if (!issueId) { console.error('Usage: multi-agent reset --issue <issue_id>'); process.exit(2); }
|
|
1461
|
+
if (!existsSync(PORT_PATH)) { console.error('Daemon not running (no port file).'); process.exit(1); }
|
|
1462
|
+
const port = Number(readFileSync(PORT_PATH, 'utf8').trim());
|
|
1463
|
+
const config = loadConfig();
|
|
1464
|
+
if (!config.dispatchSecret) { console.error('No dispatchSecret in config — run `multi-agent setup` first.'); process.exit(1); }
|
|
1465
|
+
const res = await fetch(`http://127.0.0.1:${port}/reset_session`, {
|
|
1466
|
+
method: 'POST',
|
|
1467
|
+
headers: { 'content-type': 'application/json', 'authorization': `Bearer ${config.dispatchSecret}` },
|
|
1468
|
+
body: JSON.stringify({ issue_id: issueId }),
|
|
1469
|
+
});
|
|
1470
|
+
const body = await res.text();
|
|
1471
|
+
if (!res.ok) { console.error(`Reset failed (${res.status}): ${body}`); process.exit(1); }
|
|
1472
|
+
console.log(body);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1380
1475
|
async function cmdRestart(apiUrl: string) {
|
|
1381
1476
|
if (existsSync(PID_PATH)) {
|
|
1382
1477
|
const pid = Number(readFileSync(PID_PATH, 'utf8').trim());
|
|
@@ -1458,6 +1553,11 @@ function openTasksDb(): Database {
|
|
|
1458
1553
|
`);
|
|
1459
1554
|
// Old rows used 'pending'; normalize to 'queued'.
|
|
1460
1555
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'pending'");
|
|
1556
|
+
// Backfill agent_id/issue_id from payload JSON for rows predating the columns.
|
|
1557
|
+
// Without this, the scheduler cannot serialize per-agent / per-issue and fires
|
|
1558
|
+
// concurrent dispatches for the same agent on the same issue.
|
|
1559
|
+
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)");
|
|
1560
|
+
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
1561
|
return db;
|
|
1462
1562
|
}
|
|
1463
1563
|
|