chatccc 0.2.270 → 0.2.272
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/README.md +20 -16
- package/config.sample.json +1 -0
- package/deepccc-agent/README.md +19 -1
- package/deepccc-agent/package.json +65 -65
- package/dist/deepccc-agent/src/cli.js +5 -0
- package/dist/deepccc-agent/src/config.js +8 -0
- package/dist/deepccc-agent/src/index.js +20 -5
- package/dist/src/adapters/ccc-adapter.js +4 -1
- package/dist/src/agent-team/application/task-execution-service.js +330 -97
- package/dist/src/agent-team/domain/task-run.js +14 -1
- package/dist/src/agent-team/infrastructure/task-execution-runtime.js +7 -2
- package/dist/src/agent-team/main-agent-bootstrap.js +24 -1
- package/dist/src/agent-team/repositories/json-task-run-repository.js +22 -4
- package/dist/src/agent-team/web/agent-team-page.js +250 -243
- package/dist/src/config.js +12 -0
- package/dist/src/safe-maintenance.js +4 -1
- package/dist/src/session.js +11 -2
- package/dist/src/web-ui.js +31 -4
- package/package.json +76 -76
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { isActiveTaskRun } from "../domain/task-run.js";
|
|
2
|
+
import { isActiveTaskRun, } from "../domain/task-run.js";
|
|
3
3
|
import { BoardStoreError } from "../repositories/board-repository.js";
|
|
4
4
|
import { beginSafeMaintenanceTrackedWork, isSafeMaintenanceAdmissionClosed, } from "../../safe-maintenance.js";
|
|
5
5
|
export class TaskExecutionService {
|
|
6
6
|
options;
|
|
7
7
|
now;
|
|
8
8
|
idFactory;
|
|
9
|
+
stopTimeoutMs;
|
|
10
|
+
staleAfterMs;
|
|
9
11
|
projectOperations = new Map();
|
|
10
12
|
executions = new Map();
|
|
13
|
+
pendingReconciliationProjects = new Set();
|
|
11
14
|
constructor(options) {
|
|
12
15
|
this.options = options;
|
|
13
16
|
this.now = options.now ?? (() => new Date());
|
|
14
17
|
this.idFactory = options.idFactory ?? randomUUID;
|
|
18
|
+
this.stopTimeoutMs = options.stopTimeoutMs ?? 30_000;
|
|
19
|
+
this.staleAfterMs = options.staleAfterMs ?? 5 * 60_000;
|
|
15
20
|
}
|
|
16
21
|
async listRuns(projectId) {
|
|
17
22
|
const runs = await this.options.repository.listByProject(projectId);
|
|
@@ -22,85 +27,101 @@ export class TaskExecutionService {
|
|
|
22
27
|
if (!run || run.projectId !== projectId) {
|
|
23
28
|
throw new BoardStoreError("task_run_not_found", "找不到这次任务执行记录", 404);
|
|
24
29
|
}
|
|
25
|
-
if (!isActiveTaskRun(run) || !this.options.runtime.
|
|
30
|
+
if (!isActiveTaskRun(run) || !this.options.runtime.getSnapshot)
|
|
26
31
|
return run;
|
|
27
|
-
const
|
|
28
|
-
return
|
|
32
|
+
const snapshot = await this.options.runtime.getSnapshot(run.sessionId).catch(() => null);
|
|
33
|
+
return snapshot?.transcript.length
|
|
34
|
+
? { ...run, transcript: combineTranscript(run.transcript, snapshot.transcript) }
|
|
35
|
+
: run;
|
|
29
36
|
}
|
|
30
37
|
async startTask(projectId, taskId, expectedRevision) {
|
|
31
38
|
if (isSafeMaintenanceAdmissionClosed()) {
|
|
32
39
|
throw new BoardStoreError("safe_maintenance_draining", "ChatCCC 正在等待安全维护,暂不接受新的 Agent Team 任务。", 409);
|
|
33
40
|
}
|
|
34
|
-
const release = beginSafeMaintenanceTrackedWork("agent-team-task-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
throw new BoardStoreError("revision_conflict", `Board changed in another page (expected revision ${expectedRevision}, current ${board.revision})`, 409);
|
|
45
|
-
}
|
|
46
|
-
const task = board.tasks.find((candidate) => candidate.id === taskId && !candidate.deletedAt);
|
|
47
|
-
if (!task)
|
|
48
|
-
throw new BoardStoreError("not_found", `Task not found: ${taskId}`, 404);
|
|
49
|
-
const binding = await this.options.getBinding(projectId);
|
|
50
|
-
if (!binding || binding.status !== "ready" || !binding.chatId || !binding.sessionId) {
|
|
51
|
-
throw new BoardStoreError("main_agent_unavailable", "请先为项目设置可用的主 Agent", 409);
|
|
52
|
-
}
|
|
53
|
-
if (this.options.runtime.isSessionRunning(binding.sessionId)) {
|
|
54
|
-
throw new BoardStoreError("task_run_busy", "主 Agent 正在处理其他消息,请稍后再试", 409);
|
|
55
|
-
}
|
|
56
|
-
const priorRuns = await this.options.repository.listByProject(projectId);
|
|
57
|
-
const timestamp = this.now().toISOString();
|
|
58
|
-
let run = {
|
|
59
|
-
schemaVersion: 1,
|
|
60
|
-
runId: this.idFactory(),
|
|
61
|
-
projectId,
|
|
62
|
-
taskId,
|
|
63
|
-
taskTitle: task.title,
|
|
64
|
-
taskDescription: task.description,
|
|
65
|
-
attempt: priorRuns.filter((candidate) => candidate.taskId === taskId).length + 1,
|
|
66
|
-
state: "queued",
|
|
67
|
-
agentId: binding.agentId,
|
|
68
|
-
chatId: binding.chatId,
|
|
69
|
-
sessionId: binding.sessionId,
|
|
70
|
-
createdAt: timestamp,
|
|
71
|
-
updatedAt: timestamp,
|
|
72
|
-
};
|
|
73
|
-
run = {
|
|
74
|
-
...run,
|
|
75
|
-
transcript: [{ type: "prompt", at: timestamp, text: taskPrompt(run) }],
|
|
76
|
-
};
|
|
77
|
-
await this.options.repository.save(run);
|
|
78
|
-
try {
|
|
79
|
-
if (task.columnId !== "doing") {
|
|
80
|
-
const doingCount = board.tasks.filter((candidate) => !candidate.deletedAt && candidate.columnId === "doing").length;
|
|
81
|
-
board = await this.options.boardService.moveTask(projectId, taskId, {
|
|
82
|
-
expectedRevision: board.revision,
|
|
83
|
-
columnId: "doing",
|
|
84
|
-
index: doingCount,
|
|
85
|
-
});
|
|
41
|
+
const release = beginSafeMaintenanceTrackedWork("agent-team-task-run");
|
|
42
|
+
let releaseOwnedByExecution = false;
|
|
43
|
+
try {
|
|
44
|
+
const result = await this.exclusive(projectId, async () => {
|
|
45
|
+
let board = await this.options.boardService.getBoard(projectId);
|
|
46
|
+
const active = (await this.options.repository.listByProject(projectId)).find(isActiveTaskRun);
|
|
47
|
+
if (active) {
|
|
48
|
+
if (active.taskId === taskId)
|
|
49
|
+
return { board, run: active };
|
|
50
|
+
throw new BoardStoreError("task_run_busy", "该项目已有任务正在由主 Agent 执行", 409);
|
|
86
51
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
52
|
+
if (board.revision !== expectedRevision) {
|
|
53
|
+
throw new BoardStoreError("revision_conflict", `Board changed in another page (expected revision ${expectedRevision}, current ${board.revision})`, 409);
|
|
54
|
+
}
|
|
55
|
+
const task = board.tasks.find((candidate) => candidate.id === taskId && !candidate.deletedAt);
|
|
56
|
+
if (!task)
|
|
57
|
+
throw new BoardStoreError("not_found", `Task not found: ${taskId}`, 404);
|
|
58
|
+
const binding = await this.options.getBinding(projectId);
|
|
59
|
+
if (!binding || binding.status !== "ready" || !binding.chatId || !binding.sessionId) {
|
|
60
|
+
throw new BoardStoreError("main_agent_unavailable", "请先为项目设置可用的主 Agent", 409);
|
|
61
|
+
}
|
|
62
|
+
if (this.options.runtime.isSessionRunning(binding.sessionId)) {
|
|
63
|
+
throw new BoardStoreError("task_run_busy", "主 Agent 正在处理其他消息,请稍后再试", 409);
|
|
64
|
+
}
|
|
65
|
+
const priorRuns = await this.options.repository.listByProject(projectId);
|
|
66
|
+
const timestamp = this.now().toISOString();
|
|
67
|
+
const runId = this.idFactory();
|
|
68
|
+
let run = {
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
runId,
|
|
71
|
+
projectId,
|
|
72
|
+
taskId,
|
|
73
|
+
taskTitle: task.title,
|
|
74
|
+
taskDescription: task.description,
|
|
75
|
+
attempt: priorRuns.filter((candidate) => candidate.taskId === taskId).length + 1,
|
|
76
|
+
state: "queued",
|
|
77
|
+
agentId: binding.agentId,
|
|
78
|
+
chatId: binding.chatId,
|
|
79
|
+
sessionId: binding.sessionId,
|
|
80
|
+
createdAt: timestamp,
|
|
81
|
+
updatedAt: timestamp,
|
|
82
|
+
traceId: `agent-team-${runId}`,
|
|
83
|
+
lastProgressAt: timestamp,
|
|
84
|
+
};
|
|
85
|
+
run = {
|
|
86
|
+
...run,
|
|
87
|
+
transcript: [{ type: "prompt", at: timestamp, text: taskPrompt(run) }],
|
|
88
|
+
};
|
|
89
|
+
await this.options.repository.save(run);
|
|
90
|
+
try {
|
|
91
|
+
if (task.columnId !== "doing") {
|
|
92
|
+
const doingCount = board.tasks.filter((candidate) => !candidate.deletedAt && candidate.columnId === "doing").length;
|
|
93
|
+
board = await this.options.boardService.moveTask(projectId, taskId, {
|
|
94
|
+
expectedRevision: board.revision,
|
|
95
|
+
columnId: "doing",
|
|
96
|
+
index: doingCount,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
run = await this.finishRun(run, "failed", { error: err.message });
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
const startedAt = this.now().toISOString();
|
|
105
|
+
run = { ...run, state: "running", startedAt, updatedAt: startedAt };
|
|
106
|
+
await this.options.repository.save(run);
|
|
107
|
+
logTaskRunEvent("started", run);
|
|
108
|
+
const execution = this.execute(run).finally(() => {
|
|
109
|
+
this.executions.delete(run.runId);
|
|
110
|
+
release();
|
|
111
|
+
});
|
|
112
|
+
releaseOwnedByExecution = true;
|
|
113
|
+
this.executions.set(run.runId, execution);
|
|
114
|
+
void execution.catch((err) => {
|
|
115
|
+
console.error(`[Agent Team] Task run ${run.runId} failed to persist its terminal state: ${err.message}`);
|
|
116
|
+
});
|
|
117
|
+
return { board, run };
|
|
101
118
|
});
|
|
102
|
-
return
|
|
103
|
-
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (!releaseOwnedByExecution)
|
|
123
|
+
release();
|
|
124
|
+
}
|
|
104
125
|
}
|
|
105
126
|
async stopRun(projectId, runId) {
|
|
106
127
|
return this.exclusive(projectId, async () => {
|
|
@@ -110,11 +131,19 @@ export class TaskExecutionService {
|
|
|
110
131
|
if (!isActiveTaskRun(run))
|
|
111
132
|
return run;
|
|
112
133
|
const now = this.now().toISOString();
|
|
113
|
-
const updated = {
|
|
134
|
+
const updated = {
|
|
135
|
+
...run,
|
|
136
|
+
stopRequestedAt: run.stopRequestedAt ?? now,
|
|
137
|
+
stopDeadlineAt: run.stopDeadlineAt ?? new Date(Date.parse(now) + this.stopTimeoutMs).toISOString(),
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
};
|
|
114
140
|
await this.options.repository.save(updated);
|
|
115
141
|
const stopped = this.options.runtime.stop(run.sessionId);
|
|
116
142
|
if (!stopped && !this.options.runtime.isSessionRunning(run.sessionId)) {
|
|
117
|
-
return this.finishRun(updated, "interrupted", {
|
|
143
|
+
return this.finishRun(updated, "interrupted", {
|
|
144
|
+
error: "Task process was no longer running",
|
|
145
|
+
failureCode: "process_missing",
|
|
146
|
+
});
|
|
118
147
|
}
|
|
119
148
|
return updated;
|
|
120
149
|
});
|
|
@@ -130,35 +159,104 @@ export class TaskExecutionService {
|
|
|
130
159
|
}
|
|
131
160
|
async recoverInterruptedRuns() {
|
|
132
161
|
const active = await this.options.repository.listActive();
|
|
133
|
-
await Promise.all(active.map((run) =>
|
|
134
|
-
|
|
135
|
-
|
|
162
|
+
await Promise.all(active.map(async (run) => {
|
|
163
|
+
const snapshot = await this.readSnapshot(run);
|
|
164
|
+
await this.finishRun(run, "interrupted", {
|
|
165
|
+
error: "ChatCCC restarted while this task was running",
|
|
166
|
+
failureCode: "chatccc_restart",
|
|
167
|
+
transcript: snapshot?.transcript,
|
|
168
|
+
});
|
|
169
|
+
}));
|
|
170
|
+
const workspaces = await this.options.boardService.listWorkspaces().catch(() => []);
|
|
171
|
+
await Promise.all(workspaces.map((workspace) => this.reconcileProject(workspace.boardId)));
|
|
172
|
+
return active.length;
|
|
173
|
+
}
|
|
174
|
+
/** Persist live execution state and enforce stop deadlines. Safe to call repeatedly from a timer. */
|
|
175
|
+
async checkpointActiveRuns() {
|
|
176
|
+
const active = await this.options.repository.listActive();
|
|
177
|
+
await Promise.all(active.map((run) => this.exclusive(run.projectId, () => this.checkpointRun(run.runId))));
|
|
178
|
+
const pending = [...this.pendingReconciliationProjects];
|
|
179
|
+
await Promise.all(pending.map((projectId) => this.reconcileProject(projectId)));
|
|
136
180
|
return active.length;
|
|
137
181
|
}
|
|
182
|
+
async reconcileProject(projectId) {
|
|
183
|
+
return this.exclusive(projectId, async () => {
|
|
184
|
+
const runs = await this.options.repository.listByProject(projectId);
|
|
185
|
+
const latestByTask = new Map();
|
|
186
|
+
for (const run of runs) {
|
|
187
|
+
if (!latestByTask.has(run.taskId))
|
|
188
|
+
latestByTask.set(run.taskId, run);
|
|
189
|
+
}
|
|
190
|
+
let reconciled = 0;
|
|
191
|
+
let failed = false;
|
|
192
|
+
for (const run of latestByTask.values()) {
|
|
193
|
+
// Only repair a transition that this service previously recorded as partial.
|
|
194
|
+
// A user may intentionally move an already-synced card after completion.
|
|
195
|
+
if (isActiveTaskRun(run) || run.boardSyncPending !== true)
|
|
196
|
+
continue;
|
|
197
|
+
try {
|
|
198
|
+
await this.moveFinishedTask(run);
|
|
199
|
+
const now = this.now().toISOString();
|
|
200
|
+
await this.options.repository.save({
|
|
201
|
+
...run,
|
|
202
|
+
boardSyncPending: false,
|
|
203
|
+
reconciledAt: now,
|
|
204
|
+
updatedAt: now,
|
|
205
|
+
syncError: undefined,
|
|
206
|
+
});
|
|
207
|
+
reconciled++;
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
await this.options.repository.save({
|
|
211
|
+
...run,
|
|
212
|
+
boardSyncPending: true,
|
|
213
|
+
syncError: err.message,
|
|
214
|
+
});
|
|
215
|
+
this.pendingReconciliationProjects.add(projectId);
|
|
216
|
+
logTaskRunEvent("board_sync_failed", run, { error: err.message });
|
|
217
|
+
failed = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!failed)
|
|
222
|
+
this.pendingReconciliationProjects.delete(projectId);
|
|
223
|
+
return reconciled;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
138
226
|
async execute(run) {
|
|
139
227
|
try {
|
|
140
228
|
const result = await this.options.runtime.run({
|
|
141
229
|
sessionId: run.sessionId,
|
|
142
230
|
chatId: run.chatId,
|
|
143
231
|
agentId: run.agentId,
|
|
144
|
-
traceId: `agent-team-${run.runId}`,
|
|
232
|
+
traceId: run.traceId ?? `agent-team-${run.runId}`,
|
|
145
233
|
prompt: taskPrompt(run),
|
|
146
234
|
});
|
|
147
235
|
if (result.outcome === "done") {
|
|
148
236
|
return this.finishRun(run, "succeeded", { result: result.result, transcript: result.transcript, moveTask: true });
|
|
149
237
|
}
|
|
150
238
|
if (result.outcome === "stopped") {
|
|
151
|
-
return this.finishRun(run, "canceled", {
|
|
239
|
+
return this.finishRun(run, "canceled", {
|
|
240
|
+
error: result.error,
|
|
241
|
+
failureCode: "user_stopped",
|
|
242
|
+
transcript: result.transcript,
|
|
243
|
+
moveTask: true,
|
|
244
|
+
});
|
|
152
245
|
}
|
|
153
246
|
return this.finishRun(run, "failed", {
|
|
154
247
|
error: result.error || (result.outcome === "auto_ended" ? "Agent response timed out" : "Agent execution failed"),
|
|
248
|
+
failureCode: result.outcome === "auto_ended" ? "agent_timeout" : "agent_error",
|
|
155
249
|
result: result.result,
|
|
156
250
|
transcript: result.transcript,
|
|
157
251
|
moveTask: true,
|
|
158
252
|
});
|
|
159
253
|
}
|
|
160
254
|
catch (err) {
|
|
161
|
-
return this.finishRun(run, "failed", {
|
|
255
|
+
return this.finishRun(run, "failed", {
|
|
256
|
+
error: err.message,
|
|
257
|
+
failureCode: "agent_error",
|
|
258
|
+
moveTask: true,
|
|
259
|
+
});
|
|
162
260
|
}
|
|
163
261
|
}
|
|
164
262
|
async finishRun(run, state, details = {}) {
|
|
@@ -171,35 +269,109 @@ export class TaskExecutionService {
|
|
|
171
269
|
state,
|
|
172
270
|
updatedAt: now,
|
|
173
271
|
finishedAt: now,
|
|
174
|
-
|
|
272
|
+
stalledAt: undefined,
|
|
273
|
+
lastProgressAt: latestTranscriptAt(details.transcript) ?? latest.lastProgressAt ?? now,
|
|
274
|
+
...(details.transcript?.length ? { transcript: combineTranscript(latest.transcript, details.transcript) } : {}),
|
|
175
275
|
...(details.result ? { result: details.result } : {}),
|
|
176
276
|
...(details.error ? { error: details.error } : {}),
|
|
277
|
+
...(details.failureCode ? { failureCode: details.failureCode } : {}),
|
|
278
|
+
...(details.moveTask !== false ? { boardSyncPending: true } : {}),
|
|
177
279
|
};
|
|
178
280
|
await this.options.repository.save(updated);
|
|
281
|
+
logTaskRunEvent("finished", updated);
|
|
179
282
|
if (details.moveTask !== false) {
|
|
180
|
-
|
|
283
|
+
try {
|
|
284
|
+
await this.moveFinishedTask(updated);
|
|
285
|
+
const reconciledAt = this.now().toISOString();
|
|
181
286
|
await this.options.repository.save({
|
|
182
287
|
...updated,
|
|
183
|
-
|
|
288
|
+
boardSyncPending: false,
|
|
289
|
+
reconciledAt,
|
|
290
|
+
updatedAt: reconciledAt,
|
|
291
|
+
syncError: undefined,
|
|
184
292
|
});
|
|
185
|
-
}
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
await this.options.repository.save({
|
|
296
|
+
...updated,
|
|
297
|
+
boardSyncPending: true,
|
|
298
|
+
syncError: err.message,
|
|
299
|
+
});
|
|
300
|
+
this.pendingReconciliationProjects.add(run.projectId);
|
|
301
|
+
logTaskRunEvent("board_sync_failed", updated, { error: err.message });
|
|
302
|
+
}
|
|
186
303
|
}
|
|
187
304
|
return await this.options.repository.get(run.runId) ?? updated;
|
|
188
305
|
}
|
|
189
306
|
async moveFinishedTask(run) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
307
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
308
|
+
const board = await this.options.boardService.getBoard(run.projectId);
|
|
309
|
+
const task = board.tasks.find((candidate) => candidate.id === run.taskId && !candidate.deletedAt);
|
|
310
|
+
if (!task)
|
|
311
|
+
return;
|
|
312
|
+
const target = run.state === "succeeded" ? "done" : "on_hold";
|
|
313
|
+
if (task.columnId === target)
|
|
314
|
+
return;
|
|
315
|
+
const targetCount = board.tasks.filter((candidate) => !candidate.deletedAt && candidate.columnId === target).length;
|
|
316
|
+
try {
|
|
317
|
+
await this.options.boardService.moveTask(run.projectId, run.taskId, {
|
|
318
|
+
expectedRevision: board.revision,
|
|
319
|
+
columnId: target,
|
|
320
|
+
index: targetCount,
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
if (!(err instanceof BoardStoreError) || err.code !== "revision_conflict" || attempt === 2)
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async checkpointRun(runId) {
|
|
331
|
+
const run = await this.options.repository.get(runId);
|
|
332
|
+
if (!run || !isActiveTaskRun(run))
|
|
193
333
|
return;
|
|
194
|
-
const
|
|
195
|
-
|
|
334
|
+
const now = this.now();
|
|
335
|
+
const snapshot = await this.readSnapshot(run);
|
|
336
|
+
const transcript = snapshot?.transcript.length
|
|
337
|
+
? combineTranscript(run.transcript, snapshot.transcript)
|
|
338
|
+
: run.transcript;
|
|
339
|
+
const snapshotProgressAt = latestIso(validIso(snapshot?.updatedAt), run.lastProgressAt ?? run.startedAt ?? run.createdAt);
|
|
340
|
+
const madeProgress = snapshotProgressAt > (run.lastProgressAt ?? "");
|
|
341
|
+
const stalled = now.getTime() - Date.parse(snapshotProgressAt) >= this.staleAfterMs;
|
|
342
|
+
const updated = {
|
|
343
|
+
...run,
|
|
344
|
+
transcript,
|
|
345
|
+
lastProgressAt: snapshotProgressAt,
|
|
346
|
+
updatedAt: madeProgress ? now.toISOString() : run.updatedAt,
|
|
347
|
+
stalledAt: stalled ? (run.stalledAt ?? now.toISOString()) : undefined,
|
|
348
|
+
};
|
|
349
|
+
if (JSON.stringify(updated) !== JSON.stringify(run))
|
|
350
|
+
await this.options.repository.save(updated);
|
|
351
|
+
if (updated.stopDeadlineAt && Date.parse(updated.stopDeadlineAt) <= now.getTime()) {
|
|
352
|
+
this.options.runtime.stop(updated.sessionId);
|
|
353
|
+
await this.finishRun(updated, "canceled", {
|
|
354
|
+
error: "Agent did not stop before the requested deadline",
|
|
355
|
+
failureCode: "stop_timeout",
|
|
356
|
+
transcript: snapshot?.transcript,
|
|
357
|
+
moveTask: true,
|
|
358
|
+
});
|
|
196
359
|
return;
|
|
197
|
-
|
|
198
|
-
await this.options.boardService.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
360
|
+
}
|
|
361
|
+
const board = await this.options.boardService.getBoard(updated.projectId);
|
|
362
|
+
const taskExists = board.tasks.some((task) => task.id === updated.taskId && !task.deletedAt);
|
|
363
|
+
if (!taskExists) {
|
|
364
|
+
this.options.runtime.stop(updated.sessionId);
|
|
365
|
+
await this.finishRun(updated, "canceled", {
|
|
366
|
+
error: "Task was deleted while the Agent was running",
|
|
367
|
+
failureCode: "task_deleted",
|
|
368
|
+
transcript: snapshot?.transcript,
|
|
369
|
+
moveTask: false,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async readSnapshot(run) {
|
|
374
|
+
return this.options.runtime.getSnapshot?.(run.sessionId).catch(() => null) ?? null;
|
|
203
375
|
}
|
|
204
376
|
async exclusive(projectId, operation) {
|
|
205
377
|
const previous = this.projectOperations.get(projectId) ?? Promise.resolve();
|
|
@@ -217,8 +389,69 @@ export class TaskExecutionService {
|
|
|
217
389
|
}
|
|
218
390
|
}
|
|
219
391
|
}
|
|
220
|
-
function
|
|
221
|
-
|
|
392
|
+
function combineTranscript(existing, live) {
|
|
393
|
+
const prompts = (existing ?? []).filter((entry) => entry.type === "prompt");
|
|
394
|
+
return compactTranscript([...prompts, ...live]);
|
|
395
|
+
}
|
|
396
|
+
const MAX_TRANSCRIPT_ENTRIES = 2_000;
|
|
397
|
+
const MAX_TRANSCRIPT_CHARS = 2_000_000;
|
|
398
|
+
const MAX_TRANSCRIPT_FIELD_CHARS = 100_000;
|
|
399
|
+
function compactTranscript(entries) {
|
|
400
|
+
const prompt = entries.find((entry) => entry.type === "prompt");
|
|
401
|
+
const tail = entries.filter((entry) => entry !== prompt).slice(-(MAX_TRANSCRIPT_ENTRIES - (prompt ? 1 : 0)));
|
|
402
|
+
const kept = [...(prompt ? [prompt] : []), ...tail].map((entry) => {
|
|
403
|
+
const compacted = { ...entry };
|
|
404
|
+
for (const field of ["text", "input", "output"]) {
|
|
405
|
+
const value = compacted[field];
|
|
406
|
+
if (value && value.length > MAX_TRANSCRIPT_FIELD_CHARS) {
|
|
407
|
+
compacted[field] = `${value.slice(0, MAX_TRANSCRIPT_FIELD_CHARS)}\n…(内容已截断)`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return compacted;
|
|
411
|
+
});
|
|
412
|
+
let remaining = MAX_TRANSCRIPT_CHARS;
|
|
413
|
+
const result = [];
|
|
414
|
+
for (let index = kept.length - 1; index >= 0; index--) {
|
|
415
|
+
const entry = kept[index];
|
|
416
|
+
const size = JSON.stringify(entry).length;
|
|
417
|
+
if (size > remaining && result.length)
|
|
418
|
+
break;
|
|
419
|
+
result.unshift(entry);
|
|
420
|
+
remaining -= size;
|
|
421
|
+
}
|
|
422
|
+
return result;
|
|
423
|
+
}
|
|
424
|
+
function validIso(value) {
|
|
425
|
+
if (!value || !Number.isFinite(Date.parse(value)))
|
|
426
|
+
return null;
|
|
427
|
+
return new Date(value).toISOString();
|
|
428
|
+
}
|
|
429
|
+
function latestIso(first, second) {
|
|
430
|
+
if (!first)
|
|
431
|
+
return second;
|
|
432
|
+
return Date.parse(first) >= Date.parse(second) ? first : second;
|
|
433
|
+
}
|
|
434
|
+
function latestTranscriptAt(entries) {
|
|
435
|
+
if (!entries?.length)
|
|
436
|
+
return null;
|
|
437
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
438
|
+
const at = validIso(entries[index]?.at);
|
|
439
|
+
if (at)
|
|
440
|
+
return at;
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
function logTaskRunEvent(event, run, details = {}) {
|
|
445
|
+
console.log(`[Agent Team] ${JSON.stringify({
|
|
446
|
+
event,
|
|
447
|
+
projectId: run.projectId,
|
|
448
|
+
taskId: run.taskId,
|
|
449
|
+
runId: run.runId,
|
|
450
|
+
traceId: run.traceId,
|
|
451
|
+
state: run.state,
|
|
452
|
+
attempt: run.attempt,
|
|
453
|
+
...details,
|
|
454
|
+
})}`);
|
|
222
455
|
}
|
|
223
456
|
function withoutTranscript(run) {
|
|
224
457
|
const { transcript: _transcript, ...summary } = run;
|
|
@@ -22,10 +22,23 @@ export function parseTaskRun(value) {
|
|
|
22
22
|
}
|
|
23
23
|
if (!isAgentTool(run.agentId))
|
|
24
24
|
throw new Error("Invalid task run Agent");
|
|
25
|
-
for (const field of [
|
|
25
|
+
for (const field of [
|
|
26
|
+
"traceId", "startedAt", "stopRequestedAt", "stopDeadlineAt", "finishedAt", "lastProgressAt",
|
|
27
|
+
"stalledAt", "result", "error", "reconciledAt", "syncError",
|
|
28
|
+
]) {
|
|
26
29
|
if (run[field] !== undefined && typeof run[field] !== "string")
|
|
27
30
|
throw new Error(`Invalid task run ${field}`);
|
|
28
31
|
}
|
|
32
|
+
const failureCodes = [
|
|
33
|
+
"agent_error", "agent_timeout", "user_stopped", "stop_timeout", "process_missing",
|
|
34
|
+
"chatccc_restart", "task_deleted",
|
|
35
|
+
];
|
|
36
|
+
if (run.failureCode !== undefined && !failureCodes.includes(run.failureCode)) {
|
|
37
|
+
throw new Error("Invalid task run failureCode");
|
|
38
|
+
}
|
|
39
|
+
if (run.boardSyncPending !== undefined && typeof run.boardSyncPending !== "boolean") {
|
|
40
|
+
throw new Error("Invalid task run boardSyncPending");
|
|
41
|
+
}
|
|
29
42
|
if (run.transcript !== undefined && (!Array.isArray(run.transcript) || !run.transcript.every(isExecutionTranscriptEntry))) {
|
|
30
43
|
throw new Error("Invalid task run transcript");
|
|
31
44
|
}
|
|
@@ -16,8 +16,13 @@ export function createTaskExecutionRuntime(platform) {
|
|
|
16
16
|
...(stream?.terminalError?.message ? { error: stream.terminalError.message } : {}),
|
|
17
17
|
};
|
|
18
18
|
},
|
|
19
|
-
async
|
|
20
|
-
|
|
19
|
+
async getSnapshot(sessionId) {
|
|
20
|
+
const state = await readStreamState(sessionId);
|
|
21
|
+
return {
|
|
22
|
+
transcript: state?.transcript ?? [],
|
|
23
|
+
...(state?.updatedAt ? { updatedAt: new Date(state.updatedAt).toISOString() } : {}),
|
|
24
|
+
...(state?.status ? { status: state.status } : {}),
|
|
25
|
+
};
|
|
21
26
|
},
|
|
22
27
|
stop: stopSession,
|
|
23
28
|
isSessionRunning,
|
|
@@ -6,8 +6,13 @@ import { createTaskExecutionRuntime } from "./infrastructure/task-execution-runt
|
|
|
6
6
|
import { feishuP2pContactStore } from "./repositories/feishu-p2p-contact-store.js";
|
|
7
7
|
import { JsonMainAgentBindingRepository } from "./repositories/main-agent-binding-repository.js";
|
|
8
8
|
import { JsonTaskRunRepository } from "./repositories/json-task-run-repository.js";
|
|
9
|
+
const TASK_RUN_CHECKPOINT_INTERVAL_MS = 3_000;
|
|
10
|
+
let taskRunMonitor = null;
|
|
11
|
+
let taskRunMonitorBusy = false;
|
|
12
|
+
let taskRunMonitorGeneration = 0;
|
|
9
13
|
/** Wire runtime dependencies only from index.ts, keeping the standalone Web UI import side-effect free. */
|
|
10
14
|
export function configureAgentTeamMainAgent(platform) {
|
|
15
|
+
const monitorGeneration = ++taskRunMonitorGeneration;
|
|
11
16
|
const bindingRepository = new JsonMainAgentBindingRepository();
|
|
12
17
|
const mainAgentService = new MainAgentService({
|
|
13
18
|
boardService: defaultAgentTeamBoardService,
|
|
@@ -24,7 +29,25 @@ export function configureAgentTeamMainAgent(platform) {
|
|
|
24
29
|
});
|
|
25
30
|
setDefaultAgentTeamMainAgentService(mainAgentService);
|
|
26
31
|
setDefaultAgentTeamTaskExecutionService(taskExecutionService);
|
|
27
|
-
|
|
32
|
+
if (taskRunMonitor)
|
|
33
|
+
clearInterval(taskRunMonitor);
|
|
34
|
+
void taskExecutionService.recoverInterruptedRuns()
|
|
35
|
+
.catch((err) => {
|
|
28
36
|
console.error(`[Agent Team] Failed to recover interrupted task runs: ${err.message}`);
|
|
37
|
+
})
|
|
38
|
+
.finally(() => {
|
|
39
|
+
if (monitorGeneration !== taskRunMonitorGeneration)
|
|
40
|
+
return;
|
|
41
|
+
taskRunMonitor = setInterval(() => {
|
|
42
|
+
if (taskRunMonitorBusy)
|
|
43
|
+
return;
|
|
44
|
+
taskRunMonitorBusy = true;
|
|
45
|
+
void taskExecutionService.checkpointActiveRuns()
|
|
46
|
+
.catch((err) => {
|
|
47
|
+
console.error(`[Agent Team] Failed to checkpoint task runs: ${err.message}`);
|
|
48
|
+
})
|
|
49
|
+
.finally(() => { taskRunMonitorBusy = false; });
|
|
50
|
+
}, TASK_RUN_CHECKPOINT_INTERVAL_MS);
|
|
51
|
+
taskRunMonitor.unref?.();
|
|
29
52
|
});
|
|
30
53
|
}
|
|
@@ -16,8 +16,10 @@ export class JsonTaskRunRepository {
|
|
|
16
16
|
return parseTaskRun(JSON.parse(await readFile(this.pathFor(projectId, runId), "utf8")));
|
|
17
17
|
}
|
|
18
18
|
catch (err) {
|
|
19
|
-
if (err.code
|
|
20
|
-
|
|
19
|
+
if (err.code === "ENOENT")
|
|
20
|
+
continue;
|
|
21
|
+
await quarantineCorruptRun(this.pathFor(projectId, runId), err);
|
|
22
|
+
return null;
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
return null;
|
|
@@ -41,8 +43,19 @@ export class JsonTaskRunRepository {
|
|
|
41
43
|
}
|
|
42
44
|
const runs = await Promise.all(entries
|
|
43
45
|
.filter((entry) => entry.endsWith(".json"))
|
|
44
|
-
.map(async (entry) =>
|
|
45
|
-
|
|
46
|
+
.map(async (entry) => {
|
|
47
|
+
const path = join(directory, entry);
|
|
48
|
+
try {
|
|
49
|
+
return parseTaskRun(JSON.parse(await readFile(path, "utf8")));
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
await quarantineCorruptRun(path, err);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
return runs
|
|
57
|
+
.filter((run) => run !== null)
|
|
58
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.runId.localeCompare(a.runId));
|
|
46
59
|
}
|
|
47
60
|
async listActive() {
|
|
48
61
|
const projectIds = await this.projectDirectories();
|
|
@@ -96,3 +109,8 @@ async function writeJsonAtomic(path, value) {
|
|
|
96
109
|
throw err;
|
|
97
110
|
}
|
|
98
111
|
}
|
|
112
|
+
async function quarantineCorruptRun(path, err) {
|
|
113
|
+
const quarantinePath = `${path}.corrupt-${Date.now()}`;
|
|
114
|
+
await rename(path, quarantinePath).catch(() => { });
|
|
115
|
+
console.error(`[Agent Team] Quarantined corrupt task run ${path}: ${err.message}`);
|
|
116
|
+
}
|