dsh-mesh-node 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +8 -2
- package/package.json +43 -4
- package/src/cli.js +1246 -0
- package/src/core/acl.js +81 -0
- package/src/core/audit.js +75 -0
- package/src/core/autostart.js +119 -0
- package/src/core/cluster.js +340 -0
- package/src/core/config.js +622 -0
- package/src/core/desktop-proxy.js +85 -0
- package/src/core/ds-web.js +394 -0
- package/src/core/host-agent.js +263 -0
- package/src/core/jobs.js +259 -0
- package/src/core/pairing.js +141 -0
- package/src/core/peer-client.js +273 -0
- package/src/core/peer-doctor.js +294 -0
- package/src/core/peer-http.js +61 -0
- package/src/core/repair.js +93 -0
- package/src/core/restart-policy.js +21 -0
- package/src/core/result.js +29 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/util.js +68 -0
- package/src/e2e-autostart.js +58 -0
- package/src/e2e-channel.js +384 -0
- package/src/e2e-cluster.js +417 -0
- package/src/e2e-host.js +411 -0
- package/src/e2e-onboard.js +232 -0
- package/src/e2e-pair.js +317 -0
- package/src/e2e-plugin.js +395 -0
- package/src/e2e-repair.js +297 -0
- package/src/index.js +20 -0
- package/src/peer-bridge.js +124 -0
- package/src/selftest.js +872 -0
- package/src/server.js +313 -0
- package/src/tools/alerts.js +80 -0
- package/src/tools/channel.js +253 -0
- package/src/tools/cluster.js +235 -0
- package/src/tools/config-patch.js +182 -0
- package/src/tools/control-apps.js +469 -0
- package/src/tools/control-path.js +160 -0
- package/src/tools/control.js +232 -0
- package/src/tools/core.js +79 -0
- package/src/tools/delegate.js +653 -0
- package/src/tools/fs.js +436 -0
- package/src/tools/git.js +266 -0
- package/src/tools/host.js +305 -0
- package/src/tools/index.js +122 -0
- package/src/tools/repair-fs.js +130 -0
- package/src/tools/repair.js +317 -0
- package/src/tools/session-history.js +60 -0
- package/src/tools/shell.js +66 -0
- package/src/tools/tasks.js +117 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { createHostClient, hostInstances } from '../core/host-agent.js'
|
|
6
|
+
import { buildSessionProfile, classifySession, sessionBlock } from '../core/session-profile.js'
|
|
7
|
+
import { fail, ok } from '../core/result.js'
|
|
8
|
+
// 2026-11-06:MCP 委托也要写 delegations.json 台账(此前只有 CLI 写 → 台账漏记,审计失真)
|
|
9
|
+
import { instanceName, updateRegistry } from '../../scripts/lib/mesh-delegate/common.mjs'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MCP-native delegation (M8.5). These tools run on the machine being delegated
|
|
13
|
+
* TO: a peer AI calls `delegate` with a goal, this node picks a local DSH
|
|
14
|
+
* session, injects a task envelope via the host bridge, and the peer polls
|
|
15
|
+
* `delegate_status` / answers `delegate_reply`. No cookie, no shell.
|
|
16
|
+
*
|
|
17
|
+
* Task files live in `<fsRoot>/.mesh/tasks/<taskId>/` so the peer can also read
|
|
18
|
+
* them with fs_read; the local AI writes status.json / result.json there.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const TERMINAL = new Set(['done', 'failed', 'cancelled'])
|
|
22
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms))
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Preset the fresh session is composed from when the caller names none.
|
|
26
|
+
*
|
|
27
|
+
* DSH >= 0.2.0 composes an agent from an agent preset, and `standard` is the
|
|
28
|
+
* built-in default the web UI itself sends. Creating a session WITHOUT a preset
|
|
29
|
+
* leaves the composition scope unset, which `@deepseek-ai/dsh-tool-subagent`
|
|
30
|
+
* rejects with "standing `modelSelectionSettings` requires a scoped preset
|
|
31
|
+
* Context" — that is what broke every fresh-session delegation before this
|
|
32
|
+
* default existed (observed on a peer node, mesh 0.17.0).
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_AGENT_PRESET = 'standard'
|
|
35
|
+
|
|
36
|
+
function localRoot(config) {
|
|
37
|
+
const root = config?.acl?.fsRoots?.[0]
|
|
38
|
+
if (!root) throw new Error('config.acl.fsRoots[0] is required for delegate tools')
|
|
39
|
+
return root
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stamp() {
|
|
43
|
+
const d = new Date()
|
|
44
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
45
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readJson(file, fallback = null) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
51
|
+
} catch {
|
|
52
|
+
return fallback
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeJson(file, data) {
|
|
57
|
+
mkdirSync(join(file, '..'), { recursive: true })
|
|
58
|
+
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function taskAge(status, taskId) {
|
|
62
|
+
const match = /^T-(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})-/.exec(String(taskId || ''))
|
|
63
|
+
const idTime = match
|
|
64
|
+
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5])).getTime()
|
|
65
|
+
: 0
|
|
66
|
+
const statusTime = Date.parse(status?.updatedAt || '') || 0
|
|
67
|
+
const updatedMs = statusTime || idTime || 0
|
|
68
|
+
return {
|
|
69
|
+
updatedMs,
|
|
70
|
+
ageSeconds: updatedMs ? Math.round((Date.now() - updatedMs) / 1000) : null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function taskPaths(root, taskId) {
|
|
75
|
+
const dir = join(root, '.mesh', 'tasks', taskId)
|
|
76
|
+
return { dir, request: join(dir, 'request.json'), status: join(dir, 'status.json'), result: join(dir, 'result.json') }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 2026-11-06:防「长任务被误判失败 → 重复委托 → 重复执行」。
|
|
81
|
+
* 扫本地 .mesh/tasks:同一 goal(trim 后严格相等)、未终态、30 分钟内 → 视为重复,返回已有任务。
|
|
82
|
+
* 发起方在等 10+ 分钟长任务时若重派同一 goal,会在这里被挡住(force:true 可强制新建)。
|
|
83
|
+
*/
|
|
84
|
+
function findRecentDuplicateTask(root, goalText, withinMs = 30 * 60 * 1000) {
|
|
85
|
+
const dir = join(root, '.mesh', 'tasks')
|
|
86
|
+
let names = []
|
|
87
|
+
try {
|
|
88
|
+
names = readdirSync(dir)
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
const text = String(goalText || '').trim()
|
|
93
|
+
if (!text) return null
|
|
94
|
+
const nowMs = Date.now()
|
|
95
|
+
for (const id of names) {
|
|
96
|
+
const base = join(dir, id)
|
|
97
|
+
const request = readJson(join(base, 'request.json'), null)
|
|
98
|
+
if (!request || String(request.goal || '').trim() !== text) continue
|
|
99
|
+
const status = readJson(join(base, 'status.json'), null)
|
|
100
|
+
const state = status?.state || 'submitted'
|
|
101
|
+
if (TERMINAL.has(state)) continue
|
|
102
|
+
const at = Date.parse(request.requestedAt || '') || 0
|
|
103
|
+
if (at && nowMs - at > withinMs) continue
|
|
104
|
+
return { taskId: request.taskId || id, conversationId: request.conversationId || '', sessionId: request.sessionId || '', state }
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requestText(task, root) {
|
|
110
|
+
return `[dsh-mesh delegation]
|
|
111
|
+
taskId=${task.taskId}
|
|
112
|
+
conversationId=${task.conversationId}
|
|
113
|
+
from=${task.from || 'peer'}
|
|
114
|
+
policy=${task.policy}
|
|
115
|
+
|
|
116
|
+
请求内容:
|
|
117
|
+
${JSON.stringify(
|
|
118
|
+
{ taskId: task.taskId, conversationId: task.conversationId, from: task.from || 'peer', workspace: task.workspace || '', goal: task.goal, scope: task.scope, acceptance: task.acceptance },
|
|
119
|
+
null,
|
|
120
|
+
2
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
1) 可选:读请求原文(只读):${task.paths.request}
|
|
124
|
+
2) 按 goal / scope / acceptance 执行。
|
|
125
|
+
3) 状态写到:${task.paths.status}
|
|
126
|
+
形如 {"taskId":"${task.taskId}","state":"accepted|working|question|done|failed","question":"...","error":"...","summary":"...","heartbeatAt":"ISO"}
|
|
127
|
+
完成时再写 ${task.paths.result}(JSON,含证据/产物路径)。
|
|
128
|
+
4) 长任务每 30~60 秒更新一次 status.json(至少 heartbeatAt)。
|
|
129
|
+
5) 需要澄清:写 state=question + question 字段后停止;协调者会 reply 并唤醒本会话继续。
|
|
130
|
+
6) 不要 kill 进程、不要 push、不要改无关文件。`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sessionSeq(agents, sessionId) {
|
|
134
|
+
const row = (agents || []).find((agent) => agent.id === sessionId)
|
|
135
|
+
return row ? { seq: row.seq ?? 0, status: row.status || 'unknown' } : null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A prompt is only *admitted* into the agent inbox. If the instance's runtime or
|
|
140
|
+
* model is unhealthy the agent can accept the prompt and never start a turn,
|
|
141
|
+
* leaving the coordinator polling "submitted" forever. Watch seq/status briefly
|
|
142
|
+
* and surface a warning instead of silently reporting success.
|
|
143
|
+
*/
|
|
144
|
+
async function confirmFreshSessionStarted(client, sessionId, baselineSeq, { timeoutMs = 3000, intervalMs = 250 } = {}) {
|
|
145
|
+
const deadline = Date.now() + timeoutMs
|
|
146
|
+
let last = null
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
try {
|
|
149
|
+
last = sessionSeq((await client.agents())?.agents, sessionId)
|
|
150
|
+
} catch {
|
|
151
|
+
/* transient host bridge hiccup — keep polling */
|
|
152
|
+
}
|
|
153
|
+
if (last && (last.seq > baselineSeq || last.status === 'running')) {
|
|
154
|
+
return { started: true, seq: last.seq, status: last.status }
|
|
155
|
+
}
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
157
|
+
}
|
|
158
|
+
return { started: false, seq: last?.seq ?? baselineSeq, status: last?.status || 'unknown', timeoutMs }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function listAgents(ctx) {
|
|
162
|
+
const config = ctx.getConfig()
|
|
163
|
+
const rows = []
|
|
164
|
+
for (const entry of hostInstances(config)) {
|
|
165
|
+
if (entry.enabled === false) continue
|
|
166
|
+
try {
|
|
167
|
+
const client = createHostClient({ config, home: ctx.home, log: ctx.log, instance: entry.id })
|
|
168
|
+
try {
|
|
169
|
+
const listed = await client.sessions({ limit: 200, includeEnded: false })
|
|
170
|
+
for (const session of listed.sessions || []) {
|
|
171
|
+
if (session.agentAvailable !== true) continue
|
|
172
|
+
rows.push({
|
|
173
|
+
instance: entry.id,
|
|
174
|
+
id: session.sessionId,
|
|
175
|
+
status: session.state === 'working' ? 'running' : 'idle',
|
|
176
|
+
title: session.title || '',
|
|
177
|
+
cwd: session.cwd || '',
|
|
178
|
+
seq: 0,
|
|
179
|
+
updatedAt: session.updatedAt || 0
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
continue
|
|
183
|
+
} catch {
|
|
184
|
+
/* old fork or session service unavailable: fall back to live agents */
|
|
185
|
+
}
|
|
186
|
+
const { agents } = await client.agents()
|
|
187
|
+
for (const agent of agents || []) rows.push({ instance: entry.id, id: agent.id, status: agent.status, title: agent.title || '', cwd: agent.cwd || '', seq: agent.seq || 0, updatedAt: 0 })
|
|
188
|
+
} catch {
|
|
189
|
+
/* instance offline: keep looking */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return rows
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function listSessionRows(ctx, { includeEnded = true, workspace = '' } = {}) {
|
|
196
|
+
const config = ctx.getConfig()
|
|
197
|
+
const rows = []
|
|
198
|
+
for (const entry of hostInstances(config)) {
|
|
199
|
+
if (entry.enabled === false) continue
|
|
200
|
+
try {
|
|
201
|
+
const client = createHostClient({ config, home: ctx.home, log: ctx.log, instance: entry.id })
|
|
202
|
+
const listed = await client.sessions({ limit: 200, includeEnded, workspace })
|
|
203
|
+
for (const session of listed.sessions || []) rows.push({ instance: entry.id, ...session })
|
|
204
|
+
} catch (error) {
|
|
205
|
+
rows.push({ instance: entry.id, error: String(error?.message || error) })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return rows
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function pickSession(ctx, sessionId, workspace) {
|
|
212
|
+
const agents = await listAgents(ctx)
|
|
213
|
+
const norm = (value) => String(value || '').toLowerCase().replace(/\\/g, '/')
|
|
214
|
+
const pool = workspace ? agents.filter((agent) => norm(agent.cwd).includes(norm(workspace))) : agents
|
|
215
|
+
if (workspace && pool.length === 0) {
|
|
216
|
+
throw new Error(`no live session with workspace ${workspace} on this node — available: ${agents.map((agent) => agent.cwd || '?').join(', ') || '(none)'}`)
|
|
217
|
+
}
|
|
218
|
+
if (sessionId) {
|
|
219
|
+
const found = pool.find((agent) => agent.id === sessionId)
|
|
220
|
+
if (!found) throw new Error(`session not alive on this node: ${sessionId}`)
|
|
221
|
+
return found
|
|
222
|
+
}
|
|
223
|
+
const idle = pool.find((agent) => agent.status === 'idle') || pool[0]
|
|
224
|
+
if (!idle) throw new Error('no idle DSH session on this node — start one first')
|
|
225
|
+
return idle
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hostError(error) {
|
|
229
|
+
return fail('E_DELEGATE', error?.message || String(error), { retryable: false })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function buildDelegate(ctx) {
|
|
233
|
+
return [
|
|
234
|
+
{
|
|
235
|
+
name: 'delegate',
|
|
236
|
+
title: 'Hand a task to this machine\u2019s AI',
|
|
237
|
+
description:
|
|
238
|
+
'Create a delegation task on this machine and deliver it to a DSH conversation. Default: creates a FRESH DSH session (clean context, same as opening a new chat) on the chosen instance and prompts it there. Pass sessionId (returned by a previous delegate) to CONTINUE that exact conversation instead — use that for long tasks that must keep their context. Returns taskId / conversationId / sessionId immediately; poll with delegate_status and answer questions with delegate_reply. Duplicate guard (2026-11-06): if the SAME goal already has a non-terminal task on this node within 30 min, returns { deduped: true, taskId } instead of starting a second copy — use force:true only when you really want two parallel runs. No DSH cookie, no shell, no cross-machine write.',
|
|
239
|
+
inputSchema: z.object({
|
|
240
|
+
goal: z.string().min(1).max(8000).describe('What the local AI should do.'),
|
|
241
|
+
scope: z.string().max(4000).optional().describe('Files/repos/actions allowed (optional).'),
|
|
242
|
+
acceptance: z.string().max(4000).optional().describe('How to verify the result (optional).'),
|
|
243
|
+
conversationId: z.string().max(120).optional().describe('Reuse a conversation id; defaults to a new one.'),
|
|
244
|
+
sessionId: z.string().optional().describe('Continue THIS live DSH session (from an earlier delegate response or delegate_candidates). Omit to start a fresh conversation.'),
|
|
245
|
+
force: z
|
|
246
|
+
.boolean()
|
|
247
|
+
.optional()
|
|
248
|
+
.describe('Allow continuing a session that owns scheduled tasks (default false: those are refused because a delegation would pollute the scheduled conversation).'),
|
|
249
|
+
instance: z.string().optional().describe('Local DSH instance id for a fresh conversation (default: first enabled instance).'),
|
|
250
|
+
workspace: z.string().max(1000).optional().describe('For a fresh conversation: cwd/workspace of the new session (repo path). For a continuation: unused.'),
|
|
251
|
+
agentPreset: z
|
|
252
|
+
.string()
|
|
253
|
+
.max(200)
|
|
254
|
+
.optional()
|
|
255
|
+
.describe(`Agent preset id the fresh session is composed from (default: ${DEFAULT_AGENT_PRESET}). Ignored for a continuation.`)
|
|
256
|
+
}),
|
|
257
|
+
handler: async ({ goal, scope, acceptance, conversationId, sessionId, instance, workspace, force, agentPreset }) => {
|
|
258
|
+
try {
|
|
259
|
+
const config = ctx.getConfig()
|
|
260
|
+
const root = localRoot(config)
|
|
261
|
+
// 2026-11-06:同一 goal 已有未终态任务 → 直接复用,避免长任务被误判失败后重复投递
|
|
262
|
+
if (force !== true) {
|
|
263
|
+
const duplicate = findRecentDuplicateTask(root, goal)
|
|
264
|
+
if (duplicate) {
|
|
265
|
+
return ok({
|
|
266
|
+
deduped: true,
|
|
267
|
+
taskId: duplicate.taskId,
|
|
268
|
+
conversationId: duplicate.conversationId,
|
|
269
|
+
sessionId: duplicate.sessionId,
|
|
270
|
+
state: duplicate.state,
|
|
271
|
+
note: '同一 goal 已有未终态任务(可能只是长任务还没写 status.json)。请用 delegate_status 轮询这个 taskId,不要重复委托;确实要并行另开一份才传 force:true'
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const instances = hostInstances(config).filter((entry) => entry.enabled !== false)
|
|
276
|
+
if (instances.length === 0) throw new Error('no host bridge configured (config.hosts is empty)')
|
|
277
|
+
const norm = (value) => String(value || '').toLowerCase().replace(/\\/g, '/')
|
|
278
|
+
let chosen
|
|
279
|
+
let created = false
|
|
280
|
+
let createdBaselineSeq = 0
|
|
281
|
+
if (sessionId) {
|
|
282
|
+
const blocked = sessionBlock(buildSessionProfile(config), sessionId)
|
|
283
|
+
if (blocked && force !== true) return fail('E_SESSION_PROTECTED', blocked.reason, blocked)
|
|
284
|
+
const agents = await listAgents(ctx)
|
|
285
|
+
const found = agents.find((agent) => agent.id === sessionId)
|
|
286
|
+
if (!found) {
|
|
287
|
+
throw new Error(`session not alive on this node: ${sessionId} — a continuation needs a live session; check delegate_candidates (pass sessionId exactly as returned by delegate)`)
|
|
288
|
+
}
|
|
289
|
+
chosen = found
|
|
290
|
+
} else {
|
|
291
|
+
const wanted = instance
|
|
292
|
+
? instances.find((entry) => entry.id === instance)
|
|
293
|
+
: instances[0]
|
|
294
|
+
if (!wanted) {
|
|
295
|
+
throw new Error(`unknown DSH instance "${instance}" — configured: ${instances.map((entry) => entry.id).join(', ')}`)
|
|
296
|
+
}
|
|
297
|
+
const client = createHostClient({ config, home: ctx.home, log: ctx.log, instance: wanted.id })
|
|
298
|
+
const cwd = workspace ? workspace : undefined
|
|
299
|
+
const preset = agentPreset || DEFAULT_AGENT_PRESET
|
|
300
|
+
const createdSession = await client.createSession({ cwd, agentPreset: preset })
|
|
301
|
+
if (!createdSession?.sessionId) {
|
|
302
|
+
throw new Error(`host bridge did not return a sessionId (plugin too old? need dsh-mesh-host >= 0.2.0): ${JSON.stringify(createdSession)}`)
|
|
303
|
+
}
|
|
304
|
+
createdBaselineSeq = 0
|
|
305
|
+
try {
|
|
306
|
+
createdBaselineSeq = sessionSeq((await client.agents())?.agents, createdSession.sessionId)?.seq ?? 0
|
|
307
|
+
} catch {
|
|
308
|
+
/* baseline is advisory */
|
|
309
|
+
}
|
|
310
|
+
chosen = {
|
|
311
|
+
id: createdSession.sessionId,
|
|
312
|
+
instance: wanted.id,
|
|
313
|
+
status: 'idle',
|
|
314
|
+
cwd: workspace || ''
|
|
315
|
+
}
|
|
316
|
+
created = true
|
|
317
|
+
}
|
|
318
|
+
const taskId = `T-${stamp()}-${randomUUID().slice(0, 4)}`
|
|
319
|
+
const convId = conversationId || `C-${stamp()}-${randomUUID().slice(0, 4)}`
|
|
320
|
+
const task = {
|
|
321
|
+
taskId,
|
|
322
|
+
conversationId: convId,
|
|
323
|
+
from: ctx.peerId || 'peer',
|
|
324
|
+
policy: created ? 'new' : 'continue',
|
|
325
|
+
// 2026-11-06:MCP 无 policy 参数 —— 传 sessionId 即"发起方请求 continue",否则 new
|
|
326
|
+
requestedPolicy: sessionId ? 'continue' : 'new',
|
|
327
|
+
policySource: sessionId ? 'session' : 'default',
|
|
328
|
+
node: (config?.node?.name) || 'local',
|
|
329
|
+
sessionId: chosen.id,
|
|
330
|
+
instance: chosen.instance,
|
|
331
|
+
workspace: chosen.cwd || '',
|
|
332
|
+
state: 'submitted',
|
|
333
|
+
createdAt: new Date().toISOString(),
|
|
334
|
+
updatedAt: new Date().toISOString(),
|
|
335
|
+
source: 'mcp',
|
|
336
|
+
goal,
|
|
337
|
+
scope: scope || '',
|
|
338
|
+
acceptance: acceptance || '',
|
|
339
|
+
paths: {}
|
|
340
|
+
}
|
|
341
|
+
task.paths = taskPaths(root, taskId)
|
|
342
|
+
writeJson(task.paths.request, {
|
|
343
|
+
taskId,
|
|
344
|
+
conversationId: convId,
|
|
345
|
+
from: task.from,
|
|
346
|
+
workspace: task.workspace,
|
|
347
|
+
goal,
|
|
348
|
+
scope: task.scope,
|
|
349
|
+
acceptance: task.acceptance,
|
|
350
|
+
sessionId: chosen.id,
|
|
351
|
+
instance: chosen.instance,
|
|
352
|
+
sessionPolicy: task.policy,
|
|
353
|
+
createdSession: created,
|
|
354
|
+
seqBaseline: createdBaselineSeq,
|
|
355
|
+
requestedAt: new Date().toISOString()
|
|
356
|
+
})
|
|
357
|
+
// 2026-11-06:补写 delegations.json 台账(与 CLI 同一份文件,锁 + 原子写;失败不影响投递)
|
|
358
|
+
try {
|
|
359
|
+
updateRegistry((data) => {
|
|
360
|
+
data.tasks = data.tasks || {}
|
|
361
|
+
data.conversations = data.conversations || {}
|
|
362
|
+
const existing = data.conversations[convId]
|
|
363
|
+
data.tasks[taskId] = task
|
|
364
|
+
data.conversations[convId] = {
|
|
365
|
+
conversationId: convId,
|
|
366
|
+
node: task.node,
|
|
367
|
+
dedicated: false,
|
|
368
|
+
sessionId: chosen.id,
|
|
369
|
+
instance: chosen.instance,
|
|
370
|
+
activeTask: taskId,
|
|
371
|
+
createdAt: existing?.createdAt || task.createdAt,
|
|
372
|
+
lastActiveAt: task.createdAt,
|
|
373
|
+
tasks: [...(existing?.tasks || []), taskId]
|
|
374
|
+
}
|
|
375
|
+
return data
|
|
376
|
+
})
|
|
377
|
+
} catch (error) {
|
|
378
|
+
try {
|
|
379
|
+
ctx.log?.warn?.(`delegate registry write failed (task continues): ${error?.message || error}`)
|
|
380
|
+
} catch {
|
|
381
|
+
/* logging must not break delivery */
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const client = createHostClient({ config, home: ctx.home, log: ctx.log, instance: chosen.instance })
|
|
385
|
+
const text = requestText(task, root)
|
|
386
|
+
// A brand-new session has an agent but no running turn: wake() only drops a
|
|
387
|
+
// message into the inbox, which a dormant agent never reads. prompt() drives
|
|
388
|
+
// the first turn. Live sessions keep using wake().
|
|
389
|
+
const delivery = created
|
|
390
|
+
? { kind: 'prompt', ...(await client.prompt({ sessionId: chosen.id, text, mode: 'queue' })) }
|
|
391
|
+
: { kind: 'wake', ...(await client.wake({ sessionId: chosen.id, text, target: 'next-turn' })) }
|
|
392
|
+
let verification
|
|
393
|
+
if (created) {
|
|
394
|
+
verification = await confirmFreshSessionStarted(client, chosen.id, createdBaselineSeq)
|
|
395
|
+
if (!verification.started) {
|
|
396
|
+
delivery.warning = `prompt accepted, but the agent did not start a turn within ${verification.timeoutMs}ms (status=${verification.status}, seq=${verification.seq}). Check this instance's runtime/model health (e.g. provider error) — the session exists and can be continued later.`
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return ok({
|
|
400
|
+
taskId,
|
|
401
|
+
conversationId: convId,
|
|
402
|
+
sessionId: chosen.id,
|
|
403
|
+
instance: chosen.instance,
|
|
404
|
+
createdSession: created,
|
|
405
|
+
workspace: chosen.cwd || '',
|
|
406
|
+
requestPath: task.paths.request,
|
|
407
|
+
delivery,
|
|
408
|
+
...(verification ? { verification } : {}),
|
|
409
|
+
...(created ? { prompt: delivery } : { wake: delivery })
|
|
410
|
+
})
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return hostError(error)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'delegate_candidates',
|
|
418
|
+
title: 'List sessions eligible for delegation on this machine',
|
|
419
|
+
description:
|
|
420
|
+
'Read-only candidate list before delegating: every live/stored DSH session with instance, title, cwd (workspace), state (working|idle|ended), running, agentAvailable, delegatable and recommended flags. Each row also carries kind (delegation | business-scheduled | business | unknown), protected (owns scheduled tasks — never delegate here), reason and schedules/delegation evidence, so a peer can tell its own delegation sessions apart from the local business ones. Ended sessions are visible but not delegatable; use workspace to narrow by repo.',
|
|
421
|
+
inputSchema: z.object({
|
|
422
|
+
instance: z.string().optional().describe('Local DSH instance id (default: all).'),
|
|
423
|
+
workspace: z.string().max(500).optional().describe('Only sessions whose cwd contains this path.'),
|
|
424
|
+
includeEnded: z.boolean().optional().describe('Include stored sessions with no live agent (default true).'),
|
|
425
|
+
limit: z.number().int().min(1).max(200).optional()
|
|
426
|
+
}),
|
|
427
|
+
handler: async ({ instance, workspace = '', includeEnded = true, limit = 50 }) => {
|
|
428
|
+
try {
|
|
429
|
+
const rows = await listSessionRows(ctx, { includeEnded, workspace })
|
|
430
|
+
const profile = buildSessionProfile(ctx.getConfig())
|
|
431
|
+
const filtered = instance ? rows.filter((row) => row.instance === instance) : rows
|
|
432
|
+
const sessions = filtered.slice(0, limit).map((session) => {
|
|
433
|
+
if (session.error) {
|
|
434
|
+
return { ...session, kind: 'unknown', protected: false, delegatable: false, recommended: false, reason: String(session.error), schedules: [], delegation: null }
|
|
435
|
+
}
|
|
436
|
+
return { ...session, ...classifySession(profile, session) }
|
|
437
|
+
})
|
|
438
|
+
return ok({ count: sessions.length, protectedCount: sessions.filter((row) => row.protected === true).length, sessions })
|
|
439
|
+
} catch (error) {
|
|
440
|
+
return hostError(error)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
name: 'delegate_status',
|
|
446
|
+
title: 'Poll a delegation task on this machine',
|
|
447
|
+
description:
|
|
448
|
+
'Read status.json / result.json of a local delegation task. `waitSec` (0-60) polls until the task is done/failed or a question appears. Returns notFailed/advice — a long task may take 10+ minutes and “stalled” only means status.json has not been updated recently; it is NOT a failure, so do not re-delegate the same goal. Read-only.',
|
|
449
|
+
inputSchema: z.object({
|
|
450
|
+
taskId: z.string().describe('Task id returned by delegate.'),
|
|
451
|
+
waitSec: z.number().int().min(0).max(60).optional().describe('Seconds to poll (default 0).')
|
|
452
|
+
}),
|
|
453
|
+
handler: async ({ taskId, waitSec = 0 }) => {
|
|
454
|
+
try {
|
|
455
|
+
const root = localRoot(ctx.getConfig())
|
|
456
|
+
const paths = taskPaths(root, taskId)
|
|
457
|
+
if (!existsSync(paths.request)) return fail('E_NOT_FOUND', `unknown taskId: ${taskId}`)
|
|
458
|
+
const deadline = Date.now() + waitSec * 1000
|
|
459
|
+
const request = readJson(paths.request, null)
|
|
460
|
+
let state = readJson(paths.status, null)
|
|
461
|
+
while (!state && Date.now() < deadline) {
|
|
462
|
+
await sleep(1000)
|
|
463
|
+
state = readJson(paths.status, null)
|
|
464
|
+
}
|
|
465
|
+
while (state && !TERMINAL.has(state.state) && state.state !== 'question' && Date.now() < deadline) {
|
|
466
|
+
await sleep(1000)
|
|
467
|
+
state = readJson(paths.status, null) || state
|
|
468
|
+
}
|
|
469
|
+
// Some agents finish the turn without writing status.json (e.g. a reply-only
|
|
470
|
+
// goal). Derive what really happened from the live agent instead of
|
|
471
|
+
// reporting "submitted" forever.
|
|
472
|
+
let derived = null
|
|
473
|
+
if (!state && request?.sessionId) {
|
|
474
|
+
try {
|
|
475
|
+
// Query only the task's own instance: aggregating every instance can
|
|
476
|
+
// stall on an unrelated bridge and turn a cheap status poll into a timeout.
|
|
477
|
+
const config = ctx.getConfig()
|
|
478
|
+
const targetInstance = request.instance || hostInstances(config)[0]?.id
|
|
479
|
+
const client = createHostClient({ config, home: ctx.home, log: ctx.log, instance: targetInstance })
|
|
480
|
+
const agents = (await client.agents())?.agents || []
|
|
481
|
+
const found = agents.find((agent) => agent.id === request.sessionId)
|
|
482
|
+
const baseline = Number(request.seqBaseline) || 0
|
|
483
|
+
if (!found) {
|
|
484
|
+
derived = { state: 'unknown', note: 'the delegated session is no longer live on this node' }
|
|
485
|
+
} else if (found.status === 'running') {
|
|
486
|
+
derived = { state: 'working', note: 'the delegated agent is running; it has not written status.json yet' }
|
|
487
|
+
} else if ((found.seq ?? 0) > baseline) {
|
|
488
|
+
derived = {
|
|
489
|
+
state: 'unreported',
|
|
490
|
+
note: 'the delegated agent finished its turn but wrote no status.json — read the reply in that session (sessionId above); ask it to write status.json/result.json for structured reporting'
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} catch {
|
|
494
|
+
/* fall back to submitted */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// 2026-11-06:长任务(10+ 分钟)常见"status.json 很久没更新",这里显式给出
|
|
498
|
+
// ageSeconds / stalled / notFailed / advice,避免发起方把它当失败而重复委托。
|
|
499
|
+
let ageSeconds = null
|
|
500
|
+
try {
|
|
501
|
+
ageSeconds = Math.round((Date.now() - statSync(paths.status).mtimeMs) / 1000)
|
|
502
|
+
} catch {
|
|
503
|
+
ageSeconds = null
|
|
504
|
+
}
|
|
505
|
+
const effectiveState = state?.state || derived?.state || 'submitted'
|
|
506
|
+
const runningLike = ['submitted', 'accepted', 'working', 'stalled', 'unknown', 'unreported'].includes(effectiveState)
|
|
507
|
+
const stalled = Boolean(runningLike && ageSeconds !== null && ageSeconds > 900)
|
|
508
|
+
return ok({
|
|
509
|
+
taskId,
|
|
510
|
+
state: effectiveState,
|
|
511
|
+
question: state?.question || '',
|
|
512
|
+
error: state?.error || '',
|
|
513
|
+
summary: state?.summary || '',
|
|
514
|
+
note: state?.note || derived?.note || '',
|
|
515
|
+
ageSeconds,
|
|
516
|
+
stalled,
|
|
517
|
+
notFailed: runningLike,
|
|
518
|
+
advice: runningLike
|
|
519
|
+
? 'still running — do NOT re-delegate the same goal; poll this taskId again later. “stalled”只表示 status.json 很久没更新(可能正在长步骤),不代表失败。'
|
|
520
|
+
: '',
|
|
521
|
+
result: readJson(paths.result, null)
|
|
522
|
+
})
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return hostError(error)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: 'delegate_reply',
|
|
530
|
+
title: 'Answer a delegation question',
|
|
531
|
+
description:
|
|
532
|
+
'Send an answer to a task that reported state=question; wakes the same local session so it can continue. The task metadata is read from <fsRoot>/.mesh/tasks/<taskId>/request.json.',
|
|
533
|
+
inputSchema: z.object({
|
|
534
|
+
taskId: z.string().describe('Task id returned by delegate.'),
|
|
535
|
+
text: z.string().min(1).max(8000).describe('Answer / clarification for the local AI.')
|
|
536
|
+
}),
|
|
537
|
+
handler: async ({ taskId, text }) => {
|
|
538
|
+
try {
|
|
539
|
+
const root = localRoot(ctx.getConfig())
|
|
540
|
+
const paths = taskPaths(root, taskId)
|
|
541
|
+
const request = readJson(paths.request, null)
|
|
542
|
+
if (!request) return fail('E_NOT_FOUND', `unknown taskId: ${taskId}`)
|
|
543
|
+
const config = ctx.getConfig()
|
|
544
|
+
const agents = await listAgents(ctx)
|
|
545
|
+
const found = agents.find((agent) => agent.id === request.sessionId)
|
|
546
|
+
if (!found) return fail('E_SESSION_GONE', `session no longer alive: ${request.sessionId}`)
|
|
547
|
+
const client = createHostClient({ config, home: ctx.home, log: ctx.log, instance: found.instance })
|
|
548
|
+
const wake = await client.wake({ sessionId: found.id, text: `[dsh-mesh delegation answer]\ntaskId=${taskId}\nconversationId=${request.conversationId}\n${text}`, target: 'next-turn' })
|
|
549
|
+
return ok({ taskId, sessionId: found.id, wake })
|
|
550
|
+
} catch (error) {
|
|
551
|
+
return hostError(error)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: 'delegate_list',
|
|
557
|
+
title: 'List delegation tasks on this machine',
|
|
558
|
+
description: 'List recent delegation task files under <fsRoot>/.mesh/tasks/ with state, workspace, sessionId, summary and stale flag. Read-only.',
|
|
559
|
+
inputSchema: z.object({
|
|
560
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
561
|
+
state: z.string().optional().describe('Only tasks in this state (working/accepted/question/done/failed/cancelled).'),
|
|
562
|
+
activeOnly: z.boolean().optional().describe('Only non-terminal tasks.')
|
|
563
|
+
}),
|
|
564
|
+
handler: async ({ limit = 20, state, activeOnly = false }) => {
|
|
565
|
+
try {
|
|
566
|
+
const root = localRoot(ctx.getConfig())
|
|
567
|
+
const dir = join(root, '.mesh', 'tasks')
|
|
568
|
+
if (!existsSync(dir)) return ok({ count: 0, activeCount: 0, tasks: [] })
|
|
569
|
+
let tasks = readdirSync(dir, { withFileTypes: true })
|
|
570
|
+
.filter((entry) => entry.isDirectory())
|
|
571
|
+
.map((entry) => {
|
|
572
|
+
const paths = taskPaths(root, entry.name)
|
|
573
|
+
const status = readJson(paths.status, null)
|
|
574
|
+
const request = readJson(paths.request, null)
|
|
575
|
+
const age = taskAge(status, entry.name)
|
|
576
|
+
return {
|
|
577
|
+
taskId: entry.name,
|
|
578
|
+
state: status?.state || 'submitted',
|
|
579
|
+
workspace: request?.workspace || '',
|
|
580
|
+
sessionId: request?.sessionId || '',
|
|
581
|
+
conversationId: request?.conversationId || '',
|
|
582
|
+
summary: status?.summary || '',
|
|
583
|
+
ageSeconds: age.ageSeconds,
|
|
584
|
+
stale: (status?.staleSeconds || 0) > 300 || (age.ageSeconds !== null && age.ageSeconds > 3600),
|
|
585
|
+
updatedAt: status?.updatedAt || ''
|
|
586
|
+
}
|
|
587
|
+
})
|
|
588
|
+
if (state) tasks = tasks.filter((task) => task.state === state)
|
|
589
|
+
if (activeOnly) tasks = tasks.filter((task) => !TERMINAL.has(task.state))
|
|
590
|
+
tasks.sort((a, b) => {
|
|
591
|
+
const rank = (task) => (TERMINAL.has(task.state) ? 2 : task.stale ? 1 : 0)
|
|
592
|
+
const rankDelta = rank(a) - rank(b)
|
|
593
|
+
return rankDelta !== 0 ? rankDelta : b.taskId.localeCompare(a.taskId)
|
|
594
|
+
})
|
|
595
|
+
const activeCount = tasks.filter((task) => !TERMINAL.has(task.state)).length
|
|
596
|
+
const staleActiveCount = tasks.filter((task) => !TERMINAL.has(task.state) && task.stale).length
|
|
597
|
+
return ok({ count: Math.min(tasks.length, limit), activeCount, staleActiveCount, tasks: tasks.slice(0, limit) })
|
|
598
|
+
} catch (error) {
|
|
599
|
+
return hostError(error)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: 'delegate_gc',
|
|
605
|
+
title: 'Close stale delegation tasks on this machine',
|
|
606
|
+
description:
|
|
607
|
+
'Find non-terminal delegation tasks older than olderThanSeconds and optionally close them as state=cancelled with a gc summary. Default is a dry run; pass apply=true to write status.json. Read/write only under <fsRoot>/.mesh/tasks; no code, no process changes.',
|
|
608
|
+
inputSchema: z.object({
|
|
609
|
+
olderThanSeconds: z.number().int().min(60).max(30 * 24 * 3600).optional(),
|
|
610
|
+
apply: z.boolean().optional(),
|
|
611
|
+
limit: z.number().int().min(1).max(200).optional()
|
|
612
|
+
}),
|
|
613
|
+
handler: async ({ olderThanSeconds = 3600, apply = false, limit = 50 }) => {
|
|
614
|
+
try {
|
|
615
|
+
const root = localRoot(ctx.getConfig())
|
|
616
|
+
const dir = join(root, '.mesh', 'tasks')
|
|
617
|
+
if (!existsSync(dir)) return ok({ applied: apply, count: 0, tasks: [] })
|
|
618
|
+
const targets = []
|
|
619
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
620
|
+
if (!entry.isDirectory()) continue
|
|
621
|
+
const paths = taskPaths(root, entry.name)
|
|
622
|
+
const status = readJson(paths.status, null)
|
|
623
|
+
const state = status?.state || 'submitted'
|
|
624
|
+
if (TERMINAL.has(state)) continue
|
|
625
|
+
const age = taskAge(status, entry.name)
|
|
626
|
+
if (age.ageSeconds === null || age.ageSeconds < olderThanSeconds) continue
|
|
627
|
+
targets.push({ taskId: entry.name, state, ageSeconds: age.ageSeconds })
|
|
628
|
+
}
|
|
629
|
+
const closed = []
|
|
630
|
+
if (apply) {
|
|
631
|
+
for (const target of targets) {
|
|
632
|
+
const paths = taskPaths(root, target.taskId)
|
|
633
|
+
const status = readJson(paths.status, {}) || {}
|
|
634
|
+
const next = {
|
|
635
|
+
...status,
|
|
636
|
+
state: 'cancelled',
|
|
637
|
+
previousSummary: status.summary || '',
|
|
638
|
+
summary: `[gc] closed stale task after ${target.ageSeconds}s by ${ctx.peerId || 'peer'}`,
|
|
639
|
+
staleSeconds: target.ageSeconds,
|
|
640
|
+
updatedAt: new Date().toISOString()
|
|
641
|
+
}
|
|
642
|
+
writeJson(paths.status, next)
|
|
643
|
+
closed.push(target.taskId)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return ok({ applied: apply, thresholdSeconds: olderThanSeconds, count: targets.length, closed, tasks: targets.slice(0, limit) })
|
|
647
|
+
} catch (error) {
|
|
648
|
+
return hostError(error)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
]
|
|
653
|
+
}
|