@nowcrew/daemon 0.5.11 → 0.5.13

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.
@@ -1,6 +1,6 @@
1
1
  function fields(input) {
2
2
  return {
3
- scheduled_run_id: input.scheduledRunId,
3
+ ...(input.scheduledRunId ? { scheduled_run_id: input.scheduledRunId } : {}),
4
4
  run_id: input.runId,
5
5
  agent_handle: input.agentHandle,
6
6
  channel_id: input.channelId,
@@ -9,19 +9,24 @@ function fields(input) {
9
9
  ...(input.model ? { model: input.model } : {}),
10
10
  };
11
11
  }
12
- export function reportScheduledRunComplete(socket, input, log) {
12
+ export function reportAgentRunComplete(socket, input, log) {
13
+ const eventPrefix = input.scheduledRunId ? "scheduled_run" : "run";
13
14
  if (!socket) {
14
- log("scheduled_run.complete_send_failed", "定时任务完成回报未发送:控制面连接不可用", {
15
+ log(`${eventPrefix}.complete_send_failed`, "Agent 完成回报未发送:控制面连接不可用", {
15
16
  level: "WARN", ...fields(input), error_message: "control socket unavailable",
16
17
  });
17
18
  return;
18
19
  }
19
20
  const payload = JSON.stringify({
20
21
  type: "agent:run-complete",
22
+ runId: input.runId,
21
23
  agentHandle: input.agentHandle,
22
24
  channelId: input.channelId,
23
- scheduledRunId: input.scheduledRunId,
25
+ ...(input.threadId !== undefined ? { threadId: input.threadId } : {}),
26
+ ...(input.scheduledRunId ? { scheduledRunId: input.scheduledRunId } : {}),
24
27
  exitCode: input.exitCode,
28
+ ...(input.wakeOrigin ? { wakeOrigin: input.wakeOrigin } : {}),
29
+ ...(input.originDecision ? { originDecision: input.originDecision } : {}),
25
30
  ...(input.runtime ? { runtime: input.runtime } : {}),
26
31
  ...(input.model !== undefined ? { model: input.model } : {}),
27
32
  ...(input.resumed !== undefined ? { resumed: input.resumed } : {}),
@@ -32,17 +37,20 @@ export function reportScheduledRunComplete(socket, input, log) {
32
37
  try {
33
38
  socket.send(payload, (error) => {
34
39
  if (error) {
35
- log("scheduled_run.complete_send_failed", "定时任务完成回报发送失败", {
40
+ log(`${eventPrefix}.complete_send_failed`, "Agent 完成回报发送失败", {
36
41
  level: "WARN", ...fields(input), error_message: error.message,
37
42
  });
38
43
  return;
39
44
  }
40
- log("scheduled_run.complete_sent", "定时任务完成回报已发送", fields(input));
45
+ log(`${eventPrefix}.complete_sent`, "Agent 完成回报已发送", fields(input));
41
46
  });
42
47
  }
43
48
  catch (error) {
44
- log("scheduled_run.complete_send_failed", "定时任务完成回报发送失败", {
49
+ log(`${eventPrefix}.complete_send_failed`, "Agent 完成回报发送失败", {
45
50
  level: "WARN", ...fields(input), error_message: error.message,
46
51
  });
47
52
  }
48
53
  }
54
+ export function reportScheduledRunComplete(socket, input, log) {
55
+ reportAgentRunComplete(socket, input, log);
56
+ }
package/dist/serve.js CHANGED
@@ -6,24 +6,32 @@ import { WebSocket } from "ws";
6
6
  import { join } from "node:path";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { initSlog, dslog, setSlogDefaults, drainSpool, flushSlog } from "./slog.js";
9
- import { reportScheduledStartFailure, runAgent } from "./runner.js";
10
- import { buildScheduledPrompt } from "./prompt.js";
11
- import { collectMachineHello, DAEMON_CAPABILITIES } from "./machine-info.js";
9
+ import { mergeRunAgentResults, reportScheduledStartFailure, runAgent } from "./runner.js";
10
+ import { buildOriginDecisionRetryPrompt, buildScheduledPrompt } from "./prompt.js";
11
+ import { collectMachineHello, DAEMON_CAPABILITIES, EXECUTION_PROTOCOL } from "./machine-info.js";
12
12
  import { listWorkspace, readWorkspaceFile } from "./workspace-fs.js";
13
13
  import { listSkills } from "./skills.js";
14
14
  import { inspectRaftWorkspace, importRaftWorkspace } from "./workspace-import.js";
15
15
  import { listRuntimeModels } from "./list-models.js";
16
16
  import { normalizeScheduledContext } from "./scheduled-report.js";
17
17
  import { formatDaemonLogLine } from "./log-format.js";
18
- import { reportScheduledRunComplete } from "./scheduled-run-report.js";
18
+ import { reportAgentRunComplete } from "./scheduled-run-report.js";
19
+ import { runWithOriginDecisionGuard } from "./origin-decision.js";
20
+ import { createExecutionJournal } from "./execution-journal.js";
21
+ import { ExecutionRejectedSchema, ExecutionSnapshotSchema, LegacyAgentStartSchema, ServerToDaemonExecutionFrameSchema, } from "./execution-protocol.js";
22
+ import { hashExecutionSpec, runExecution, } from "./execution-runner.js";
19
23
  // normalize.ts 的活动种类 → activity 枚举
20
24
  const ACTIVITY_MAP = {
21
25
  init: "working", text: "thinking", reading: "reading", sending: "sending",
22
26
  checking: "checking", claiming: "claiming", crew: "working", tool: "working",
23
27
  tool_result: "working", done: "done", error: "error",
24
28
  };
25
- export function buildControlPlaneUrl(serverUrl, machineToken) {
29
+ export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform) {
26
30
  const query = new URLSearchParams({ key: machineToken });
31
+ if (runtimePlatform !== "win32") {
32
+ query.set("execution_min", String(EXECUTION_PROTOCOL.min));
33
+ query.set("execution_max", String(EXECUTION_PROTOCOL.max));
34
+ }
27
35
  for (const capability of DAEMON_CAPABILITIES)
28
36
  query.append("capability", capability);
29
37
  return `${serverUrl.replace(/^http/, "ws").replace(/\/+$/, "")}/daemon/connect?${query.toString()}`;
@@ -34,34 +42,187 @@ export function serve(config, opts = {}) {
34
42
  let ws = null;
35
43
  let backoff = 1000;
36
44
  const maxBackoff = opts.maxBackoffMs ?? 30_000;
45
+ const executionJournal = opts.execution?.journal ?? createExecutionJournal(config.agentsRoot);
46
+ const executeProtocol = opts.execution?.runExecution ?? runExecution;
47
+ let detectedRuntimes = [];
48
+ let runtimeFacts = null;
49
+ const executionReady = executionJournal.reconcileAfterRestart().then(() => true, (error) => {
50
+ dslog("execution.recovery_failed", "execution journal 恢复失败", {
51
+ level: "ERROR", error_message: error.message,
52
+ });
53
+ return false;
54
+ });
55
+ const sharedActive = new Map();
56
+ const sharedQueues = new Map();
57
+ const knownExecutionHashes = new Map();
58
+ const executionReservations = new Map();
59
+ const executionRuns = new Map();
60
+ let executionFrameQueue = Promise.resolve();
61
+ const cancellations = new Map();
62
+ const safeExecutionSend = (frame) => {
63
+ try {
64
+ if (ws?.readyState === WebSocket.OPEN)
65
+ ws.send(JSON.stringify(frame));
66
+ }
67
+ catch { /* reconnect/sync replays durable lifecycle */ }
68
+ };
69
+ const snapshotEntry = (entry) => {
70
+ if ((entry.state === "completed" || entry.state === "interrupted") && entry.completion !== null) {
71
+ return { executionId: entry.executionId, state: entry.state, completion: entry.completion, updatedAt: entry.updatedAt };
72
+ }
73
+ if (entry.state === "accepted" || entry.state === "running") {
74
+ return { executionId: entry.executionId, state: entry.state, updatedAt: entry.updatedAt };
75
+ }
76
+ return null;
77
+ };
78
+ const sendSnapshot = async (reqId) => {
79
+ const entries = (await executionJournal.replay()).map(snapshotEntry).filter((entry) => entry !== null);
80
+ safeExecutionSend(ExecutionSnapshotSchema.parse({
81
+ type: "execution:snapshot", protocolVersion: 1, reqId, entries,
82
+ }));
83
+ };
84
+ const sendJournalStatus = (entry, acceptanceState = "ready") => {
85
+ if ((entry.state === "completed" || entry.state === "interrupted") && entry.completion !== null) {
86
+ safeExecutionSend(entry.completion);
87
+ return;
88
+ }
89
+ if (entry.state === "accepted" || entry.state === "running") {
90
+ safeExecutionSend({
91
+ type: "execution:accepted", protocolVersion: 1, executionId: entry.executionId,
92
+ state: acceptanceState, effectivePermission: entry.effectivePermission ?? "workspace_write",
93
+ at: entry.acceptedAt,
94
+ });
95
+ if (entry.state === "running" && entry.processStartedAt !== null) {
96
+ safeExecutionSend({
97
+ type: "execution:started", protocolVersion: 1,
98
+ executionId: entry.executionId, at: entry.processStartedAt,
99
+ });
100
+ }
101
+ }
102
+ };
103
+ const cancellationFor = (executionId) => {
104
+ const startStop = (state) => {
105
+ if (!state.requested || state.cancel === null)
106
+ return Promise.resolve();
107
+ state.stopPromise ??= Promise.resolve().then(state.cancel);
108
+ return state.stopPromise;
109
+ };
110
+ const existing = cancellations.get(executionId);
111
+ if (existing !== undefined) {
112
+ return {
113
+ isRequested: () => existing.requested,
114
+ requested: existing.promise,
115
+ register: (cancel) => {
116
+ existing.cancel = cancel;
117
+ void startStop(existing).catch(() => { });
118
+ },
119
+ waitForStop: () => startStop(existing),
120
+ };
121
+ }
122
+ let resolve;
123
+ const promise = new Promise((done) => { resolve = done; });
124
+ const state = {
125
+ requested: false, resolve, promise,
126
+ cancel: null,
127
+ stopPromise: null,
128
+ };
129
+ cancellations.set(executionId, state);
130
+ return {
131
+ isRequested: () => state.requested,
132
+ requested: promise,
133
+ register: (cancel) => {
134
+ state.cancel = cancel;
135
+ void startStop(state).catch(() => { });
136
+ },
137
+ waitForStop: () => startStop(state),
138
+ };
139
+ };
140
+ const requestCancellation = (executionId) => {
141
+ cancellationFor(executionId);
142
+ const state = cancellations.get(executionId);
143
+ if (state.requested)
144
+ return;
145
+ state.requested = true;
146
+ state.resolve();
147
+ const reservation = executionReservations.get(executionId);
148
+ if (reservation?.isQueued()) {
149
+ reservation.release();
150
+ }
151
+ if (state.cancel !== null && state.stopPromise === null) {
152
+ state.stopPromise = Promise.resolve().then(state.cancel);
153
+ void state.stopPromise.catch(() => { });
154
+ }
155
+ };
156
+ const promoteNext = (handle) => {
157
+ const queue = sharedQueues.get(handle) ?? [];
158
+ while ((sharedActive.get(handle) ?? 0) < config.executionLimits.maxParallelPerAgent && queue.length > 0) {
159
+ const next = queue.shift();
160
+ if (next.released)
161
+ continue;
162
+ next.promoted = true;
163
+ sharedActive.set(handle, (sharedActive.get(handle) ?? 0) + 1);
164
+ next.resolve();
165
+ }
166
+ if (queue.length === 0)
167
+ sharedQueues.delete(handle);
168
+ };
169
+ const reserveSharedSlot = (handle, kind) => {
170
+ const active = sharedActive.get(handle) ?? 0;
171
+ const queued = sharedQueues.get(handle)?.length ?? 0;
172
+ const facts = { activeForAgent: active, queuedForAgent: queued };
173
+ if (kind === "execution" && active >= config.executionLimits.maxParallelPerAgent
174
+ && queued >= config.executionLimits.maxQueuedPerAgent) {
175
+ return { facts, ready: Promise.resolve(), isQueued: () => false, release: () => { } };
176
+ }
177
+ let resolve;
178
+ const ready = new Promise((done) => { resolve = done; });
179
+ const entry = { kind, released: false, promoted: active < config.executionLimits.maxParallelPerAgent, resolve };
180
+ if (entry.promoted) {
181
+ sharedActive.set(handle, active + 1);
182
+ resolve();
183
+ }
184
+ else {
185
+ const queue = sharedQueues.get(handle) ?? [];
186
+ queue.push(entry);
187
+ sharedQueues.set(handle, queue);
188
+ }
189
+ return {
190
+ facts,
191
+ state: entry.promoted ? "ready" : "queued",
192
+ ready,
193
+ isQueued: () => !entry.promoted && !entry.released,
194
+ release: () => {
195
+ if (entry.released)
196
+ return;
197
+ entry.released = true;
198
+ if (entry.promoted) {
199
+ sharedActive.set(handle, Math.max(0, (sharedActive.get(handle) ?? 1) - 1));
200
+ }
201
+ else {
202
+ const queue = sharedQueues.get(handle);
203
+ const index = queue?.indexOf(entry) ?? -1;
204
+ if (queue !== undefined && index >= 0)
205
+ queue.splice(index, 1);
206
+ if (queue?.length === 0)
207
+ sharedQueues.delete(handle);
208
+ }
209
+ promoteNext(handle);
210
+ },
211
+ };
212
+ };
37
213
  let connectedAt = 0; // 本次 WS 连接建立时刻(断开日志算在线时长用)
38
214
  initSlog(config.serverUrl, config.machineToken);
39
215
  dslog("daemon.start", "daemon 常驻模式启动", { server_url: config.serverUrl, runtime: config.runtimeBin });
40
216
  // 并行调度:同一 agent 可并行处理多个【不同任务】(线程/频道),每任务隔离 cwd+work-log。
41
217
  // - running:正在跑的「agent:任务」去重键(同一任务重复唤醒才跳过)。
42
- // - agentSlots:每 agent 当前并行数;超过 MAX_PARALLEL 的进 FIFO 队列(不丢)。
43
- const MAX_PARALLEL = Number(process.env.CREW_MAX_PARALLEL) || 4;
218
+ // - agentSlots:每 agent 当前并行数;超过配置上限的进 FIFO 队列(不丢)。
44
219
  const running = new Set();
45
- const agentSlots = new Map();
46
- const waitQueue = new Map();
47
220
  const acquireSlot = async (handle) => {
48
- const n = agentSlots.get(handle) ?? 0;
49
- if (n < MAX_PARALLEL) {
50
- agentSlots.set(handle, n + 1);
51
- return;
52
- }
53
- await new Promise((resolve) => {
54
- const q = waitQueue.get(handle) ?? [];
55
- q.push(resolve);
56
- waitQueue.set(handle, q);
57
- });
58
- agentSlots.set(handle, (agentSlots.get(handle) ?? 0) + 1);
221
+ await reserveSharedSlot(handle, "legacy").ready;
59
222
  };
60
223
  const releaseSlot = (handle) => {
61
- agentSlots.set(handle, Math.max(0, (agentSlots.get(handle) ?? 1) - 1));
62
- const q = waitQueue.get(handle);
63
- if (q && q.length)
64
- (q.shift())();
224
+ sharedActive.set(handle, Math.max(0, (sharedActive.get(handle) ?? 1) - 1));
225
+ promoteNext(handle);
65
226
  };
66
227
  const log = (s) => process.stdout.write(formatDaemonLogLine(s) + "\n");
67
228
  function connect() {
@@ -76,8 +237,16 @@ export function serve(config, opts = {}) {
76
237
  // 连上了才有机会把离线期间(断连原因/退出前)落盘的日志补传上去
77
238
  void drainSpool();
78
239
  // 上报本机信息 (hostname/os/daemon 版本/已装 runtimes)
79
- void collectMachineHello(config.agentsRoot)
240
+ const helloPromise = collectMachineHello(config.agentsRoot, config.executionLimits);
241
+ runtimeFacts = helloPromise.then((hello) => hello.runtimes, (error) => {
242
+ dslog("execution.runtime_detection_failed", "runtime 探测失败", {
243
+ level: "ERROR", error_message: error.message,
244
+ });
245
+ return [];
246
+ });
247
+ void helloPromise
80
248
  .then((hello) => {
249
+ detectedRuntimes = hello.runtimes;
81
250
  try {
82
251
  ws?.send(JSON.stringify(hello));
83
252
  log(`📤 已上报机器信息: ${hello.hostname} · ${hello.os} · runtimes=[${hello.runtimes.join(",")}] · agents=[${hello.agentHandles.join(",")}]`);
@@ -86,15 +255,153 @@ export function serve(config, opts = {}) {
86
255
  })
87
256
  .catch(() => { });
88
257
  opts.onOpen?.(ws);
258
+ void executionReady.then((ready) => ready ? sendSnapshot(`reconnect-${randomUUID()}`) : undefined);
89
259
  });
90
260
  ws.on("message", async (data) => {
91
- let msg;
261
+ let decoded;
92
262
  try {
93
- msg = JSON.parse(data.toString());
263
+ decoded = JSON.parse(data.toString());
94
264
  }
95
265
  catch {
96
266
  return;
97
267
  }
268
+ if (typeof decoded !== "object" || decoded === null)
269
+ return;
270
+ const rawType = "type" in decoded && typeof decoded.type === "string" ? decoded.type : "";
271
+ if (rawType.startsWith("execution:")) {
272
+ const parsedExecution = ServerToDaemonExecutionFrameSchema.safeParse(decoded);
273
+ if (!parsedExecution.success) {
274
+ const executionId = "executionId" in decoded && typeof decoded.executionId === "string"
275
+ ? decoded.executionId
276
+ : null;
277
+ if (executionId !== null) {
278
+ const rejected = ExecutionRejectedSchema.safeParse({
279
+ type: "execution:rejected", protocolVersion: 1, executionId,
280
+ reason: "invalid_spec", message: parsedExecution.error.issues[0]?.message,
281
+ at: new Date().toISOString(),
282
+ });
283
+ if (rejected.success)
284
+ safeExecutionSend(rejected.data);
285
+ }
286
+ return;
287
+ }
288
+ const frame = parsedExecution.data;
289
+ executionFrameQueue = executionFrameQueue.then(async () => {
290
+ const recovered = await executionReady;
291
+ if (frame.type === "execution:completion-ack") {
292
+ await executionJournal.acknowledgeCompletion(frame.executionId).catch(() => { });
293
+ return;
294
+ }
295
+ if (frame.type === "execution:cancel") {
296
+ if (!knownExecutionHashes.has(frame.executionId)
297
+ && !executionRuns.has(frame.executionId)
298
+ && await executionJournal.get(frame.executionId) === null)
299
+ return;
300
+ requestCancellation(frame.executionId);
301
+ return;
302
+ }
303
+ if (frame.type === "execution:sync") {
304
+ await sendSnapshot(frame.reqId);
305
+ return;
306
+ }
307
+ const spec = frame;
308
+ if (!recovered) {
309
+ safeExecutionSend(ExecutionRejectedSchema.parse({
310
+ type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
311
+ reason: "resource_limit", message: "Local execution journal recovery failed",
312
+ at: new Date().toISOString(),
313
+ }));
314
+ return;
315
+ }
316
+ const hash = hashExecutionSpec(spec);
317
+ const knownHash = knownExecutionHashes.get(spec.executionId);
318
+ if (knownHash !== undefined) {
319
+ if (knownHash !== hash) {
320
+ safeExecutionSend(ExecutionRejectedSchema.parse({
321
+ type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
322
+ reason: "invalid_spec", message: "executionId already has a different spec",
323
+ at: new Date().toISOString(),
324
+ }));
325
+ return;
326
+ }
327
+ const existing = await executionJournal.get(spec.executionId);
328
+ if (existing !== null) {
329
+ const state = executionReservations.get(spec.executionId)?.isQueued() ? "queued" : "ready";
330
+ sendJournalStatus(existing, state);
331
+ }
332
+ return;
333
+ }
334
+ const durableExisting = await executionJournal.get(spec.executionId);
335
+ if (durableExisting !== null) {
336
+ if (durableExisting.specHash !== hash) {
337
+ safeExecutionSend(ExecutionRejectedSchema.parse({
338
+ type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
339
+ reason: "invalid_spec", message: "executionId already has a different spec",
340
+ at: new Date().toISOString(),
341
+ }));
342
+ }
343
+ else {
344
+ sendJournalStatus(durableExisting);
345
+ }
346
+ return;
347
+ }
348
+ knownExecutionHashes.set(spec.executionId, hash);
349
+ const reservation = reserveSharedSlot(spec.agent.handle, "execution");
350
+ executionReservations.set(spec.executionId, reservation);
351
+ const cancellation = cancellationFor(spec.executionId);
352
+ let availableRuntimes;
353
+ try {
354
+ availableRuntimes = opts.execution?.availableRuntimes?.()
355
+ ?? await (runtimeFacts ?? Promise.resolve(detectedRuntimes));
356
+ }
357
+ catch (error) {
358
+ reservation.release();
359
+ cancellations.delete(spec.executionId);
360
+ executionReservations.delete(spec.executionId);
361
+ knownExecutionHashes.delete(spec.executionId);
362
+ safeExecutionSend(ExecutionRejectedSchema.parse({
363
+ type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
364
+ reason: "resource_limit", message: `Runtime detection failed: ${error.message}`,
365
+ at: new Date().toISOString(),
366
+ }));
367
+ return;
368
+ }
369
+ const execution = executeProtocol(config, spec, {
370
+ ...opts.execution?.dependencies,
371
+ journal: executionJournal,
372
+ facts: {
373
+ availableRuntimes,
374
+ ...reservation.facts,
375
+ },
376
+ report: safeExecutionSend,
377
+ ...(reservation.state === undefined ? {} : {
378
+ slot: { state: reservation.state, ready: reservation.ready },
379
+ }),
380
+ cancellation,
381
+ }).finally(() => {
382
+ reservation.release();
383
+ cancellations.delete(spec.executionId);
384
+ executionReservations.delete(spec.executionId);
385
+ executionRuns.delete(spec.executionId);
386
+ knownExecutionHashes.delete(spec.executionId);
387
+ });
388
+ executionRuns.set(spec.executionId, execution);
389
+ void execution.catch(() => { });
390
+ return;
391
+ }).catch((error) => {
392
+ dslog("execution.frame_failed", "execution 控制帧处理失败", {
393
+ level: "ERROR", error_message: error.message,
394
+ });
395
+ });
396
+ return;
397
+ }
398
+ const legacy = typeof decoded === "object" && decoded !== null
399
+ && "type" in decoded && decoded.type === "agent:start"
400
+ ? LegacyAgentStartSchema.safeParse(decoded)
401
+ : null;
402
+ if (legacy !== null && !legacy.success)
403
+ return;
404
+ const msg = (legacy?.success ? legacy.data : decoded);
98
405
  // 控制面鉴权拒绝:server 端 resolveToken 未命中有效的 machine 凭证(失效/被吊销/
99
406
  // 库已重置)。不能静默丢弃这帧——否则只表现为神秘的「每 1s 重连」循环。打印可执行
100
407
  // 提示,并把退避拉满,避免无意义高频重连刷屏 server(凭证失配不会靠重试自愈,
@@ -164,7 +471,14 @@ export function serve(config, opts = {}) {
164
471
  // 任务键:scheduled run 用 runId(每次运行独立 cwd/work-log;overlap 由 server 端 skip_if_running 管,
165
472
  // daemon 的 running 去重只兜底"同一 run 重复投递");普通唤醒仍是 线程锚点 ?? 频道。
166
473
  const scheduled = msg.reason === "scheduled_job" && msg.scheduledRun
167
- ? normalizeScheduledContext(msg.scheduledRun)
474
+ ? normalizeScheduledContext({
475
+ jobId: msg.scheduledRun.jobId,
476
+ runId: msg.scheduledRun.runId,
477
+ ...(typeof msg.scheduledRun.title === "string" ? { title: msg.scheduledRun.title } : {}),
478
+ ...(msg.scheduledRun.outputPolicy !== undefined
479
+ ? { outputPolicy: msg.scheduledRun.outputPolicy }
480
+ : {}),
481
+ })
168
482
  : null;
169
483
  const threadId = msg.wake?.threadId;
170
484
  const taskKey = scheduled ? scheduled.runId : (threadId ?? msg.channelId);
@@ -177,6 +491,7 @@ export function serve(config, opts = {}) {
177
491
  };
178
492
  dslog("run.wake_received", `收到唤醒 ${msg.agentHandle}`, {
179
493
  ...runKeys, reason: msg.reason ?? "", sender: msg.wake?.senderHandle,
494
+ wake_origin: msg.wake?.origin ?? null,
180
495
  content_preview: (msg.wake?.content ?? "").replace(/\s+/g, " ").slice(0, 120),
181
496
  });
182
497
  if (running.has(key)) {
@@ -185,7 +500,7 @@ export function serve(config, opts = {}) {
185
500
  return;
186
501
  }
187
502
  running.add(key);
188
- // 并行槽:同 agent 超过 MAX_PARALLEL 个任务时在此排队(不丢),有空位再跑。
503
+ // 并行槽:同 agent 超过配置上限的任务在此排队(不丢),有空位再跑。
189
504
  const slotWaitStart = Date.now();
190
505
  await acquireSlot(msg.agentHandle);
191
506
  const queueMs = Date.now() - slotWaitStart;
@@ -271,18 +586,32 @@ export function serve(config, opts = {}) {
271
586
  : (msg.wake?.content
272
587
  ? `你被唤醒(${msg.reason}): ${msg.wake.content}\n用 ${readCmd} 读${threadId ? "本线程" : "频道"}后按需处理。${reasonHint}${ackHint}${threadHint}${attHint}`
273
588
  : undefined);
274
- const result = await runAgent(config, {
275
- handle: msg.agentHandle,
276
- channelId: msg.channelId,
277
- taskKey, // 每任务隔离 cwd + work-log(并行不冲突)
278
- runId, // 贯穿 SLS 日志的单轮关联键
279
- ...(scheduled ? {
280
- scheduled: { title: scheduled.title, outputPolicy: scheduled.outputPolicy },
281
- } : {}),
282
- // 唤醒锚点是具体消息(非纯频道唤醒)时,把它透传下去,供 `crew task create` 锚定到该消息。
283
- ...(!scheduled && threadId ? { wakeMessageId: threadId } : {}),
284
- ...(wakeText ? { wake: wakeText } : {}),
285
- }, reportActivity, reportConsole);
589
+ const wakeOrigin = scheduled ? undefined : msg.wake?.origin;
590
+ const guarded = await runWithOriginDecisionGuard(wakeOrigin, async (attempt) => {
591
+ if (attempt === 1) {
592
+ dslog("run.origin_decision_retry", "Agent 未完成企微回复决策,补跑一次", {
593
+ level: "WARN", ...runKeys, wake_origin: wakeOrigin,
594
+ });
595
+ }
596
+ const attemptWake = attempt === 1
597
+ ? buildOriginDecisionRetryPrompt(msg.channelId, threadId)
598
+ : wakeText;
599
+ return runAgent(config, {
600
+ handle: msg.agentHandle,
601
+ channelId: msg.channelId,
602
+ taskKey, // 每任务隔离 cwd + work-log(并行不冲突)
603
+ runId, // 贯穿 SLS 日志的单轮关联键
604
+ ...(scheduled ? {
605
+ scheduled: { title: scheduled.title, outputPolicy: scheduled.outputPolicy },
606
+ } : {}),
607
+ ...(wakeOrigin ? { wakeOrigin, originDecisionAttempt: attempt } : {}),
608
+ // 唤醒锚点是具体消息(非纯频道唤醒)时,把它透传下去,供 `crew task create` 锚定到该消息。
609
+ ...(!scheduled && threadId ? { wakeMessageId: threadId } : {}),
610
+ ...(!scheduled && msg.wake?.seq !== undefined ? { wakeContextUpToSeq: msg.wake.seq } : {}),
611
+ ...(attemptWake ? { wake: attemptWake } : {}),
612
+ }, reportActivity, reportConsole);
613
+ });
614
+ const result = mergeRunAgentResults(guarded.results);
286
615
  // 本轮 token 用量上报:runner 已从 result 事件提取(含缓存读/写细分),
287
616
  // 连同模型/runtime 一起上送控制面落库 → 支撑每 agent / 每任务(线程)的用量监控与成本核算。
288
617
  if (result.usage) {
@@ -305,16 +634,25 @@ export function serve(config, opts = {}) {
305
634
  }
306
635
  catch { /* ws 非 OPEN,忽略(用量非关键路径,丢一轮不阻塞) */ }
307
636
  }
308
- // 静默定时 run:结构化完成回报(驱动 server scheduled_run/job 状态落库,§5.5)
309
- if (scheduled) {
310
- reportScheduledRunComplete(ws?.readyState === WebSocket.OPEN ? ws : null, {
311
- runId, scheduledRunId: scheduled.runId, agentHandle: msg.agentHandle,
312
- channelId: msg.channelId, exitCode: result.exitCode, runtime: result.runtime,
313
- model: result.model, resumed: result.resumed, usage: result.usage,
314
- ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
315
- ...(result.report ? { report: result.report } : {}),
316
- }, dslog);
317
- }
637
+ // 所有 run 都上报完成边界:普通交互用于企微单轮聚合;scheduled 继续驱动 run/job 落库。
638
+ reportAgentRunComplete(ws?.readyState === WebSocket.OPEN ? ws : null, {
639
+ runId,
640
+ agentHandle: msg.agentHandle,
641
+ channelId: msg.channelId,
642
+ threadId: threadId ?? null,
643
+ exitCode: result.exitCode,
644
+ runtime: result.runtime,
645
+ model: result.model,
646
+ resumed: result.resumed,
647
+ usage: result.usage,
648
+ ...(wakeOrigin ? {
649
+ wakeOrigin,
650
+ originDecision: result.originDecision ?? "missing",
651
+ } : {}),
652
+ ...(scheduled ? { scheduledRunId: scheduled.runId } : {}),
653
+ ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
654
+ ...(result.report ? { report: result.report } : {}),
655
+ }, dslog);
318
656
  // run.end 是排查「任务没跑完就本轮结束」的核心证据:退出码 + 时长 + 最后活动 +
319
657
  // 是否 resume + 用量。exit_code!=0 或时长异常短都值得追。
320
658
  const lastActivity = result.activities.length
@@ -327,6 +665,8 @@ export function serve(config, opts = {}) {
327
665
  runtime: result.runtime, model: result.model, resumed: result.resumed,
328
666
  session_id: result.sessionId,
329
667
  activity_count: result.activities.length, last_activity: lastActivity,
668
+ wake_origin: wakeOrigin ?? null,
669
+ origin_decision: result.originDecision ?? null,
330
670
  ...(result.usage ? {
331
671
  tokens_input: result.usage.inputTokens, tokens_output: result.usage.outputTokens,
332
672
  cache_read: result.usage.cacheReadTokens, cache_creation: result.usage.cacheCreationTokens,
@@ -354,8 +694,8 @@ export function serve(config, opts = {}) {
354
694
  level: "ERROR", ...runKeys, duration_ms: Date.now() - runStartedAt,
355
695
  error_message: e.message, error_stack: e.stack,
356
696
  });
697
+ let report;
357
698
  if (scheduled) {
358
- let report;
359
699
  try {
360
700
  report = await reportScheduledStartFailure(config, {
361
701
  handle: msg.agentHandle,
@@ -365,12 +705,21 @@ export function serve(config, opts = {}) {
365
705
  });
366
706
  }
367
707
  catch { /* token 也不可用时只能让 server 按 runtime failure 终态化 */ }
368
- reportScheduledRunComplete(ws?.readyState === WebSocket.OPEN ? ws : null, {
369
- runId, scheduledRunId: scheduled.runId, agentHandle: msg.agentHandle,
370
- channelId: msg.channelId, exitCode: -1, errorMessage: e.message,
371
- ...(report ? { report } : {}),
372
- }, dslog);
373
708
  }
709
+ reportAgentRunComplete(ws?.readyState === WebSocket.OPEN ? ws : null, {
710
+ runId,
711
+ agentHandle: msg.agentHandle,
712
+ channelId: msg.channelId,
713
+ threadId: threadId ?? null,
714
+ exitCode: -1,
715
+ errorMessage: e.message,
716
+ ...(!scheduled && msg.wake?.origin ? {
717
+ wakeOrigin: msg.wake.origin,
718
+ originDecision: "missing",
719
+ } : {}),
720
+ ...(scheduled ? { scheduledRunId: scheduled.runId } : {}),
721
+ ...(report ? { report } : {}),
722
+ }, dslog);
374
723
  }
375
724
  finally {
376
725
  running.delete(key);
@@ -407,6 +756,12 @@ export function serve(config, opts = {}) {
407
756
  stop: () => {
408
757
  stopped = true;
409
758
  ws?.close();
759
+ for (const executionId of executionRuns.keys())
760
+ requestCancellation(executionId);
761
+ void Promise.race([
762
+ Promise.allSettled([...executionRuns.values()]).then(() => true),
763
+ new Promise((resolve) => setTimeout(() => resolve(false), 10_000)),
764
+ ]).then((settled) => settled ? executionJournal.close() : undefined);
410
765
  },
411
766
  };
412
767
  }