heyio 0.30.1 → 0.32.0
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/api/server.js +85 -9
- package/dist/copilot/agents.js +41 -0
- package/dist/copilot/auto-complete-instance.test.js +104 -0
- package/dist/copilot/instance-deactivate.test.js +119 -0
- package/dist/copilot/orchestrator.js +35 -1
- package/dist/copilot/scheduler.js +1 -1
- package/dist/copilot/tools.js +140 -2
- package/dist/instance-watchdog.js +43 -9
- package/dist/instance-watchdog.test.js +77 -6
- package/dist/mcp/client.js +109 -0
- package/dist/mcp/client.test.js +99 -0
- package/dist/mcp/config.js +29 -0
- package/dist/mcp/config.test.js +49 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/registry.js +96 -0
- package/dist/mcp/registry.test.js +79 -0
- package/dist/store/db.js +21 -1
- package/dist/store/feed.js +3 -3
- package/dist/store/feed.test.js +20 -20
- package/dist/store/squads.js +1 -1
- package/dist/store/squads.test.js +353 -0
- package/dist/tui/index.js +2 -2
- package/package.json +4 -3
- package/web-dist/assets/index-Ddn6rUkk.js +88 -0
- package/web-dist/assets/index-KNbOV6QX.css +10 -0
- package/web-dist/index.html +2 -2
- package/dist/store/inbox.js +0 -28
- package/web-dist/assets/index-BvKvht8h.js +0 -88
- package/web-dist/assets/index-DmthMbtN.css +0 -10
package/dist/copilot/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join, dirname, resolve } from "path";
|
|
|
6
6
|
import { homedir } from "os";
|
|
7
7
|
import { UNIVERSES, getOrCreateUniverse, generateUniverseRoster } from "./universes.js";
|
|
8
8
|
import { createFeedEntry } from "../store/feed.js";
|
|
9
|
+
import { loadMcpConfig, saveMcpConfig } from "../mcp/config.js";
|
|
9
10
|
import { validateCron, nextRun } from "./cron.js";
|
|
10
11
|
import { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
|
|
11
12
|
import { runIoScheduleNow } from "./io-scheduler.js";
|
|
@@ -407,7 +408,7 @@ export function createTools(deps) {
|
|
|
407
408
|
const taskId = await deps.delegateToAgent(slug, task, (id, result) => {
|
|
408
409
|
console.error(`[io] Agent task ${id} completed for squad ${slug}`);
|
|
409
410
|
if (shouldRouteToInbox(task)) {
|
|
410
|
-
createFeedEntry({ type: "
|
|
411
|
+
createFeedEntry({ type: "inbox", title: `[${slug}] Task result`, body: result, squad_slug: slug });
|
|
411
412
|
console.error(`[io] Task ${id} result routed to inbox`);
|
|
412
413
|
}
|
|
413
414
|
}, agent, deps.activeInstanceId);
|
|
@@ -1811,6 +1812,10 @@ export function createTools(deps) {
|
|
|
1811
1812
|
const projectPath = squad?.projectPath ?? instance.worktree_path.replace(/\/\.io-worktrees\/.*$/, "");
|
|
1812
1813
|
deps.removeWorktree(projectPath, instance.worktree_path);
|
|
1813
1814
|
deps.updateInstanceStatus(instance_id, "done");
|
|
1815
|
+
// Auto-deactivate if this was the active instance
|
|
1816
|
+
if (deps.activeInstanceId === instance_id) {
|
|
1817
|
+
deps.activeInstanceId = undefined;
|
|
1818
|
+
}
|
|
1814
1819
|
return `Instance "${instance_id}" completed.\n- ${merged} decision(s) merged to master squad "${instance.master_squad_slug}"\n- Worktree cleaned up`;
|
|
1815
1820
|
}
|
|
1816
1821
|
catch (err) {
|
|
@@ -1832,6 +1837,10 @@ export function createTools(deps) {
|
|
|
1832
1837
|
return `Instance already in terminal state: ${instance.status}`;
|
|
1833
1838
|
}
|
|
1834
1839
|
deps.updateInstanceStatus(instance_id, "failed");
|
|
1840
|
+
// Auto-deactivate if this was the active instance
|
|
1841
|
+
if (deps.activeInstanceId === instance_id) {
|
|
1842
|
+
deps.activeInstanceId = undefined;
|
|
1843
|
+
}
|
|
1835
1844
|
return `Instance "${instance_id}" aborted. Worktree preserved at: ${instance.worktree_path}\nUse squad_instance_cleanup to remove it.`;
|
|
1836
1845
|
},
|
|
1837
1846
|
});
|
|
@@ -1885,7 +1894,136 @@ export function createTools(deps) {
|
|
|
1885
1894
|
return prev ? `Instance context deactivated (was: ${prev})` : `No instance context was active.`;
|
|
1886
1895
|
},
|
|
1887
1896
|
});
|
|
1888
|
-
|
|
1897
|
+
const sendToInbox = defineTool("send_to_inbox", {
|
|
1898
|
+
description: "Send a message directly to Michael's IO inbox. Use this to deliver results, reports, summaries, or any content that should appear in the inbox feed.",
|
|
1899
|
+
skipPermission: true,
|
|
1900
|
+
parameters: z.object({
|
|
1901
|
+
title: z.string().describe("Short title for the inbox item"),
|
|
1902
|
+
body: z.string().describe("Full content/body of the message (supports markdown)"),
|
|
1903
|
+
squad_slug: z.string().optional().describe("Squad slug to prefix the title with (e.g. 'io-assistant')"),
|
|
1904
|
+
instance_id: z.string().optional().describe("Instance ID if this message is from a squad instance"),
|
|
1905
|
+
task_id: z.string().optional().describe("Task ID associated with this message"),
|
|
1906
|
+
}),
|
|
1907
|
+
handler: async ({ title, body, squad_slug, instance_id, task_id }) => {
|
|
1908
|
+
const prefix = squad_slug ? `[${squad_slug}] ` : "";
|
|
1909
|
+
createFeedEntry({
|
|
1910
|
+
type: "inbox",
|
|
1911
|
+
title: `${prefix}${title}`,
|
|
1912
|
+
body,
|
|
1913
|
+
squad_slug: squad_slug ?? null,
|
|
1914
|
+
instance_id: instance_id ?? null,
|
|
1915
|
+
task_id: task_id ?? null,
|
|
1916
|
+
});
|
|
1917
|
+
return "Message sent to inbox successfully.";
|
|
1918
|
+
},
|
|
1919
|
+
});
|
|
1920
|
+
const sendNotification = defineTool("send_notification", {
|
|
1921
|
+
description: "Send a short status notification to the IO feed. Use for brief updates, alerts, and FYIs (one sentence). For longer content, use send_to_inbox instead.",
|
|
1922
|
+
skipPermission: true,
|
|
1923
|
+
parameters: z.object({
|
|
1924
|
+
message: z.string().describe("Short notification message (one sentence)"),
|
|
1925
|
+
squad_slug: z.string().optional().describe("Squad slug for context"),
|
|
1926
|
+
}),
|
|
1927
|
+
handler: async ({ message, squad_slug }) => {
|
|
1928
|
+
const prefix = squad_slug ? `[${squad_slug}] ` : "";
|
|
1929
|
+
createFeedEntry({
|
|
1930
|
+
type: "notification",
|
|
1931
|
+
title: `${prefix}${message}`,
|
|
1932
|
+
body: message,
|
|
1933
|
+
squad_slug: squad_slug ?? null,
|
|
1934
|
+
});
|
|
1935
|
+
return "Notification sent.";
|
|
1936
|
+
},
|
|
1937
|
+
});
|
|
1938
|
+
const mcpServerList = defineTool("mcp_server_list", {
|
|
1939
|
+
description: "List all configured MCP servers with their status (enabled/disabled, connected/disconnected).",
|
|
1940
|
+
skipPermission: true,
|
|
1941
|
+
parameters: z.object({}),
|
|
1942
|
+
handler: async () => {
|
|
1943
|
+
const config = loadMcpConfig();
|
|
1944
|
+
if (config.servers.length === 0)
|
|
1945
|
+
return "No MCP servers configured. Add one with mcp_server_add.";
|
|
1946
|
+
return config.servers.map(s => {
|
|
1947
|
+
const status = s.enabled === false ? "disabled" : "enabled";
|
|
1948
|
+
const transport = s.url ? `SSE: ${s.url}` : `stdio: ${s.command} ${(s.args ?? []).join(" ")}`;
|
|
1949
|
+
return `- **${s.name}** [${status}] — ${transport}`;
|
|
1950
|
+
}).join("\n");
|
|
1951
|
+
},
|
|
1952
|
+
});
|
|
1953
|
+
const mcpServerAdd = defineTool("mcp_server_add", {
|
|
1954
|
+
description: "Add a new MCP server to the configuration. Provide either command+args (stdio transport) or url (SSE transport).",
|
|
1955
|
+
skipPermission: true,
|
|
1956
|
+
parameters: z.object({
|
|
1957
|
+
name: z.string().describe("Unique name for the server (e.g., 'figma', 'postgres')"),
|
|
1958
|
+
command: z.string().optional().describe("Executable command for stdio transport (e.g., 'npx')"),
|
|
1959
|
+
args: z.array(z.string()).optional().describe("Command arguments (e.g., ['-y', '@anthropic/mcp-server-figma'])"),
|
|
1960
|
+
url: z.string().optional().describe("URL for SSE transport (e.g., 'http://localhost:3001/sse')"),
|
|
1961
|
+
env: z.record(z.string(), z.string()).optional().describe("Environment variables for the server process"),
|
|
1962
|
+
}),
|
|
1963
|
+
handler: async ({ name, command, args, url, env }) => {
|
|
1964
|
+
if (!command && !url)
|
|
1965
|
+
return "Error: provide either 'command' (stdio) or 'url' (SSE).";
|
|
1966
|
+
const config = loadMcpConfig();
|
|
1967
|
+
if (config.servers.some(s => s.name === name)) {
|
|
1968
|
+
return `Error: server "${name}" already exists. Remove it first with mcp_server_remove.`;
|
|
1969
|
+
}
|
|
1970
|
+
config.servers.push({ name, command, args, url, env, enabled: true });
|
|
1971
|
+
saveMcpConfig(config);
|
|
1972
|
+
return `MCP server "${name}" added. Use mcp_server_reload to connect.`;
|
|
1973
|
+
},
|
|
1974
|
+
});
|
|
1975
|
+
const mcpServerRemove = defineTool("mcp_server_remove", {
|
|
1976
|
+
description: "Remove an MCP server from the configuration by name.",
|
|
1977
|
+
skipPermission: true,
|
|
1978
|
+
parameters: z.object({
|
|
1979
|
+
name: z.string().describe("Name of the server to remove"),
|
|
1980
|
+
}),
|
|
1981
|
+
handler: async ({ name }) => {
|
|
1982
|
+
const config = loadMcpConfig();
|
|
1983
|
+
const idx = config.servers.findIndex(s => s.name === name);
|
|
1984
|
+
if (idx === -1)
|
|
1985
|
+
return `Server "${name}" not found.`;
|
|
1986
|
+
config.servers.splice(idx, 1);
|
|
1987
|
+
saveMcpConfig(config);
|
|
1988
|
+
return `MCP server "${name}" removed. Use mcp_server_reload to apply.`;
|
|
1989
|
+
},
|
|
1990
|
+
});
|
|
1991
|
+
const mcpServerToggle = defineTool("mcp_server_toggle", {
|
|
1992
|
+
description: "Enable or disable an MCP server without removing it from the config.",
|
|
1993
|
+
skipPermission: true,
|
|
1994
|
+
parameters: z.object({
|
|
1995
|
+
name: z.string().describe("Name of the server to toggle"),
|
|
1996
|
+
enabled: z.boolean().describe("true to enable, false to disable"),
|
|
1997
|
+
}),
|
|
1998
|
+
handler: async ({ name, enabled }) => {
|
|
1999
|
+
const config = loadMcpConfig();
|
|
2000
|
+
const server = config.servers.find(s => s.name === name);
|
|
2001
|
+
if (!server)
|
|
2002
|
+
return `Server "${name}" not found.`;
|
|
2003
|
+
server.enabled = enabled;
|
|
2004
|
+
saveMcpConfig(config);
|
|
2005
|
+
return `MCP server "${name}" ${enabled ? "enabled" : "disabled"}. Use mcp_server_reload to apply changes.`;
|
|
2006
|
+
},
|
|
2007
|
+
});
|
|
2008
|
+
const mcpServerReload = defineTool("mcp_server_reload", {
|
|
2009
|
+
description: "Reload MCP server connections and tools. Call after adding, removing, or toggling servers to apply changes without restarting IO.",
|
|
2010
|
+
skipPermission: true,
|
|
2011
|
+
parameters: z.object({}),
|
|
2012
|
+
handler: async () => {
|
|
2013
|
+
if (!deps.reloadMcpTools)
|
|
2014
|
+
return "MCP reload not available in this context.";
|
|
2015
|
+
try {
|
|
2016
|
+
await deps.reloadMcpTools();
|
|
2017
|
+
const config = loadMcpConfig();
|
|
2018
|
+
const enabled = config.servers.filter(s => s.enabled !== false);
|
|
2019
|
+
return `MCP tools reloaded. ${enabled.length} server(s) active.`;
|
|
2020
|
+
}
|
|
2021
|
+
catch (err) {
|
|
2022
|
+
return `Error reloading MCP tools: ${err instanceof Error ? err.message : String(err)}`;
|
|
2023
|
+
}
|
|
2024
|
+
},
|
|
2025
|
+
});
|
|
2026
|
+
return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadResetAgent, squadSetLead, squadSetQA, squadTaskReviews, squadScheduleCreate, squadScheduleList, squadScheduleDelete, squadSchedulePause, squadScheduleResume, squadScheduleRunNow, scheduleCreate, scheduleList, scheduleDelete, schedulePause, scheduleResume, scheduleRunNow, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github, squadInstanceCreate, squadInstanceList, squadInstanceStatus, squadInstanceComplete, squadInstanceAbort, squadInstanceCleanup, squadInstanceActivate, squadInstanceDeactivate, sendToInbox, sendNotification, mcpServerList, mcpServerAdd, mcpServerRemove, mcpServerToggle, mcpServerReload];
|
|
1889
2027
|
}
|
|
1890
2028
|
function walkDirectory(dir, maxDepth = 3, depth = 0) {
|
|
1891
2029
|
if (depth >= maxDepth)
|
|
@@ -3,23 +3,55 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Periodically checks for active squad instances that haven't had any
|
|
5
5
|
* task activity beyond a configurable timeout and auto-aborts them.
|
|
6
|
+
* Also detects instances stuck in 'merging' state (#267).
|
|
6
7
|
*/
|
|
7
8
|
import { getDb } from "./store/db.js";
|
|
8
9
|
import { updateInstanceStatus } from "./store/instances.js";
|
|
9
10
|
import { createFeedEntry } from "./store/feed.js";
|
|
10
11
|
const DEFAULT_CHECK_INTERVAL_MS = 5 * 60_000; // Check every 5 minutes
|
|
11
12
|
const DEFAULT_STALE_THRESHOLD_MS = 30 * 60_000; // 30 minutes with no task activity
|
|
13
|
+
const DEFAULT_MERGING_THRESHOLD_MS = 5 * 60_000; // 5 minutes stuck in merging
|
|
12
14
|
/**
|
|
13
|
-
* Find
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* Find instances that are stale or stuck.
|
|
16
|
+
*
|
|
17
|
+
* For 'active' instances: uses task-activity-based staleness (last started_at/completed_at).
|
|
18
|
+
* For 'merging' instances: uses wall-clock since entering merging state (shorter threshold).
|
|
16
19
|
*/
|
|
17
|
-
export function findStaleInstances(thresholdMs) {
|
|
20
|
+
export function findStaleInstances(thresholdMs, mergingThresholdMs = DEFAULT_MERGING_THRESHOLD_MS) {
|
|
18
21
|
const db = getDb();
|
|
19
22
|
const now = Date.now();
|
|
20
|
-
const
|
|
23
|
+
const instances = db.prepare("SELECT * FROM squad_instances WHERE status IN ('active', 'merging')").all();
|
|
21
24
|
const stale = [];
|
|
22
|
-
for (const instance of
|
|
25
|
+
for (const instance of instances) {
|
|
26
|
+
if (instance.status === "merging") {
|
|
27
|
+
// Merging instances: use the time they've been in merging state.
|
|
28
|
+
// We approximate this from completed_at (set when status changes to terminal)
|
|
29
|
+
// or fall back to created_at. Since updateInstanceStatus doesn't set completed_at
|
|
30
|
+
// for non-terminal states, we use a query on the DB's internal timestamp approach.
|
|
31
|
+
// Best proxy: last task completed_at (since merging happens after task completion).
|
|
32
|
+
const lastTaskCompleted = db.prepare(`
|
|
33
|
+
SELECT MAX(completed_at) AS last_ts
|
|
34
|
+
FROM agent_tasks
|
|
35
|
+
WHERE instance_id = ?
|
|
36
|
+
`).get(instance.id);
|
|
37
|
+
const rawTs = lastTaskCompleted?.last_ts ?? instance.created_at;
|
|
38
|
+
const lastTs = new Date(rawTs.includes("T") ? rawTs : rawTs + "Z").getTime();
|
|
39
|
+
const idleMs = now - lastTs;
|
|
40
|
+
if (idleMs >= mergingThresholdMs) {
|
|
41
|
+
stale.push({ instance, idleMs });
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// Active instances: skip those whose most recent task completed successfully —
|
|
46
|
+
// auto-complete in agents.ts handles these (#261)
|
|
47
|
+
const latestTaskStatus = db.prepare(`
|
|
48
|
+
SELECT status FROM agent_tasks
|
|
49
|
+
WHERE instance_id = ?
|
|
50
|
+
ORDER BY COALESCE(completed_at, started_at) DESC
|
|
51
|
+
LIMIT 1
|
|
52
|
+
`).get(instance.id);
|
|
53
|
+
if (latestTaskStatus?.status === "done")
|
|
54
|
+
continue;
|
|
23
55
|
// Find the most recent task activity for this instance
|
|
24
56
|
const lastActivity = db.prepare(`
|
|
25
57
|
SELECT MAX(COALESCE(completed_at, started_at)) AS last_ts
|
|
@@ -41,16 +73,18 @@ export function findStaleInstances(thresholdMs) {
|
|
|
41
73
|
export function startInstanceWatchdog(opts = {}) {
|
|
42
74
|
const checkInterval = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
|
|
43
75
|
const staleThreshold = opts.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
|
|
76
|
+
const mergingThreshold = opts.mergingThresholdMs ?? DEFAULT_MERGING_THRESHOLD_MS;
|
|
44
77
|
const timer = setInterval(() => {
|
|
45
78
|
try {
|
|
46
|
-
const staleInstances = findStaleInstances(staleThreshold);
|
|
79
|
+
const staleInstances = findStaleInstances(staleThreshold, mergingThreshold);
|
|
47
80
|
for (const { instance, idleMs } of staleInstances) {
|
|
48
|
-
|
|
81
|
+
const reason = instance.status === "merging" ? "stuck in merging" : "idle";
|
|
82
|
+
console.error(`[instance-watchdog] Auto-aborting ${reason} instance "${instance.id}" — ${Math.round(idleMs / 60_000)}m (threshold: ${Math.round(instance.status === "merging" ? mergingThreshold / 60_000 : staleThreshold / 60_000)}m)`);
|
|
49
83
|
updateInstanceStatus(instance.id, "failed");
|
|
50
84
|
createFeedEntry({
|
|
51
85
|
type: "notification",
|
|
52
86
|
title: `[${instance.master_squad_slug}] Instance auto-aborted`,
|
|
53
|
-
body: `Instance "${instance.id}" was auto-aborted after ${Math.round(idleMs / 60_000)} minutes
|
|
87
|
+
body: `Instance "${instance.id}" was auto-aborted (${reason}) after ${Math.round(idleMs / 60_000)} minutes. Worktree preserved at: ${instance.worktree_path}`,
|
|
54
88
|
source_type: "instance-watchdog",
|
|
55
89
|
});
|
|
56
90
|
if (opts.onAbort) {
|
|
@@ -24,7 +24,7 @@ beforeEach(() => {
|
|
|
24
24
|
db.prepare("INSERT INTO squads (slug, name, project_path) VALUES (?, ?, ?)").run("test-squad", "Test", "/tmp/test");
|
|
25
25
|
});
|
|
26
26
|
describe("instance watchdog", () => {
|
|
27
|
-
describe("findStaleInstances", () => {
|
|
27
|
+
describe("findStaleInstances — active instances", () => {
|
|
28
28
|
it("detects stale active instances with no task activity", () => {
|
|
29
29
|
const db = getDb();
|
|
30
30
|
db.prepare(`
|
|
@@ -48,7 +48,7 @@ describe("instance watchdog", () => {
|
|
|
48
48
|
const stale = findStaleInstances(30 * 60_000);
|
|
49
49
|
assert.strictEqual(stale.length, 0);
|
|
50
50
|
});
|
|
51
|
-
it("does not flag non-active instances", () => {
|
|
51
|
+
it("does not flag non-active/non-merging instances", () => {
|
|
52
52
|
const db = getDb();
|
|
53
53
|
db.prepare(`
|
|
54
54
|
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
@@ -68,6 +68,80 @@ describe("instance watchdog", () => {
|
|
|
68
68
|
assert.ok(stale[0].idleMs >= 44 * 60_000);
|
|
69
69
|
assert.ok(stale[0].idleMs <= 46 * 60_000);
|
|
70
70
|
});
|
|
71
|
+
it("skips active instances whose latest task status is done (#261)", () => {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
db.prepare(`
|
|
74
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
75
|
+
VALUES (?, ?, ?, ?, 'active', datetime('now', '-60 minutes'))
|
|
76
|
+
`).run("test-squad--task-done", "test-squad", "/tmp/wt7", "test-squad/instance/task-done");
|
|
77
|
+
db.prepare(`
|
|
78
|
+
INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
|
|
79
|
+
VALUES (?, ?, ?, 'done', ?, datetime('now', '-35 minutes'), datetime('now', '-34 minutes'))
|
|
80
|
+
`).run("task-done-1", "agent-1", "Finished work", "test-squad--task-done");
|
|
81
|
+
const stale = findStaleInstances(30 * 60_000);
|
|
82
|
+
assert.strictEqual(stale.length, 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("findStaleInstances — merging instances (#267)", () => {
|
|
86
|
+
it("detects merging instance older than merging threshold", () => {
|
|
87
|
+
const db = getDb();
|
|
88
|
+
db.prepare(`
|
|
89
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
90
|
+
VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
|
|
91
|
+
`).run("test-squad--stuck-merge", "test-squad", "/tmp/wt-merge1", "test-squad/instance/merge1");
|
|
92
|
+
// Task completed 10 minutes ago — merging has been stuck since then
|
|
93
|
+
db.prepare(`
|
|
94
|
+
INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
|
|
95
|
+
VALUES (?, ?, ?, 'done', ?, datetime('now', '-15 minutes'), datetime('now', '-10 minutes'))
|
|
96
|
+
`).run("task-merge-1", "agent-1", "Work done", "test-squad--stuck-merge");
|
|
97
|
+
// 10 min since last task completed > 5 min merging threshold
|
|
98
|
+
const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
|
|
99
|
+
assert.strictEqual(stale.length, 1);
|
|
100
|
+
assert.strictEqual(stale[0].instance.id, "test-squad--stuck-merge");
|
|
101
|
+
assert.strictEqual(stale[0].instance.status, "merging");
|
|
102
|
+
});
|
|
103
|
+
it("does not flag merging instance younger than merging threshold", () => {
|
|
104
|
+
const db = getDb();
|
|
105
|
+
db.prepare(`
|
|
106
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
107
|
+
VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
|
|
108
|
+
`).run("test-squad--fresh-merge", "test-squad", "/tmp/wt-merge2", "test-squad/instance/merge2");
|
|
109
|
+
// Task completed 2 minutes ago — merging just started
|
|
110
|
+
db.prepare(`
|
|
111
|
+
INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
|
|
112
|
+
VALUES (?, ?, ?, 'done', ?, datetime('now', '-5 minutes'), datetime('now', '-2 minutes'))
|
|
113
|
+
`).run("task-merge-2", "agent-1", "Work done", "test-squad--fresh-merge");
|
|
114
|
+
// 2 min since last task completed < 5 min merging threshold
|
|
115
|
+
const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
|
|
116
|
+
assert.strictEqual(stale.length, 0);
|
|
117
|
+
});
|
|
118
|
+
it("uses created_at as fallback when merging instance has no tasks", () => {
|
|
119
|
+
const db = getDb();
|
|
120
|
+
db.prepare(`
|
|
121
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
122
|
+
VALUES (?, ?, ?, ?, 'merging', datetime('now', '-10 minutes'))
|
|
123
|
+
`).run("test-squad--no-tasks-merge", "test-squad", "/tmp/wt-merge3", "test-squad/instance/merge3");
|
|
124
|
+
// No tasks at all — falls back to created_at (10 min ago > 5 min threshold)
|
|
125
|
+
const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
|
|
126
|
+
assert.strictEqual(stale.length, 1);
|
|
127
|
+
assert.strictEqual(stale[0].instance.id, "test-squad--no-tasks-merge");
|
|
128
|
+
});
|
|
129
|
+
it("does not apply done-task skip logic to merging instances", () => {
|
|
130
|
+
const db = getDb();
|
|
131
|
+
db.prepare(`
|
|
132
|
+
INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
|
|
133
|
+
VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
|
|
134
|
+
`).run("test-squad--merging-done-task", "test-squad", "/tmp/wt-merge4", "test-squad/instance/merge4");
|
|
135
|
+
// Latest task is done — but for merging instances this should NOT skip
|
|
136
|
+
db.prepare(`
|
|
137
|
+
INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
|
|
138
|
+
VALUES (?, ?, ?, 'done', ?, datetime('now', '-20 minutes'), datetime('now', '-10 minutes'))
|
|
139
|
+
`).run("task-merge-4", "agent-1", "Done", "test-squad--merging-done-task");
|
|
140
|
+
// 10 min since task completed > 5 min merging threshold — should be detected
|
|
141
|
+
const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
|
|
142
|
+
assert.strictEqual(stale.length, 1);
|
|
143
|
+
assert.strictEqual(stale[0].instance.id, "test-squad--merging-done-task");
|
|
144
|
+
});
|
|
71
145
|
});
|
|
72
146
|
describe("startInstanceWatchdog", () => {
|
|
73
147
|
it("calls onAbort for stale instances and stops cleanly", async () => {
|
|
@@ -82,11 +156,9 @@ describe("instance watchdog", () => {
|
|
|
82
156
|
staleThresholdMs: 30 * 60_000,
|
|
83
157
|
onAbort: (inst) => aborted.push(inst.id),
|
|
84
158
|
});
|
|
85
|
-
// Wait for at least one interval to fire
|
|
86
159
|
await new Promise((r) => setTimeout(r, 150));
|
|
87
160
|
stop();
|
|
88
161
|
assert.ok(aborted.includes("test-squad--stale"));
|
|
89
|
-
// Verify it was marked failed
|
|
90
162
|
const row = db.prepare("SELECT status FROM squad_instances WHERE id = ?").get("test-squad--stale");
|
|
91
163
|
assert.strictEqual(row.status, "failed");
|
|
92
164
|
});
|
|
@@ -102,9 +174,8 @@ describe("instance watchdog", () => {
|
|
|
102
174
|
staleThresholdMs: 30 * 60_000,
|
|
103
175
|
onAbort: () => abortCount++,
|
|
104
176
|
});
|
|
105
|
-
stop();
|
|
177
|
+
stop();
|
|
106
178
|
await new Promise((r) => setTimeout(r, 150));
|
|
107
|
-
// Instance should NOT have been aborted since we stopped before first tick
|
|
108
179
|
assert.strictEqual(abortCount, 0);
|
|
109
180
|
});
|
|
110
181
|
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
|
+
export class McpConnectionManager {
|
|
5
|
+
connections = new Map();
|
|
6
|
+
connecting = new Map();
|
|
7
|
+
async getClient(config) {
|
|
8
|
+
const existing = this.connections.get(config.name);
|
|
9
|
+
if (existing)
|
|
10
|
+
return existing;
|
|
11
|
+
// Deduplicate concurrent connection attempts
|
|
12
|
+
const pending = this.connecting.get(config.name);
|
|
13
|
+
if (pending)
|
|
14
|
+
return pending;
|
|
15
|
+
const promise = this.connect(config);
|
|
16
|
+
this.connecting.set(config.name, promise);
|
|
17
|
+
try {
|
|
18
|
+
const client = await promise;
|
|
19
|
+
this.connections.set(config.name, client);
|
|
20
|
+
return client;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
this.connecting.delete(config.name);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async connect(config) {
|
|
27
|
+
const client = new Client({ name: "io-assistant", version: "1.0.0" }, { capabilities: {} });
|
|
28
|
+
let transport;
|
|
29
|
+
if (config.url) {
|
|
30
|
+
transport = new SSEClientTransport(new URL(config.url));
|
|
31
|
+
}
|
|
32
|
+
else if (config.command) {
|
|
33
|
+
transport = new StdioClientTransport({
|
|
34
|
+
command: config.command,
|
|
35
|
+
args: config.args,
|
|
36
|
+
env: config.env,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
throw new Error(`MCP server "${config.name}" has no command or url configured`);
|
|
41
|
+
}
|
|
42
|
+
await client.connect(transport);
|
|
43
|
+
return client;
|
|
44
|
+
}
|
|
45
|
+
async listTools(config) {
|
|
46
|
+
const execute = async () => {
|
|
47
|
+
const client = await this.getClient(config);
|
|
48
|
+
const result = await client.listTools();
|
|
49
|
+
return (result.tools ?? []).map((t) => ({
|
|
50
|
+
name: t.name,
|
|
51
|
+
description: t.description,
|
|
52
|
+
inputSchema: t.inputSchema,
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
return await execute();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error(`[mcp] listTools failed for ${config.name}, attempting reconnect:`, err instanceof Error ? err.message : err);
|
|
60
|
+
this.connections.delete(config.name);
|
|
61
|
+
return await execute();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async callTool(config, toolName, args) {
|
|
65
|
+
const execute = async () => {
|
|
66
|
+
const client = await this.getClient(config);
|
|
67
|
+
const result = await client.callTool({ name: toolName, arguments: args });
|
|
68
|
+
// MCP returns content as an array of content blocks
|
|
69
|
+
if (result.content && Array.isArray(result.content)) {
|
|
70
|
+
return result.content
|
|
71
|
+
.map((block) => block.type === "text" ? block.text : JSON.stringify(block))
|
|
72
|
+
.join("\n");
|
|
73
|
+
}
|
|
74
|
+
return result.content ?? result;
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
return await execute();
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// Connection likely dead — clear and retry once
|
|
81
|
+
console.error(`[mcp] Tool call failed for ${config.name}/${toolName}, attempting reconnect:`, err instanceof Error ? err.message : err);
|
|
82
|
+
this.connections.delete(config.name);
|
|
83
|
+
try {
|
|
84
|
+
return await execute();
|
|
85
|
+
}
|
|
86
|
+
catch (retryErr) {
|
|
87
|
+
throw new Error(`MCP tool ${config.name}/${toolName} failed after reconnect: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async disconnect(name) {
|
|
92
|
+
const client = this.connections.get(name);
|
|
93
|
+
if (client) {
|
|
94
|
+
try {
|
|
95
|
+
await client.close();
|
|
96
|
+
}
|
|
97
|
+
catch { /* ignore */ }
|
|
98
|
+
this.connections.delete(name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async disconnectAll() {
|
|
102
|
+
const names = [...this.connections.keys()];
|
|
103
|
+
await Promise.allSettled(names.map((n) => this.disconnect(n)));
|
|
104
|
+
}
|
|
105
|
+
isConnected(name) {
|
|
106
|
+
return this.connections.has(name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { McpConnectionManager } from "./client.js";
|
|
4
|
+
describe("McpConnectionManager — auto-reconnect", () => {
|
|
5
|
+
const testConfig = { name: "test-server", command: "echo", args: ["hello"] };
|
|
6
|
+
it("callTool retries once on connection failure", async () => {
|
|
7
|
+
const manager = new McpConnectionManager();
|
|
8
|
+
let attempts = 0;
|
|
9
|
+
// Inject a mock client that fails on first callTool, succeeds on second
|
|
10
|
+
const mockClient = {
|
|
11
|
+
callTool: async () => {
|
|
12
|
+
attempts++;
|
|
13
|
+
if (attempts === 1)
|
|
14
|
+
throw new Error("connection reset");
|
|
15
|
+
return { content: [{ type: "text", text: "success" }] };
|
|
16
|
+
},
|
|
17
|
+
listTools: async () => ({ tools: [] }),
|
|
18
|
+
close: async () => { },
|
|
19
|
+
};
|
|
20
|
+
// Inject into connections map
|
|
21
|
+
manager.connections.set("test-server", mockClient);
|
|
22
|
+
// Override getClient to return a fresh mock on reconnect
|
|
23
|
+
const originalGetClient = manager.getClient.bind(manager);
|
|
24
|
+
manager.getClient = async (config) => {
|
|
25
|
+
if (!manager.connections.has(config.name)) {
|
|
26
|
+
// Simulate reconnect — put a working mock
|
|
27
|
+
const freshMock = {
|
|
28
|
+
callTool: async () => ({ content: [{ type: "text", text: "reconnected" }] }),
|
|
29
|
+
close: async () => { },
|
|
30
|
+
};
|
|
31
|
+
manager.connections.set(config.name, freshMock);
|
|
32
|
+
return freshMock;
|
|
33
|
+
}
|
|
34
|
+
return manager.connections.get(config.name);
|
|
35
|
+
};
|
|
36
|
+
const result = await manager.callTool(testConfig, "test_tool", {});
|
|
37
|
+
assert.equal(result, "reconnected");
|
|
38
|
+
});
|
|
39
|
+
it("callTool throws after retry also fails", async () => {
|
|
40
|
+
const manager = new McpConnectionManager();
|
|
41
|
+
const failingClient = {
|
|
42
|
+
callTool: async () => { throw new Error("permanently broken"); },
|
|
43
|
+
close: async () => { },
|
|
44
|
+
};
|
|
45
|
+
manager.connections.set("test-server", failingClient);
|
|
46
|
+
// Override getClient to always return a broken client
|
|
47
|
+
manager.getClient = async () => failingClient;
|
|
48
|
+
await assert.rejects(() => manager.callTool(testConfig, "test_tool", {}), (err) => {
|
|
49
|
+
assert.ok(err.message.includes("failed after reconnect"));
|
|
50
|
+
return true;
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
it("listTools retries once on connection failure", async () => {
|
|
54
|
+
const manager = new McpConnectionManager();
|
|
55
|
+
let attempts = 0;
|
|
56
|
+
const mockClient = {
|
|
57
|
+
listTools: async () => {
|
|
58
|
+
attempts++;
|
|
59
|
+
if (attempts === 1)
|
|
60
|
+
throw new Error("connection lost");
|
|
61
|
+
return { tools: [{ name: "tool1", description: "A tool", inputSchema: {} }] };
|
|
62
|
+
},
|
|
63
|
+
close: async () => { },
|
|
64
|
+
};
|
|
65
|
+
manager.connections.set("test-server", mockClient);
|
|
66
|
+
manager.getClient = async (config) => {
|
|
67
|
+
if (!manager.connections.has(config.name)) {
|
|
68
|
+
const freshMock = {
|
|
69
|
+
listTools: async () => ({ tools: [{ name: "tool1", description: "A tool", inputSchema: {} }] }),
|
|
70
|
+
close: async () => { },
|
|
71
|
+
};
|
|
72
|
+
manager.connections.set(config.name, freshMock);
|
|
73
|
+
return freshMock;
|
|
74
|
+
}
|
|
75
|
+
return manager.connections.get(config.name);
|
|
76
|
+
};
|
|
77
|
+
const tools = await manager.listTools(testConfig);
|
|
78
|
+
assert.equal(tools.length, 1);
|
|
79
|
+
assert.equal(tools[0].name, "tool1");
|
|
80
|
+
});
|
|
81
|
+
it("clears dead connection from map on failure", async () => {
|
|
82
|
+
const manager = new McpConnectionManager();
|
|
83
|
+
const deadClient = {
|
|
84
|
+
callTool: async () => { throw new Error("dead"); },
|
|
85
|
+
close: async () => { },
|
|
86
|
+
};
|
|
87
|
+
manager.connections.set("test-server", deadClient);
|
|
88
|
+
assert.ok(manager.isConnected("test-server"));
|
|
89
|
+
// Override getClient to simulate a reconnect failure too
|
|
90
|
+
manager.getClient = async () => { throw new Error("cannot reconnect"); };
|
|
91
|
+
try {
|
|
92
|
+
await manager.callTool(testConfig, "test_tool", {});
|
|
93
|
+
}
|
|
94
|
+
catch { /* expected */ }
|
|
95
|
+
// Dead connection should have been cleared
|
|
96
|
+
assert.ok(!manager.isConnected("test-server"));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
//# sourceMappingURL=client.test.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { IO_HOME } from "../paths.js";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
export const MCP_CONFIG_PATH = join(IO_HOME, "mcp.json");
|
|
6
|
+
export function loadMcpConfig() {
|
|
7
|
+
if (!existsSync(MCP_CONFIG_PATH)) {
|
|
8
|
+
return { servers: [] };
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const raw = readFileSync(MCP_CONFIG_PATH, "utf-8");
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (!parsed.servers || !Array.isArray(parsed.servers)) {
|
|
14
|
+
return { servers: [] };
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { servers: [] };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function saveMcpConfig(config) {
|
|
23
|
+
const dir = dirname(MCP_CONFIG_PATH);
|
|
24
|
+
if (!existsSync(dir)) {
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=config.js.map
|