dsh-remote-agent 0.0.0-stage → 0.4.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/CHANGELOG.md +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +785 -0
- package/scripts/check-packaging.mjs +130 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 副机任务仓库:**任务状态的真身在副机**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用 hub 侧当权威:
|
|
5
|
+
* - 任务在副机本地持久运行,断连不终止(核心不变式)
|
|
6
|
+
* - 如果 hub 是权威,断连期间 hub 不知道任务进展,重连后要"补齐",很容易补齐错
|
|
7
|
+
* - 副机当权威后,重连只需 node → hub 一条 task/sync,hub 重建视图即可
|
|
8
|
+
*
|
|
9
|
+
* 存储:`<stateDir>/tasks/<taskId>.json`,原子写(写 .tmp 再 rename),
|
|
10
|
+
* 崩溃后最多丢最后一次进度,不会出现半截 JSON。
|
|
11
|
+
*
|
|
12
|
+
* 落盘策略(2026-10-15 热路径改造):
|
|
13
|
+
* - **状态转移 / 创建 → 同步落盘**。耐久性语义要求:终态记录必须在进程
|
|
14
|
+
* 存活期间落盘,否则崩溃后 reconcile 会把未落盘的 completed 当成
|
|
15
|
+
* interrupted 重跑 —— 任务被执行两次。
|
|
16
|
+
* - **日志 / 进度 → 批量写回**。这两类是高频率噪声(一条命令几十行输出),
|
|
17
|
+
* 此前的实现是每行都全量重写整个记录文件(含最多 500 行 × 4000 字符的
|
|
18
|
+
* 日志尾巴,单文件可达 2MB)—— 同步 fs 直接阻塞副机事件循环,而副机
|
|
19
|
+
* 同时还要跑子进程 I/O 和 ws 帧。改为 200ms 合并窗口的写回,
|
|
20
|
+
* `flush()` / `close()` 可强制立即落盘。
|
|
21
|
+
*
|
|
22
|
+
* 幂等:taskId 重复 → 直接返回已有记录,不重跑。
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { TASK_STATUS, TERMINAL_STATUS, newTaskId } from '../protocol.js'
|
|
27
|
+
|
|
28
|
+
/** 批量写回的合并窗口。日志/进度在这个窗口内的多次更新只落盘一次。 */
|
|
29
|
+
const FLUSH_INTERVAL_MS = 200
|
|
30
|
+
|
|
31
|
+
function atomicWriteJson(file, value) {
|
|
32
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
33
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
|
|
34
|
+
fs.renameSync(tmp, file)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TaskStore {
|
|
38
|
+
constructor(stateDir) {
|
|
39
|
+
this.dir = path.join(stateDir, 'tasks')
|
|
40
|
+
fs.mkdirSync(this.dir, { recursive: true })
|
|
41
|
+
/** @type {Map<string, object>} 内存镜像,热路径不读盘 */
|
|
42
|
+
this.cache = new Map()
|
|
43
|
+
/** @type {Set<string>} 待写回的 taskId(批量窗口内的脏记录) */
|
|
44
|
+
this.dirty = new Set()
|
|
45
|
+
this.flushTimer = null
|
|
46
|
+
this.closed = false
|
|
47
|
+
this.loadAll()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
loadAll() {
|
|
51
|
+
let names
|
|
52
|
+
try {
|
|
53
|
+
names = fs.readdirSync(this.dir)
|
|
54
|
+
} catch {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
for (const n of names) {
|
|
58
|
+
if (!n.endsWith('.json')) continue
|
|
59
|
+
try {
|
|
60
|
+
const rec = JSON.parse(fs.readFileSync(path.join(this.dir, n), 'utf8'))
|
|
61
|
+
if (rec && typeof rec.taskId === 'string') this.cache.set(rec.taskId, rec)
|
|
62
|
+
} catch {
|
|
63
|
+
// 单条损坏不影响其他任务:跳过,保留文件供人工排查
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fileFor(taskId) {
|
|
69
|
+
// taskId 已由协议层校验过格式;这里再做一次防目录穿越
|
|
70
|
+
const safe = String(taskId).replace(/[^A-Za-z0-9._-]/g, '_')
|
|
71
|
+
return path.join(this.dir, `${safe}.json`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get(taskId) {
|
|
75
|
+
return this.cache.get(taskId)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
has(taskId) {
|
|
79
|
+
return this.cache.has(taskId)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
list() {
|
|
83
|
+
return [...this.cache.values()].sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 正在跑或排队中的任务(重连后要上报的那些)。 */
|
|
87
|
+
active() {
|
|
88
|
+
return this.list().filter((t) => !TERMINAL_STATUS.has(t.status))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 创建一条任务记录。已存在则原样返回(幂等)。 */
|
|
92
|
+
create(spec, { nodeId, workspace }) {
|
|
93
|
+
const existing = this.cache.get(spec.taskId)
|
|
94
|
+
if (existing) return { created: false, record: existing }
|
|
95
|
+
|
|
96
|
+
const record = {
|
|
97
|
+
taskId: spec.taskId,
|
|
98
|
+
nodeId,
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
updatedAt: Date.now(),
|
|
101
|
+
status: TASK_STATUS.QUEUED,
|
|
102
|
+
mode: spec.mode,
|
|
103
|
+
goal: spec.goal,
|
|
104
|
+
workspace: spec.workspace ?? workspace,
|
|
105
|
+
// 主机指定的模型选择(可选,normalizeModelSelection 已归一)。
|
|
106
|
+
// 必须落盘:agent 任务可能在重启后 reconcile 重跑,模型选择要跟任务走。
|
|
107
|
+
model: spec.model ?? null,
|
|
108
|
+
constraints: spec.constraints,
|
|
109
|
+
outputs: spec.outputs,
|
|
110
|
+
artifactRules: spec.artifactRules ?? null,
|
|
111
|
+
summaryPolicy: spec.summaryPolicy ?? 'on-complete',
|
|
112
|
+
origin: spec.origin ?? 'hub',
|
|
113
|
+
startedAt: null,
|
|
114
|
+
endedAt: null,
|
|
115
|
+
attempts: 0,
|
|
116
|
+
progress: [],
|
|
117
|
+
logs: [],
|
|
118
|
+
sessionId: null,
|
|
119
|
+
manifest: null,
|
|
120
|
+
summary: null,
|
|
121
|
+
error: null,
|
|
122
|
+
exit: null,
|
|
123
|
+
changed: [],
|
|
124
|
+
}
|
|
125
|
+
this.cache.set(record.taskId, record)
|
|
126
|
+
this.persist(record)
|
|
127
|
+
return { created: true, record }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 同步落盘:创建 / 状态转移 / 关闭前。立即写,不等批量窗口。 */
|
|
131
|
+
persist(record) {
|
|
132
|
+
record.updatedAt = Date.now()
|
|
133
|
+
atomicWriteJson(this.fileFor(record.taskId), record)
|
|
134
|
+
this.dirty.delete(record.taskId)
|
|
135
|
+
return record
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 批量写回调度:日志 / 进度等高频走这里。
|
|
140
|
+
*
|
|
141
|
+
* 只更新内存镜像 + 标记脏;真正的磁盘写由 `flush()` 在合并窗口到期时
|
|
142
|
+
* (或显式调用 / 进程关闭时)完成。窗口内同一任务的 N 次更新只写一次盘。
|
|
143
|
+
*/
|
|
144
|
+
persistAsync(record) {
|
|
145
|
+
if (this.closed) return this.persist(record)
|
|
146
|
+
record.updatedAt = Date.now()
|
|
147
|
+
this.dirty.add(record.taskId)
|
|
148
|
+
if (!this.flushTimer) {
|
|
149
|
+
this.flushTimer = setTimeout(() => this.flush(), FLUSH_INTERVAL_MS)
|
|
150
|
+
// 不拿定时器吊住进程:副机退出不该被一个待写回的 flush 挡住
|
|
151
|
+
this.flushTimer.unref?.()
|
|
152
|
+
}
|
|
153
|
+
return record
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 把所有脏记录同步写盘。写失败的记录留在脏集合里等下次重试
|
|
158
|
+
* (不抛:落盘失败不该炸掉任务流,最多是崩溃时多丢一份进度)。
|
|
159
|
+
* @returns {number} 实际写掉的记录数
|
|
160
|
+
*/
|
|
161
|
+
flush() {
|
|
162
|
+
if (this.flushTimer) {
|
|
163
|
+
clearTimeout(this.flushTimer)
|
|
164
|
+
this.flushTimer = null
|
|
165
|
+
}
|
|
166
|
+
if (this.dirty.size === 0) return 0
|
|
167
|
+
const ids = [...this.dirty]
|
|
168
|
+
this.dirty.clear()
|
|
169
|
+
let written = 0
|
|
170
|
+
for (const id of ids) {
|
|
171
|
+
const rec = this.cache.get(id)
|
|
172
|
+
if (!rec) continue
|
|
173
|
+
try {
|
|
174
|
+
atomicWriteJson(this.fileFor(id), rec)
|
|
175
|
+
written += 1
|
|
176
|
+
} catch {
|
|
177
|
+
this.dirty.add(id)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return written
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 进程退出前调用:停批量窗口并全部落盘。 */
|
|
184
|
+
close() {
|
|
185
|
+
this.closed = true
|
|
186
|
+
return this.flush()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 合并式更新:只覆盖传入字段,其余保持。 */
|
|
190
|
+
update(taskId, patch) {
|
|
191
|
+
const rec = this.cache.get(taskId)
|
|
192
|
+
if (!rec) return undefined
|
|
193
|
+
// 旧状态必须在 assign **之前**捕获(assign 后就取不到了)
|
|
194
|
+
const prevStatus = rec.status
|
|
195
|
+
Object.assign(rec, patch)
|
|
196
|
+
// 状态转移与终态时间戳走同步落盘(耐久性语义,见文件头):
|
|
197
|
+
// 终态记录若只躺在批量窗口里,崩溃后 reconcile 会把未落盘的 completed
|
|
198
|
+
// 判成 interrupted 重跑 —— 任务执行两次。其余字段走批量。
|
|
199
|
+
const statusChanged = patch.status !== undefined && patch.status !== prevStatus
|
|
200
|
+
const terminal = patch.endedAt !== undefined
|
|
201
|
+
return statusChanged || terminal ? this.persist(rec) : this.persistAsync(rec)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
appendProgress(taskId, entry, { maxEntries = 200 } = {}) {
|
|
205
|
+
const rec = this.cache.get(taskId)
|
|
206
|
+
if (!rec) return undefined
|
|
207
|
+
rec.progress.push({ ...entry, at: Date.now() })
|
|
208
|
+
if (rec.progress.length > maxEntries) rec.progress.splice(0, rec.progress.length - maxEntries)
|
|
209
|
+
return this.persistAsync(rec)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
appendLog(taskId, line, { maxLines = 500, maxChars = 200_000 } = {}) {
|
|
213
|
+
const rec = this.cache.get(taskId)
|
|
214
|
+
if (!rec) return undefined
|
|
215
|
+
const text = String(line).replace(/\r?\n$/, '')
|
|
216
|
+
if (text === '') return rec
|
|
217
|
+
rec.logs.push({ at: Date.now(), line: text.slice(0, 4000) })
|
|
218
|
+
if (rec.logs.length > maxLines) rec.logs.splice(0, rec.logs.length - maxLines)
|
|
219
|
+
let total = rec.logs.reduce((n, l) => n + l.line.length, 0)
|
|
220
|
+
while (total > maxChars && rec.logs.length > 1) {
|
|
221
|
+
total -= rec.logs.shift().line.length
|
|
222
|
+
}
|
|
223
|
+
return this.persistAsync(rec)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 供 hub 读的摘要视图(不泄露副机内部路径以外的信息)。 */
|
|
227
|
+
view(rec) {
|
|
228
|
+
if (!rec) return undefined
|
|
229
|
+
return {
|
|
230
|
+
taskId: rec.taskId,
|
|
231
|
+
nodeId: rec.nodeId,
|
|
232
|
+
status: rec.status,
|
|
233
|
+
mode: rec.mode,
|
|
234
|
+
goal: rec.goal,
|
|
235
|
+
workspace: rec.workspace,
|
|
236
|
+
createdAt: rec.createdAt,
|
|
237
|
+
updatedAt: rec.updatedAt,
|
|
238
|
+
startedAt: rec.startedAt,
|
|
239
|
+
endedAt: rec.endedAt,
|
|
240
|
+
durationMs: rec.startedAt ? (rec.endedAt ?? Date.now()) - rec.startedAt : null,
|
|
241
|
+
sessionId: rec.sessionId,
|
|
242
|
+
exit: rec.exit,
|
|
243
|
+
changed: rec.changed,
|
|
244
|
+
error: rec.error,
|
|
245
|
+
summary: rec.summary,
|
|
246
|
+
manifestCounts: rec.manifest?.counts ?? null,
|
|
247
|
+
progressTail: rec.progress.slice(-10),
|
|
248
|
+
logTail: rec.logs.slice(-40).map((l) => l.line),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 删除终态记录(保留 running 的)。 */
|
|
253
|
+
prune({ olderThanMs = 7 * 24 * 60 * 60 * 1000 } = {}) {
|
|
254
|
+
const cutoff = Date.now() - olderThanMs
|
|
255
|
+
let removed = 0
|
|
256
|
+
for (const rec of [...this.cache.values()]) {
|
|
257
|
+
if (TERMINAL_STATUS.has(rec.status) && (rec.endedAt ?? rec.updatedAt) < cutoff) {
|
|
258
|
+
try {
|
|
259
|
+
fs.unlinkSync(this.fileFor(rec.taskId))
|
|
260
|
+
} catch {
|
|
261
|
+
/* 已经不在盘上 */
|
|
262
|
+
}
|
|
263
|
+
this.cache.delete(rec.taskId)
|
|
264
|
+
removed += 1
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return removed
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
static newId() {
|
|
271
|
+
return newTaskId()
|
|
272
|
+
}
|
|
273
|
+
}
|