@shipers-dev/multi 0.9.1 → 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 +99 -29
- package/package.json +1 -1
- package/src/acp-runner.ts +18 -4
- package/src/index.ts +76 -24
- package/.claude/settings.local.json +0 -11
package/dist/index.js
CHANGED
|
@@ -5169,6 +5169,25 @@ class RequestError extends Error {
|
|
|
5169
5169
|
// src/acp-runner.ts
|
|
5170
5170
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
5171
5171
|
import { dirname } from "path";
|
|
5172
|
+
function fmtErr(e) {
|
|
5173
|
+
if (e == null)
|
|
5174
|
+
return "unknown error";
|
|
5175
|
+
if (typeof e === "string")
|
|
5176
|
+
return e;
|
|
5177
|
+
if (e instanceof Error)
|
|
5178
|
+
return e.message;
|
|
5179
|
+
if (typeof e === "object") {
|
|
5180
|
+
const inner = e.error ?? e.cause ?? e;
|
|
5181
|
+
const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
|
|
5182
|
+
const code = inner?.code ?? e.code ?? e.status;
|
|
5183
|
+
if (msg)
|
|
5184
|
+
return code ? `${msg} (code ${code})` : String(msg);
|
|
5185
|
+
try {
|
|
5186
|
+
return JSON.stringify(e).slice(0, 500);
|
|
5187
|
+
} catch {}
|
|
5188
|
+
}
|
|
5189
|
+
return String(e);
|
|
5190
|
+
}
|
|
5172
5191
|
async function runAcp(opts) {
|
|
5173
5192
|
const cleanEnv = {};
|
|
5174
5193
|
for (const [k, v] of Object.entries(process.env)) {
|
|
@@ -5221,7 +5240,7 @@ async function runAcp(opts) {
|
|
|
5221
5240
|
`) : content;
|
|
5222
5241
|
return { content: sliced };
|
|
5223
5242
|
} catch (e) {
|
|
5224
|
-
throw new Error(`readTextFile failed: ${
|
|
5243
|
+
throw new Error(`readTextFile failed: ${fmtErr(e)}`);
|
|
5225
5244
|
}
|
|
5226
5245
|
},
|
|
5227
5246
|
async writeTextFile(params) {
|
|
@@ -5232,7 +5251,7 @@ async function runAcp(opts) {
|
|
|
5232
5251
|
writeFileSync(params.path, params.content, "utf8");
|
|
5233
5252
|
return {};
|
|
5234
5253
|
} catch (e) {
|
|
5235
|
-
throw new Error(`writeTextFile failed: ${
|
|
5254
|
+
throw new Error(`writeTextFile failed: ${fmtErr(e)}`);
|
|
5236
5255
|
}
|
|
5237
5256
|
}
|
|
5238
5257
|
};
|
|
@@ -5259,7 +5278,7 @@ async function runAcp(opts) {
|
|
|
5259
5278
|
await conn.loadSession?.({ sessionId: activeSessionId, cwd: opts.cwd || process.cwd(), mcpServers: [] });
|
|
5260
5279
|
await opts.onEvent({ event_type: "progress", payload: { message: `ACP session ${activeSessionId.slice(0, 8)} resumed` } });
|
|
5261
5280
|
} catch (e) {
|
|
5262
|
-
await opts.onEvent({ event_type: "progress", payload: { message: `load_session failed; starting new. ${
|
|
5281
|
+
await opts.onEvent({ event_type: "progress", payload: { message: `load_session failed; starting new. ${fmtErr(e)}` } });
|
|
5263
5282
|
const { sessionId } = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] });
|
|
5264
5283
|
activeSessionId = sessionId;
|
|
5265
5284
|
if (opts.onSession)
|
|
@@ -5286,7 +5305,7 @@ async function runAcp(opts) {
|
|
|
5286
5305
|
res = await runPrompt();
|
|
5287
5306
|
stopReason = res.stopReason;
|
|
5288
5307
|
} catch (e) {
|
|
5289
|
-
await opts.onEvent({ event_type: "error", payload: { message: `retry with fresh session failed: ${
|
|
5308
|
+
await opts.onEvent({ event_type: "error", payload: { message: `retry with fresh session failed: ${fmtErr(e)}` } });
|
|
5290
5309
|
}
|
|
5291
5310
|
}
|
|
5292
5311
|
if (chunkCount === 0) {
|
|
@@ -5689,7 +5708,7 @@ var LOG_PATH2 = join3(MULTI_DIR, "logs", "agent.log");
|
|
|
5689
5708
|
var SKILLS_DIR = join3(MULTI_DIR, "skills");
|
|
5690
5709
|
var STOP_PATH = join3(MULTI_DIR, "stop.flag");
|
|
5691
5710
|
var TASKS_DB_PATH = join3(MULTI_DIR, "tasks.db");
|
|
5692
|
-
var VERSION = "0.9.
|
|
5711
|
+
var VERSION = "0.9.2";
|
|
5693
5712
|
var COMMANDS = {
|
|
5694
5713
|
setup: "Register this device with a workspace",
|
|
5695
5714
|
connect: "Connect device to realtime hub and execute assigned tasks",
|
|
@@ -5928,21 +5947,48 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5928
5947
|
db.run("UPDATE tasks SET status = 'queued' WHERE status = 'running'");
|
|
5929
5948
|
const MAX_DEVICE = Math.max(1, parseInt(process.env.MULTI_MAX_CONCURRENT ?? "3", 10) || 3);
|
|
5930
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
|
+
}
|
|
5931
5962
|
function pickNext() {
|
|
5932
|
-
const busyAgents = Array.from(running.values()).map((e) => e.agentId).filter(
|
|
5933
|
-
const
|
|
5934
|
-
const
|
|
5935
|
-
|
|
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);
|
|
5936
5978
|
}
|
|
5937
5979
|
function schedule() {
|
|
5938
5980
|
while (running.size < MAX_DEVICE) {
|
|
5939
5981
|
const row = pickNext();
|
|
5940
5982
|
if (!row)
|
|
5941
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;
|
|
5942
5989
|
db.run("UPDATE tasks SET status = 'running', started_at = unixepoch(), attempts = attempts + 1 WHERE id = ?", [row.id]);
|
|
5943
|
-
const entry = { agentId:
|
|
5944
|
-
|
|
5945
|
-
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);
|
|
5946
5992
|
(async () => {
|
|
5947
5993
|
try {
|
|
5948
5994
|
const task = JSON.parse(row.payload);
|
|
@@ -5952,7 +5998,7 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5952
5998
|
log(`task ${row.id} error: ${String(e)}`);
|
|
5953
5999
|
db.run("UPDATE tasks SET status = 'failed', finished_at = unixepoch(), error = ? WHERE id = ?", [String(e), row.id]);
|
|
5954
6000
|
} finally {
|
|
5955
|
-
running.delete(
|
|
6001
|
+
running.delete(row.id);
|
|
5956
6002
|
queueMicrotask(() => schedule());
|
|
5957
6003
|
}
|
|
5958
6004
|
})();
|
|
@@ -5995,22 +6041,24 @@ async function cmdConnect(apiUrl, config) {
|
|
|
5995
6041
|
const { issue_id } = await req.json();
|
|
5996
6042
|
if (!issue_id)
|
|
5997
6043
|
return Response.json({ error: "issue_id required" }, { status: 400 });
|
|
5998
|
-
const
|
|
5999
|
-
if (!
|
|
6044
|
+
const entries = Array.from(running.values()).filter((e) => e.issueId === issue_id);
|
|
6045
|
+
if (!entries.length) {
|
|
6000
6046
|
db.run("UPDATE tasks SET status = 'failed', error = 'stopped before start', finished_at = unixepoch() WHERE issue_id = ? AND status = 'queued'", [issue_id]);
|
|
6001
6047
|
await markStopped(apiUrl, issue_id, "stopped before start");
|
|
6002
6048
|
return Response.json({ ok: true, state: "queued-cancelled" });
|
|
6003
6049
|
}
|
|
6004
|
-
entry
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
entry.child?.kill("SIGTERM");
|
|
6008
|
-
} catch {}
|
|
6009
|
-
setTimeout(() => {
|
|
6050
|
+
for (const entry of entries) {
|
|
6051
|
+
entry.stopped = true;
|
|
6052
|
+
entry.stopReason = "user requested";
|
|
6010
6053
|
try {
|
|
6011
|
-
entry.child?.kill("
|
|
6054
|
+
entry.child?.kill("SIGTERM");
|
|
6012
6055
|
} catch {}
|
|
6013
|
-
|
|
6056
|
+
setTimeout(() => {
|
|
6057
|
+
try {
|
|
6058
|
+
entry.child?.kill("SIGKILL");
|
|
6059
|
+
} catch {}
|
|
6060
|
+
}, 5000);
|
|
6061
|
+
}
|
|
6014
6062
|
return Response.json({ ok: true, state: "running-signalled" });
|
|
6015
6063
|
} catch (e) {
|
|
6016
6064
|
return Response.json({ error: String(e) }, { status: 400 });
|
|
@@ -6164,7 +6212,7 @@ async function handleRunTask(apiUrl, deviceId, task, detected, ctx) {
|
|
|
6164
6212
|
worktreeBranch = wt.branch;
|
|
6165
6213
|
await postStream(apiUrl, issueId, "worktree_created", { path: wt.path, branch: wt.branch, reused: !wt.created });
|
|
6166
6214
|
} catch (e) {
|
|
6167
|
-
await postStream(apiUrl, issueId, "worktree_error", { message:
|
|
6215
|
+
await postStream(apiUrl, issueId, "worktree_error", { message: fmtError(e) });
|
|
6168
6216
|
}
|
|
6169
6217
|
}
|
|
6170
6218
|
log(`\u25B6 run_task ${task.key}: ${isFollowup ? "(follow-up) " : ""}${task.title}${workingDir ? ` [cwd: ${workingDir}${worktreeBranch ? ` @${worktreeBranch}` : ""}]` : ""}`);
|
|
@@ -6633,14 +6681,15 @@ ${userPart}` : userPart;
|
|
|
6633
6681
|
log(` \u2713 ${task.key} complete`);
|
|
6634
6682
|
}
|
|
6635
6683
|
} catch (e) {
|
|
6684
|
+
const msg = fmtError(e);
|
|
6636
6685
|
if (ctx?.runEntry?.stopped) {
|
|
6637
6686
|
await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || "stopped");
|
|
6638
|
-
log(` \u23F9 ${task.key} stopped (${
|
|
6687
|
+
log(` \u23F9 ${task.key} stopped (${msg})`);
|
|
6639
6688
|
} else {
|
|
6640
|
-
await postStream(apiUrl, issueId, "error", { message:
|
|
6641
|
-
await postComment(`\u274C spawn error: ${
|
|
6689
|
+
await postStream(apiUrl, issueId, "error", { message: msg });
|
|
6690
|
+
await postComment(`\u274C spawn error: ${msg}`);
|
|
6642
6691
|
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
6643
|
-
log(` \u2717 ${task.key} failed: ${
|
|
6692
|
+
log(` \u2717 ${task.key} failed: ${msg}`);
|
|
6644
6693
|
}
|
|
6645
6694
|
}
|
|
6646
6695
|
}
|
|
@@ -6802,6 +6851,25 @@ function stripMd(s) {
|
|
|
6802
6851
|
function stripSelfMention(prompt, _agentType) {
|
|
6803
6852
|
return prompt.replace(/^@[A-Za-z0-9_\-]+\s*/, "").trim();
|
|
6804
6853
|
}
|
|
6854
|
+
function fmtError(e) {
|
|
6855
|
+
if (e == null)
|
|
6856
|
+
return "unknown error";
|
|
6857
|
+
if (typeof e === "string")
|
|
6858
|
+
return e;
|
|
6859
|
+
if (e instanceof Error)
|
|
6860
|
+
return e.stack ? `${e.message}` : String(e);
|
|
6861
|
+
if (typeof e === "object") {
|
|
6862
|
+
const inner = e.error ?? e.cause ?? e;
|
|
6863
|
+
const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
|
|
6864
|
+
const code = inner?.code ?? e.code ?? e.status;
|
|
6865
|
+
if (msg)
|
|
6866
|
+
return code ? `${msg} (code ${code})` : String(msg);
|
|
6867
|
+
try {
|
|
6868
|
+
return JSON.stringify(e).slice(0, 500);
|
|
6869
|
+
} catch {}
|
|
6870
|
+
}
|
|
6871
|
+
return String(e);
|
|
6872
|
+
}
|
|
6805
6873
|
function statusIcon(status) {
|
|
6806
6874
|
switch (status) {
|
|
6807
6875
|
case "pending":
|
|
@@ -6956,7 +7024,7 @@ Context (original task ${task.key}): ${task.title}` : base || task.title;
|
|
|
6956
7024
|
try {
|
|
6957
7025
|
proc = Bun.spawn([agent.path, ...args], { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd: task?.working_dir || undefined });
|
|
6958
7026
|
} catch (e) {
|
|
6959
|
-
yield { event_type: "error", payload: { message: `spawn failed: ${
|
|
7027
|
+
yield { event_type: "error", payload: { message: `spawn failed: ${fmtError(e)}` } };
|
|
6960
7028
|
return;
|
|
6961
7029
|
}
|
|
6962
7030
|
const queue = [];
|
|
@@ -7249,6 +7317,8 @@ function openTasksDb() {
|
|
|
7249
7317
|
CREATE INDEX IF NOT EXISTS idx_tasks_issue ON tasks(issue_id);
|
|
7250
7318
|
`);
|
|
7251
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)");
|
|
7252
7322
|
return db;
|
|
7253
7323
|
}
|
|
7254
7324
|
function loadConfig() {
|
package/package.json
CHANGED
package/src/acp-runner.ts
CHANGED
|
@@ -8,6 +8,20 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
|
8
8
|
import { dirname } from 'path';
|
|
9
9
|
import { apiClient } from './client';
|
|
10
10
|
|
|
11
|
+
function fmtErr(e: any): string {
|
|
12
|
+
if (e == null) return 'unknown error';
|
|
13
|
+
if (typeof e === 'string') return e;
|
|
14
|
+
if (e instanceof Error) return e.message;
|
|
15
|
+
if (typeof e === 'object') {
|
|
16
|
+
const inner = e.error ?? e.cause ?? e;
|
|
17
|
+
const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
|
|
18
|
+
const code = inner?.code ?? e.code ?? e.status;
|
|
19
|
+
if (msg) return code ? `${msg} (code ${code})` : String(msg);
|
|
20
|
+
try { return JSON.stringify(e).slice(0, 500); } catch {}
|
|
21
|
+
}
|
|
22
|
+
return String(e);
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export type AcpEvent =
|
|
12
26
|
| { event_type: 'progress'; payload: any }
|
|
13
27
|
| { event_type: 'assistant_text'; payload: { text: string } }
|
|
@@ -79,7 +93,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
|
|
|
79
93
|
: content;
|
|
80
94
|
return { content: sliced };
|
|
81
95
|
} catch (e) {
|
|
82
|
-
throw new Error(`readTextFile failed: ${
|
|
96
|
+
throw new Error(`readTextFile failed: ${fmtErr(e)}`);
|
|
83
97
|
}
|
|
84
98
|
},
|
|
85
99
|
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
|
@@ -89,7 +103,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
|
|
|
89
103
|
writeFileSync(params.path, params.content, 'utf8');
|
|
90
104
|
return {};
|
|
91
105
|
} catch (e) {
|
|
92
|
-
throw new Error(`writeTextFile failed: ${
|
|
106
|
+
throw new Error(`writeTextFile failed: ${fmtErr(e)}`);
|
|
93
107
|
}
|
|
94
108
|
},
|
|
95
109
|
};
|
|
@@ -118,7 +132,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
|
|
|
118
132
|
await conn.loadSession?.({ sessionId: activeSessionId, cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
|
|
119
133
|
await opts.onEvent({ event_type: 'progress', payload: { message: `ACP session ${activeSessionId.slice(0, 8)} resumed` } });
|
|
120
134
|
} catch (e) {
|
|
121
|
-
await opts.onEvent({ event_type: 'progress', payload: { message: `load_session failed; starting new. ${
|
|
135
|
+
await opts.onEvent({ event_type: 'progress', payload: { message: `load_session failed; starting new. ${fmtErr(e)}` } });
|
|
122
136
|
const { sessionId } = await conn.newSession({ cwd: opts.cwd || process.cwd(), mcpServers: [] } as any);
|
|
123
137
|
activeSessionId = sessionId;
|
|
124
138
|
if (opts.onSession) await opts.onSession(sessionId);
|
|
@@ -148,7 +162,7 @@ export async function runAcp(opts: AcpRunOpts): Promise<{ stopReason: string; se
|
|
|
148
162
|
res = await runPrompt();
|
|
149
163
|
stopReason = (res as any).stopReason;
|
|
150
164
|
} catch (e) {
|
|
151
|
-
await opts.onEvent({ event_type: 'error', payload: { message: `retry with fresh session failed: ${
|
|
165
|
+
await opts.onEvent({ event_type: 'error', payload: { message: `retry with fresh session failed: ${fmtErr(e)}` } });
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
168
|
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ const LOG_PATH = join(MULTI_DIR, 'logs', 'agent.log');
|
|
|
18
18
|
const SKILLS_DIR = join(MULTI_DIR, 'skills');
|
|
19
19
|
const STOP_PATH = join(MULTI_DIR, 'stop.flag');
|
|
20
20
|
const TASKS_DB_PATH = join(MULTI_DIR, 'tasks.db');
|
|
21
|
-
const VERSION = '0.9.
|
|
21
|
+
const VERSION = '0.9.2';
|
|
22
22
|
|
|
23
23
|
const COMMANDS = {
|
|
24
24
|
setup: 'Register this device with a workspace',
|
|
@@ -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;
|
|
@@ -501,7 +530,7 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
501
530
|
worktreeBranch = wt.branch;
|
|
502
531
|
await postStream(apiUrl, issueId, 'worktree_created', { path: wt.path, branch: wt.branch, reused: !wt.created });
|
|
503
532
|
} catch (e) {
|
|
504
|
-
await postStream(apiUrl, issueId, 'worktree_error', { message:
|
|
533
|
+
await postStream(apiUrl, issueId, 'worktree_error', { message: fmtError(e) });
|
|
505
534
|
}
|
|
506
535
|
}
|
|
507
536
|
|
|
@@ -875,14 +904,15 @@ async function handleRunTask(apiUrl: string, deviceId: string, task: any, detect
|
|
|
875
904
|
} else if (hadError) { await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {}); log(` ✗ ${task.key} failed`); }
|
|
876
905
|
else { await apiClient.post(`${apiUrl}/api/issues/${issueId}/complete`, {}); log(` ✓ ${task.key} complete`); }
|
|
877
906
|
} catch (e) {
|
|
907
|
+
const msg = fmtError(e);
|
|
878
908
|
if (ctx?.runEntry?.stopped) {
|
|
879
909
|
await markStopped(apiUrl, issueId, ctx.runEntry.stopReason || 'stopped');
|
|
880
|
-
log(` ⏹ ${task.key} stopped (${
|
|
910
|
+
log(` ⏹ ${task.key} stopped (${msg})`);
|
|
881
911
|
} else {
|
|
882
|
-
await postStream(apiUrl, issueId, 'error', { message:
|
|
883
|
-
await postComment(`❌ spawn error: ${
|
|
912
|
+
await postStream(apiUrl, issueId, 'error', { message: msg });
|
|
913
|
+
await postComment(`❌ spawn error: ${msg}`);
|
|
884
914
|
await apiClient.post(`${apiUrl}/api/issues/${issueId}/fail`, {});
|
|
885
|
-
log(` ✗ ${task.key} failed: ${
|
|
915
|
+
log(` ✗ ${task.key} failed: ${msg}`);
|
|
886
916
|
}
|
|
887
917
|
}
|
|
888
918
|
}
|
|
@@ -1040,6 +1070,23 @@ function stripSelfMention(prompt: string, _agentType?: string): string {
|
|
|
1040
1070
|
return prompt.replace(/^@[A-Za-z0-9_\-]+\s*/, '').trim();
|
|
1041
1071
|
}
|
|
1042
1072
|
|
|
1073
|
+
// Best-effort error → string. Plain Error, ACP error envelopes ({error:{message,code,data}}),
|
|
1074
|
+
// fetch Response-shaped rejections, and arbitrary objects all stringify to something readable
|
|
1075
|
+
// instead of "[object Object]".
|
|
1076
|
+
function fmtError(e: any): string {
|
|
1077
|
+
if (e == null) return 'unknown error';
|
|
1078
|
+
if (typeof e === 'string') return e;
|
|
1079
|
+
if (e instanceof Error) return e.stack ? `${e.message}` : String(e);
|
|
1080
|
+
if (typeof e === 'object') {
|
|
1081
|
+
const inner = e.error ?? e.cause ?? e;
|
|
1082
|
+
const msg = inner?.message ?? e.message ?? e.reason ?? e.statusText;
|
|
1083
|
+
const code = inner?.code ?? e.code ?? e.status;
|
|
1084
|
+
if (msg) return code ? `${msg} (code ${code})` : String(msg);
|
|
1085
|
+
try { return JSON.stringify(e).slice(0, 500); } catch {}
|
|
1086
|
+
}
|
|
1087
|
+
return String(e);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1043
1090
|
function statusIcon(status?: string): string {
|
|
1044
1091
|
switch (status) {
|
|
1045
1092
|
case 'pending': return '⏳';
|
|
@@ -1183,7 +1230,7 @@ function makeCliRunner(agent: { type: string; path: string }): Runner {
|
|
|
1183
1230
|
try {
|
|
1184
1231
|
proc = Bun.spawn([agent.path, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', cwd: (task as any)?.working_dir || undefined });
|
|
1185
1232
|
} catch (e) {
|
|
1186
|
-
yield { event_type: 'error', payload: { message: `spawn failed: ${
|
|
1233
|
+
yield { event_type: 'error', payload: { message: `spawn failed: ${fmtError(e)}` } };
|
|
1187
1234
|
return;
|
|
1188
1235
|
}
|
|
1189
1236
|
|
|
@@ -1440,6 +1487,11 @@ function openTasksDb(): Database {
|
|
|
1440
1487
|
`);
|
|
1441
1488
|
// Old rows used 'pending'; normalize to 'queued'.
|
|
1442
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)");
|
|
1443
1495
|
return db;
|
|
1444
1496
|
}
|
|
1445
1497
|
|