dsh-collab-workbench 0.0.0-stage → 0.1.2
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.
Potentially problematic release.
This version of dsh-collab-workbench might be problematic. Click here for more details.
- package/LICENSE +202 -0
- package/README.md +112 -2
- package/client.js +2136 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +1 -0
- package/index.js +86 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +60 -3
- package/scripts/build-approvals.mjs +15 -0
- package/scripts/check.mjs +17 -0
- package/src/client/approval-ui.js +117 -0
- package/src/client/update-ui.js +73 -0
- package/src/host/agents.js +235 -0
- package/src/host/api.js +264 -0
- package/src/host/approvals-fold.js +34 -0
- package/src/host/approvals.js +36 -0
- package/src/host/channels.js +509 -0
- package/src/host/delivery.js +95 -0
- package/src/host/execution-engine.js +417 -0
- package/src/host/execution-tools.js +65 -0
- package/src/host/execution.js +274 -0
- package/src/host/files.js +89 -0
- package/src/host/groups.js +501 -0
- package/src/host/ledger.js +177 -0
- package/src/host/memory.js +175 -0
- package/src/host/policy.js +43 -0
- package/src/host/tasks.js +117 -0
- package/src/host/tools.js +491 -0
- package/src/host/update.js +135 -0
- package/src/host/verify.js +44 -0
- package/src/host/work-item-sync.js +101 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/** Replay only admission facts when recovering a cold Session. */
|
|
4
|
+
export function executionAdmission(events = []) {
|
|
5
|
+
const inbox = {'next-turn': [], 'next-step': []}, read = new Set(), legacy = new Set()
|
|
6
|
+
for (const event of events) {
|
|
7
|
+
if (event.type === 'user/message') {
|
|
8
|
+
const id = event.data?.source?.executionInputId
|
|
9
|
+
if (id) read.add(id)
|
|
10
|
+
} else if (event.type === 'agent/inbox/spliced' && inbox[event.data?.target]) {
|
|
11
|
+
const data = event.data
|
|
12
|
+
inbox[data.target].splice(data.start, data.removedCount ?? 0, ...(data.inserted ?? []))
|
|
13
|
+
} else if (event.type === 'inbox/inserted') {
|
|
14
|
+
const id = event.message?.source?.executionInputId
|
|
15
|
+
if (id) legacy.add(id)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const pending = new Set([...Object.values(inbox).flat().map(m => m.source?.executionInputId).filter(Boolean), ...legacy])
|
|
19
|
+
return {read, pending}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TERMINAL = new Set(['completed', 'cancelled'])
|
|
23
|
+
const ACTIVE = new Set(['starting', 'running', 'approval-wait'])
|
|
24
|
+
const REPORTS = new Set(['running', 'waiting', 'blocked', 'completed', 'failed'])
|
|
25
|
+
const PRIORITY = { 高: 0, high: 0, 中: 1, medium: 1, 低: 2, low: 2 }
|
|
26
|
+
const bounded = value => String(value ?? '').slice(0, 4000)
|
|
27
|
+
const clone = value => structuredClone(value)
|
|
28
|
+
const revision = task => JSON.stringify([task.title ?? '', task.description ?? '', task.dependencies ?? [], task.revision ?? 1])
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Durable scheduler. All mutations are serialized; transport callbacks must not
|
|
32
|
+
* await calls back into this engine. persist() must durably save getState().
|
|
33
|
+
* transport.deliver(run,input) must attach source.executionInputId = input.id to
|
|
34
|
+
* the admitted user message/inbox event. Unknown admission is at-least-once:
|
|
35
|
+
* after a crash an unlogged input may be sent again with the SAME id. The
|
|
36
|
+
* transport should also deduplicate this id where possible. ensureSession(run)
|
|
37
|
+
* should be idempotent by run.id (a crash can precede persisting its result).
|
|
38
|
+
* maxAttempts counts execution starts per automatic retry budget. Explicit
|
|
39
|
+
* retry resets it. canRun is a synchronous scheduling/delivery gate.
|
|
40
|
+
*/
|
|
41
|
+
export class ExecutionEngine {
|
|
42
|
+
constructor({ getState, persist, transport, now = Date.now, maxConcurrent = 3,
|
|
43
|
+
maxAttempts = 3, retryBaseMs = 1000, staleMs = 15 * 60 * 1000,
|
|
44
|
+
canRun = () => true } = {}) {
|
|
45
|
+
if (typeof getState !== 'function' || typeof persist !== 'function' || !transport)
|
|
46
|
+
throw new TypeError('getState, persist and transport are required')
|
|
47
|
+
for (const [key, value] of Object.entries({ maxConcurrent, maxAttempts })) {
|
|
48
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${key} must be a positive integer`)
|
|
49
|
+
}
|
|
50
|
+
for (const [key, value] of Object.entries({ retryBaseMs, staleMs })) {
|
|
51
|
+
if (!Number.isFinite(value) || value < 0) throw new TypeError(`${key} must be nonnegative`)
|
|
52
|
+
}
|
|
53
|
+
Object.assign(this, { getState, persist, transport, now, maxConcurrent, maxAttempts, retryBaseMs, staleMs, canRun })
|
|
54
|
+
this.tail = Promise.resolve()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_serial(fn) {
|
|
58
|
+
const result = this.tail.then(fn)
|
|
59
|
+
this.tail = result.catch(() => {})
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
_state() {
|
|
63
|
+
const state = this.getState()
|
|
64
|
+
state.runs ??= []; state.inputs ??= []; state.version ??= 1
|
|
65
|
+
return state
|
|
66
|
+
}
|
|
67
|
+
_touch(run) { run.updatedAt = this.now() }
|
|
68
|
+
_heartbeat(run) {
|
|
69
|
+
this._touch(run); run.lastActivityAt = this.now(); delete run.attention
|
|
70
|
+
}
|
|
71
|
+
_inputs(run) { return this._state().inputs.filter(input => input.runId === run.id) }
|
|
72
|
+
_addInput(run, text, kind, eventId) {
|
|
73
|
+
const inputs = this._state().inputs
|
|
74
|
+
if (eventId !== undefined) {
|
|
75
|
+
const prior = inputs.find(i => i.runId === run.id && i.eventId === eventId)
|
|
76
|
+
if (prior) return prior
|
|
77
|
+
}
|
|
78
|
+
const input = { id: randomUUID(), runId: run.id, taskId: run.taskId,
|
|
79
|
+
text: String(text ?? ''), kind, status: 'pending', attempts: 0,
|
|
80
|
+
nextAt: this.now(), createdAt: this.now() }
|
|
81
|
+
if (eventId !== undefined) input.eventId = eventId
|
|
82
|
+
inputs.push(input)
|
|
83
|
+
return input
|
|
84
|
+
}
|
|
85
|
+
_enqueue(task, { legacy = false, generation: requestedGeneration } = {}) {
|
|
86
|
+
const runs = this._state().runs
|
|
87
|
+
const previous = runs.filter(r => r.taskId === task.id)
|
|
88
|
+
const generation = requestedGeneration ?? Math.max(1, ...previous.map(r => r.generation || 1))
|
|
89
|
+
const created = []
|
|
90
|
+
for (const roleId of new Set(task.assignees ?? [])) {
|
|
91
|
+
if (previous.some(r => r.roleId === roleId && r.generation === generation && r.status !== 'cancelled')) continue
|
|
92
|
+
const run = { id: randomUUID(), taskId: task.id, groupId: task.groupId,
|
|
93
|
+
roleId, generation, sessionId: null, status: legacy ? 'paused' : 'queued',
|
|
94
|
+
attempts: 0, createdAt: this.now(), updatedAt: this.now(), lastActivityAt: this.now(),
|
|
95
|
+
summary: '', priority: task.priority, revision: revision(task), taskRevision: task.revision ?? 1,
|
|
96
|
+
title: String(task.title ?? ''), description: String(task.description ?? '') }
|
|
97
|
+
runs.push(run); created.push(run)
|
|
98
|
+
this._addInput(run, [task.title, task.description].filter(Boolean).join('\n'), 'assignment')
|
|
99
|
+
}
|
|
100
|
+
return created
|
|
101
|
+
}
|
|
102
|
+
enqueue(task, options = {}) {
|
|
103
|
+
return this._serial(async () => {
|
|
104
|
+
const created = this._enqueue(task, options)
|
|
105
|
+
await this.persist(); return clone(created)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
list(taskId) {
|
|
109
|
+
return clone(this._state().runs.filter(r => taskId === undefined || r.taskId === taskId))
|
|
110
|
+
}
|
|
111
|
+
input(taskId, text, { eventId, roleIds, kind = 'followup' } = {}) {
|
|
112
|
+
return this._serial(async () => {
|
|
113
|
+
const created = []
|
|
114
|
+
for (const run of this._state().runs) {
|
|
115
|
+
// Paused/blocked/failed runs need explicit retry, including user stops.
|
|
116
|
+
if (run.taskId !== taskId || !['queued', 'retry', 'starting', 'running', 'approval-wait', 'waiting'].includes(run.status)
|
|
117
|
+
|| (roleIds && !roleIds.includes(run.roleId))) continue
|
|
118
|
+
created.push(this._addInput(run, text, kind, eventId))
|
|
119
|
+
if (run.status === 'waiting') { run.status = 'queued'; run.attempts = 0; run.nextAt = this.now(); run.reminders = 0; this._heartbeat(run) }
|
|
120
|
+
}
|
|
121
|
+
await this.persist(); return clone(created)
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
_finish(run, status) {
|
|
125
|
+
run.approvalIds = []; run.status = status; this._touch(run)
|
|
126
|
+
for (const input of this._inputs(run)) {
|
|
127
|
+
if (status === 'cancelled' || !['read', 'cancelled'].includes(input.status)) input.status = 'cancelled'
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async _cancelSessions(runs) {
|
|
131
|
+
for (const run of runs) {
|
|
132
|
+
if (!run.sessionId) continue
|
|
133
|
+
try { await this.transport.cancel(clone(run)) }
|
|
134
|
+
catch (error) { run.cancelError = bounded(error?.message ?? error); await this.persist() }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
cancelTask(taskId) {
|
|
138
|
+
return this._serial(async () => {
|
|
139
|
+
const runs = this._state().runs.filter(r => r.taskId === taskId && !TERMINAL.has(r.status))
|
|
140
|
+
for (const run of runs) this._finish(run, 'cancelled')
|
|
141
|
+
await this.persist(); await this._cancelSessions(runs)
|
|
142
|
+
return clone(runs)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
pauseRole(groupId, roleId) {
|
|
146
|
+
return this._serial(async () => {
|
|
147
|
+
const runs = this._state().runs.filter(r => (!groupId || r.groupId === groupId) && r.roleId === roleId && !TERMINAL.has(r.status))
|
|
148
|
+
for (const run of runs) { run.approvalIds = []; run.status = 'paused'; run.summary = '用户停止或成员已停用'; this._touch(run) }
|
|
149
|
+
await this.persist(); await this._cancelSessions(runs)
|
|
150
|
+
return clone(runs)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
resolveDecision(taskId, text, {eventId, runId, commit = () => {}} = {}) {
|
|
154
|
+
return this._serial(async () => {
|
|
155
|
+
for (const run of this._state().runs.filter(r => r.taskId === taskId && (!runId || r.id === runId) && ['blocked','waiting'].includes(r.status))) {
|
|
156
|
+
this._addInput(run, text, 'decision', eventId)
|
|
157
|
+
run.status = 'queued'; run.nextAt = this.now(); run.attempts = 0; run.reported = false
|
|
158
|
+
this._heartbeat(run)
|
|
159
|
+
}
|
|
160
|
+
commit()
|
|
161
|
+
await this.persist()
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
retryTask(taskId) {
|
|
165
|
+
return this._serial(async () => {
|
|
166
|
+
const runs = this._state().runs.filter(r => r.taskId === taskId && ['failed', 'blocked', 'paused', 'waiting'].includes(r.status))
|
|
167
|
+
for (const run of runs) {
|
|
168
|
+
run.status = 'queued'; run.attempts = 0; run.nextAt = this.now(); run.reminders = 0
|
|
169
|
+
delete run.error; this._heartbeat(run)
|
|
170
|
+
for (const input of this._inputs(run)) {
|
|
171
|
+
if (['failed','sent','delivering'].includes(input.status)) { input.status = 'pending'; input.attempts = 0; input.nextAt = this.now() }
|
|
172
|
+
}
|
|
173
|
+
this._addInput(run, 'Explicit retry: resume this task and report status with evidence when complete.', 'recovery')
|
|
174
|
+
}
|
|
175
|
+
await this.persist(); return clone(runs)
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
report(runId, { status, summary, evidence = [] } = {}) {
|
|
179
|
+
return this._serial(async () => {
|
|
180
|
+
const run = this._state().runs.find(r => r.id === runId)
|
|
181
|
+
if (!run || TERMINAL.has(run.status) || run.status === 'paused') return false
|
|
182
|
+
if (run.status === 'approval-wait') throw new Error('Cannot report while approval is pending')
|
|
183
|
+
if (!REPORTS.has(status)) throw new TypeError('Unsupported execution report status')
|
|
184
|
+
if (status === 'completed' && (!String(summary ?? '').trim() || !Array.isArray(evidence)
|
|
185
|
+
|| !evidence.some(item => item != null && (typeof item !== 'string' || item.trim()))))
|
|
186
|
+
throw new TypeError('completed requires nonempty summary and evidence')
|
|
187
|
+
if (!Array.isArray(evidence)) throw new TypeError('evidence must be an array')
|
|
188
|
+
if (status === 'completed' && this._inputs(run).some(i => !['read','cancelled'].includes(i.status))) throw new Error('仍有未处理输入,请先处理新消息再完成任务')
|
|
189
|
+
run.status = status; run.summary = bounded(summary); run.evidence = clone(evidence)
|
|
190
|
+
run.reported = status !== 'running'; this._heartbeat(run)
|
|
191
|
+
if (status === 'completed') this._finish(run, status)
|
|
192
|
+
await this.persist(); return true
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
async _failure(run, error, input) {
|
|
196
|
+
run.error = bounded(error?.message ?? error)
|
|
197
|
+
run.summary = bounded(`Execution needs retry: ${run.error}`)
|
|
198
|
+
this._touch(run)
|
|
199
|
+
if (run.attempts >= this.maxAttempts) {
|
|
200
|
+
run.status = 'failed'
|
|
201
|
+
for (const i of this._inputs(run)) if (['pending', 'delivering'].includes(i.status)) i.status = 'failed'
|
|
202
|
+
} else {
|
|
203
|
+
run.status = 'retry'
|
|
204
|
+
run.nextAt = this.now() + this.retryBaseMs * 2 ** Math.min(30, Math.max(0, run.attempts - 1))
|
|
205
|
+
if (input) { input.status = 'pending'; input.nextAt = run.nextAt; input.error = run.error }
|
|
206
|
+
}
|
|
207
|
+
await this.persist()
|
|
208
|
+
}
|
|
209
|
+
async _deliver(run) {
|
|
210
|
+
if (run.status === 'approval-wait') return
|
|
211
|
+
for (const input of this._inputs(run)) {
|
|
212
|
+
if (input.status !== 'pending' || input.nextAt > this.now()) continue
|
|
213
|
+
input.status = 'delivering'; input.attempts++; this._touch(run)
|
|
214
|
+
await this.persist()
|
|
215
|
+
try { await this.transport.deliver(clone(run), clone(input)) }
|
|
216
|
+
catch (error) { await this._failure(run, error, input); return }
|
|
217
|
+
input.status = 'sent'; run.status = 'running'; this._heartbeat(run)
|
|
218
|
+
await this.persist()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
tick() {
|
|
222
|
+
return this._serial(async () => {
|
|
223
|
+
const runs = this._state().runs
|
|
224
|
+
let changed = false
|
|
225
|
+
for (const run of runs) {
|
|
226
|
+
if (run.status === 'approval-wait' || this.now() - run.lastActivityAt < this.staleMs) continue
|
|
227
|
+
if (ACTIVE.has(run.status)) {
|
|
228
|
+
if (run.attention === 'stale') continue
|
|
229
|
+
changed = true
|
|
230
|
+
run.attention = 'stale'; run.summary = 'No recent activity; worker may still be active. Review required.'
|
|
231
|
+
} else if (run.status === 'waiting' && !run.reported) {
|
|
232
|
+
changed = true
|
|
233
|
+
if ((run.reminders || 0) >= 1) {
|
|
234
|
+
run.status = 'blocked'; run.summary = 'No execution report after one continuation reminder.'
|
|
235
|
+
} else {
|
|
236
|
+
run.reminders = 1; run.status = 'queued'
|
|
237
|
+
this._addInput(run, 'Please continue or report waiting/blocked/completed with summary and evidence.', 'continuation')
|
|
238
|
+
}
|
|
239
|
+
this._heartbeat(run)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (changed) await this.persist()
|
|
243
|
+
const candidates = runs.filter(r => ['queued', 'retry'].includes(r.status) && (r.nextAt ?? 0) <= this.now())
|
|
244
|
+
.sort((a, b) => (PRIORITY[a.priority] ?? 1) - (PRIORITY[b.priority] ?? 1) || a.createdAt - b.createdAt)
|
|
245
|
+
for (const run of candidates) {
|
|
246
|
+
const active = runs.filter(r => ACTIVE.has(r.status))
|
|
247
|
+
if (active.length >= this.maxConcurrent) break
|
|
248
|
+
if (active.some(r => r.roleId === run.roleId) || !this.canRun(clone(run))) continue
|
|
249
|
+
if (run.attempts >= this.maxAttempts) { run.status = 'failed'; await this.persist(); continue }
|
|
250
|
+
run.status = 'starting'; run.attempts++; run.reported = false; this._heartbeat(run)
|
|
251
|
+
await this.persist()
|
|
252
|
+
if (!run.sessionId) {
|
|
253
|
+
let sessionId
|
|
254
|
+
try { sessionId = await this.transport.ensureSession(clone(run)) }
|
|
255
|
+
catch (error) { await this._failure(run, error); continue }
|
|
256
|
+
if (!sessionId || runs.some(r => r.id !== run.id && r.sessionId === sessionId)) {
|
|
257
|
+
await this._failure(run, new Error('ensureSession must return an independent session ID')); continue
|
|
258
|
+
}
|
|
259
|
+
run.sessionId = sessionId
|
|
260
|
+
await this.persist()
|
|
261
|
+
}
|
|
262
|
+
run.status = 'running'; await this.persist()
|
|
263
|
+
}
|
|
264
|
+
for (const run of runs) {
|
|
265
|
+
if (run.status === 'running' && this.canRun(clone(run))) await this._deliver(run)
|
|
266
|
+
}
|
|
267
|
+
return this.list()
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
reconcileTask(task) {
|
|
271
|
+
return this._serial(async () => {
|
|
272
|
+
const old = this._state().runs.filter(r => r.taskId === task.id)
|
|
273
|
+
const cancel = []
|
|
274
|
+
if (task.status === '已完成') {
|
|
275
|
+
for (const run of old.filter(r => !TERMINAL.has(r.status))) {
|
|
276
|
+
this._finish(run, 'completed'); run.completedExternally = true
|
|
277
|
+
run.summary = 'Task marked completed externally.'; cancel.push(run)
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
const generation = Math.max(1, ...old.map(r => r.generation || 1))
|
|
281
|
+
const current = old.filter(r => r.generation === generation)
|
|
282
|
+
const revised = current.some(r => r.revision !== revision(task)) || (current.length > 0 && current.every(r => r.status === 'completed'))
|
|
283
|
+
for (const run of old) {
|
|
284
|
+
if (!TERMINAL.has(run.status) && (revised || !(task.assignees ?? []).includes(run.roleId))) {
|
|
285
|
+
this._finish(run, 'cancelled'); cancel.push(run)
|
|
286
|
+
}
|
|
287
|
+
if (!revised) run.priority = task.priority
|
|
288
|
+
}
|
|
289
|
+
if (revised) {
|
|
290
|
+
this._enqueue(task, { generation: generation + 1 })
|
|
291
|
+
} else this._enqueue(task)
|
|
292
|
+
}
|
|
293
|
+
await this.persist(); await this._cancelSessions(cancel)
|
|
294
|
+
return this.list(task.id)
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
_markers(event) {
|
|
298
|
+
// Durable inbox events may wrap the admitted message in payload/messages.
|
|
299
|
+
const ids = new Set()
|
|
300
|
+
const visit = (node, depth) => {
|
|
301
|
+
if (!node || typeof node !== 'object' || depth > 10) return
|
|
302
|
+
if (node.source?.executionInputId) ids.add(node.source.executionInputId)
|
|
303
|
+
for (const value of Object.values(node)) {
|
|
304
|
+
if (value && typeof value === 'object') visit(value, depth + 1)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
visit(event, 0); return ids
|
|
308
|
+
}
|
|
309
|
+
async _event(run, event, admissionOnly = false) {
|
|
310
|
+
if (TERMINAL.has(run.status) || ['paused', 'blocked', 'failed'].includes(run.status)) return false
|
|
311
|
+
const type = event.type ?? event.kind
|
|
312
|
+
const markers = this._markers(event)
|
|
313
|
+
let admitted = false
|
|
314
|
+
const read = type === 'user/message'
|
|
315
|
+
for (const input of this._inputs(run)) {
|
|
316
|
+
if (markers.has(input.id) && input.status !== 'cancelled') {
|
|
317
|
+
if (read) { input.status = 'read'; input.readAt = event.time ?? this.now() }
|
|
318
|
+
else if (input.status !== 'read') input.status = 'sent'
|
|
319
|
+
admitted = true
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const replayed = Number.isInteger(event.seq) && event.seq <= (run.lastEventSeq ?? -1)
|
|
323
|
+
if (admissionOnly || replayed) return admitted
|
|
324
|
+
if (Number.isInteger(event.seq)) run.lastEventSeq = event.seq
|
|
325
|
+
if (admitted) { if (run.status !== 'approval-wait') run.status = 'running'; this._heartbeat(run) }
|
|
326
|
+
const data = event.data ?? {}
|
|
327
|
+
if (type === 'approval/asked' && typeof data.id === 'string' && data.id) {
|
|
328
|
+
run.approvalIds = [...new Set([...(run.approvalIds ?? []), data.id])]
|
|
329
|
+
run.status = 'approval-wait'; this._heartbeat(run)
|
|
330
|
+
} else if (type === 'approval/decided') {
|
|
331
|
+
run.approvalIds = (run.approvalIds ?? []).filter(id => id !== data.id)
|
|
332
|
+
if (run.status === 'approval-wait' && !run.approvalIds.length) run.status = 'running'
|
|
333
|
+
}
|
|
334
|
+
if (type === 'tool/result' || type === 'tool/call') this._heartbeat(run)
|
|
335
|
+
if (type === 'turn/end') {
|
|
336
|
+
run.approvalIds = []
|
|
337
|
+
if (run.status === 'approval-wait') run.status = 'running'
|
|
338
|
+
const status = event.data?.status ?? event.status ?? event.result?.status ?? event.outcome
|
|
339
|
+
const cause = event.data?.cause ?? event.cause ?? event.reason ?? event.result?.cause
|
|
340
|
+
if (status === 'aborted' && (cause === 'user' || cause === 'user-stop' || cause?.type === 'user')) {
|
|
341
|
+
run.status = 'paused'; run.summary = 'Stopped by user.'; this._heartbeat(run)
|
|
342
|
+
} else if (status === 'blocked') {
|
|
343
|
+
run.status = 'blocked'; this._heartbeat(run)
|
|
344
|
+
} else if (['error', 'interrupted', 'aborted', 'failed'].includes(status)) {
|
|
345
|
+
await this._failure(run, new Error(bounded(event.error?.message ?? event.error ?? status)))
|
|
346
|
+
if (run.status === 'retry') this._addInput(run, 'Continue interrupted work; report status and evidence.', 'recovery', `recovery:${run.attempts}`)
|
|
347
|
+
} else if (status === 'completed' || status === 'success' || status === undefined) {
|
|
348
|
+
// An ended turn is not a proof-backed task completion.
|
|
349
|
+
const pending = this._inputs(run).some(i => ['pending','delivering'].includes(i.status))
|
|
350
|
+
run.status = pending ? 'queued' : 'waiting'
|
|
351
|
+
if (pending) {run.attempts = 0;run.nextAt = this.now();run.reported = false}
|
|
352
|
+
this._heartbeat(run)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
357
|
+
event(sessionId, event) {
|
|
358
|
+
return this._serial(async () => {
|
|
359
|
+
const run = this._state().runs.find(r => r.sessionId === sessionId)
|
|
360
|
+
if (!run) return false
|
|
361
|
+
const accepted = await this._event(run, event)
|
|
362
|
+
if (accepted) await this.persist()
|
|
363
|
+
return accepted
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
recover(observations = new Map()) {
|
|
367
|
+
return this._serial(async () => {
|
|
368
|
+
for (const run of this._state().runs) {
|
|
369
|
+
if (TERMINAL.has(run.status) || ['paused', 'blocked', 'failed'].includes(run.status)) continue
|
|
370
|
+
const observation = observations.get(run.sessionId)
|
|
371
|
+
// Audit history is authoritative for pending IDs, not the persisted cache.
|
|
372
|
+
// This rebuild deliberately starts at the beginning, independent of lastEventSeq.
|
|
373
|
+
const pendingApprovals = new Set(), seenSeq = new Set()
|
|
374
|
+
for (const event of observation?.events ?? []) {
|
|
375
|
+
if (Number.isInteger(event.seq)) {
|
|
376
|
+
if (seenSeq.has(event.seq)) continue
|
|
377
|
+
seenSeq.add(event.seq)
|
|
378
|
+
}
|
|
379
|
+
if (event.type === 'approval/asked' && typeof event.data?.id === 'string' && event.data.id) pendingApprovals.add(event.data.id)
|
|
380
|
+
else if (event.type === 'approval/decided') pendingApprovals.delete(event.data?.id)
|
|
381
|
+
else if (event.type === 'turn/end') pendingApprovals.clear()
|
|
382
|
+
}
|
|
383
|
+
run.approvalIds = observation?.running ? [...pendingApprovals] : []
|
|
384
|
+
if (run.status === 'approval-wait') run.status = 'running'
|
|
385
|
+
for (const event of observation?.events ?? []) {
|
|
386
|
+
await this._event(run, event, true)
|
|
387
|
+
if (event.type === 'turn/end' && Number.isInteger(event.seq) && event.seq > (run.lastEventSeq ?? -1)) await this._event(run, event)
|
|
388
|
+
}
|
|
389
|
+
// Approval-only observations must also advance the live replay watermark.
|
|
390
|
+
for (const event of observation?.events ?? []) {
|
|
391
|
+
if (Number.isInteger(event.seq)) run.lastEventSeq = Math.max(run.lastEventSeq ?? -1, event.seq)
|
|
392
|
+
}
|
|
393
|
+
if (TERMINAL.has(run.status) || ['paused', 'blocked', 'failed'].includes(run.status)) continue
|
|
394
|
+
run.approvalIds = observation?.running ? [...pendingApprovals] : []
|
|
395
|
+
const proof = executionAdmission(observation?.events ?? [])
|
|
396
|
+
for (const input of this._inputs(run)) {
|
|
397
|
+
if (input.status === 'cancelled') continue
|
|
398
|
+
if (proof.read.has(input.id)) input.status = 'read'
|
|
399
|
+
else if (proof.pending.has(input.id)) input.status = 'sent'
|
|
400
|
+
else if (['delivering','sent'].includes(input.status)) { input.status = 'pending'; input.nextAt = this.now() }
|
|
401
|
+
}
|
|
402
|
+
if (observation?.running) {
|
|
403
|
+
run.status = run.approvalIds.length ? 'approval-wait' : 'running'; this._heartbeat(run)
|
|
404
|
+
} else if (ACTIVE.has(run.status)) {
|
|
405
|
+
if (run.attempts >= this.maxAttempts) {
|
|
406
|
+
run.status = 'failed'; run.error = 'Restart recovery attempt budget exhausted.'
|
|
407
|
+
} else {
|
|
408
|
+
run.status = 'queued'; run.nextAt = this.now()
|
|
409
|
+
this._addInput(run, 'Recover interrupted execution; inspect prior work before continuing. Report status and evidence.', 'recovery', `recovery:${run.attempts}`)
|
|
410
|
+
}
|
|
411
|
+
this._touch(run)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
await this.persist(); return this.list()
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
const OUTPUT = {schema: {type: 'string'}, render: (_args, value) => [{type: 'text', text: String(value)}]}
|
|
3
|
+
|
|
4
|
+
export function install(rt) {
|
|
5
|
+
const register = definition => rt.ctx.tools.register(defineTool({...definition, output: OUTPUT}))
|
|
6
|
+
register({
|
|
7
|
+
name: 'dwb_task_execution',
|
|
8
|
+
description: '查看任务执行状态、结果依据和近期输入;按输入 id 分页读取完整内容。',
|
|
9
|
+
parameters: {
|
|
10
|
+
taskId: {type: 'string', description: '任务 id;省略时读取当前执行任务'},
|
|
11
|
+
offset: {type: 'integer', description: '执行记录偏移,默认 0;每页最多 20 条'},
|
|
12
|
+
inputId: {type: 'string', description: '从执行输入摘要取得的 id;设置后读取该输入正文'},
|
|
13
|
+
inputOffset: {type: 'integer', description: '输入正文的字符偏移,默认 0;每页最多 4000 字符'},
|
|
14
|
+
},
|
|
15
|
+
async execute(args) {
|
|
16
|
+
await rt.executionReady
|
|
17
|
+
const caller = rt.currentExecution(), taskId = args.taskId ?? caller?.taskId
|
|
18
|
+
if (caller && taskId !== caller.taskId) throw new Error('当前执行只能读取自己的任务')
|
|
19
|
+
const inputs = rt.state.execution.inputs ?? []
|
|
20
|
+
if (args.inputId) {
|
|
21
|
+
const input = inputs.find(i => i.id === args.inputId && (!taskId || i.taskId === taskId))
|
|
22
|
+
if (!input || (caller && input.runId !== caller.id)) throw new Error('输入不存在或不属于当前执行')
|
|
23
|
+
const offset = Math.max(0, Number(args.inputOffset) || 0)
|
|
24
|
+
return JSON.stringify({id: input.id, taskId: input.taskId, text: input.text.slice(offset, offset + 4000), nextInputOffset: offset + 4000 < input.text.length ? offset + 4000 : null})
|
|
25
|
+
}
|
|
26
|
+
const runs = rt.executionRuns(taskId), offset = Math.max(0, Number(args.offset) || 0)
|
|
27
|
+
const items = runs.slice(offset, offset + 20).map(run => ({
|
|
28
|
+
id: run.id, taskId: run.taskId, roleId: run.roleId, generation: run.generation,
|
|
29
|
+
sessionId: run.sessionId, status: run.status, attempts: run.attempts, summary: run.summary,
|
|
30
|
+
evidence: run.evidence, error: run.error, attention: run.attention,
|
|
31
|
+
inputs: inputs.filter(i => i.runId === run.id && (!caller || run.id === caller.id)).slice(-10).map(i => ({id: i.id, kind: i.kind, status: i.status, preview: i.text.slice(0, 400), truncated: i.text.length > 400})),
|
|
32
|
+
}))
|
|
33
|
+
return JSON.stringify({error: rt.executionError ?? null, taskId, items, nextOffset: offset + 20 < runs.length ? offset + 20 : null})
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
register({
|
|
37
|
+
name: 'dwb_report_execution',
|
|
38
|
+
description: '保存自己的执行状态、完整摘要与证据。系统仅生成简短完成/等待回执或异常提醒,执行中不刷群;不要重复发群消息。多人全部完成后自动关闭任务。',
|
|
39
|
+
parameters: {
|
|
40
|
+
runId: {type: 'string', description: '当前独立执行 id', required: true},
|
|
41
|
+
status: {type: 'string', enum: ['running', 'waiting', 'blocked', 'completed', 'failed'], description: '执行中、等待后续、阻塞、完成或失败', required: true},
|
|
42
|
+
summary: {type: 'string', description: '进展、结果或阻塞原因', required: true},
|
|
43
|
+
evidence: {type: 'array', items: {type: 'string'}, description: '完成时必填:成果路径、验证结果或可核验引用'},
|
|
44
|
+
},
|
|
45
|
+
async execute(args, exec) {
|
|
46
|
+
const result = await rt.reportExecution(args)
|
|
47
|
+
if (args.status !== 'running') exec?.concludeTurn?.()
|
|
48
|
+
return JSON.stringify({runId: result.id, taskId: result.taskId, status: result.status, summary: result.summary})
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
register({
|
|
52
|
+
name: 'dwb_manage_execution',
|
|
53
|
+
description: '恢复暂停、阻塞或失败的任务,或停止其全部指派成员;已完成记录不会自动重跑。',
|
|
54
|
+
parameters: {
|
|
55
|
+
taskId: {type: 'string', description: '任务 id', required: true},
|
|
56
|
+
action: {type: 'string', enum: ['retry', 'stop'], description: 'retry 恢复;stop 终止执行,终止后需重新派发任务', required: true},
|
|
57
|
+
},
|
|
58
|
+
async execute(args) {
|
|
59
|
+
await rt.executionReady
|
|
60
|
+
if (rt.currentExecution()) throw new Error('执行成员不能自行重启或停止任务;请向管理员报告')
|
|
61
|
+
const runs = args.action === 'retry' ? await rt.retryExecution(args.taskId) : await rt.stopTaskExecution(args.taskId)
|
|
62
|
+
return JSON.stringify({taskId: args.taskId, runs: runs.map(r => ({runId: r.id, roleId: r.roleId, status: r.status}))})
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
}
|