@nowcrew/daemon 0.5.19 → 0.5.21

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/dist/serve.js CHANGED
@@ -22,15 +22,20 @@ import { createExecutionTelemetryJournal } from "./execution-telemetry-journal.j
22
22
  import { ExecutionRejectedSchema, ExecutionSnapshotSchema, LegacyAgentStartSchema, ServerToDaemonExecutionFrameSchema, } from "./execution-protocol.js";
23
23
  import { hashExecutionSpec, runExecution, } from "./execution-runner.js";
24
24
  import { executionBackendCapability } from "./execution-backend.js";
25
+ import { awaitWithCancellation, createRuntimeCancellation, RuntimeCancelledError, } from "./runtime-cancellation.js";
26
+ import { createShutdownDeadline, readTestShutdownConfiguration } from "./shutdown-deadline.js";
27
+ import { closeWebSocketWithinDeadline } from "./websocket-shutdown.js";
28
+ import { reconcileExecutionJournal } from "./execution-recovery.js";
29
+ import { createSharedSlotManager } from "./shared-execution-slots.js";
25
30
  // normalize.ts 的活动种类 → activity 枚举
26
31
  const ACTIVITY_MAP = {
27
32
  init: "working", text: "thinking", reading: "reading", sending: "sending",
28
33
  checking: "checking", claiming: "claiming", crew: "working", tool: "working",
29
34
  tool_result: "working", done: "done", error: "error",
30
35
  };
31
- export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform) {
36
+ export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform, jobObjectProbe) {
32
37
  const query = new URLSearchParams({ key: machineToken });
33
- if (executionBackendCapability(runtimePlatform).supported) {
38
+ if (executionBackendCapability(runtimePlatform, jobObjectProbe).supported) {
34
39
  query.set("execution_min", String(EXECUTION_PROTOCOL.min));
35
40
  query.set("execution_max", String(EXECUTION_PROTOCOL.max));
36
41
  }
@@ -44,24 +49,23 @@ export function serve(config, opts = {}) {
44
49
  let ws = null;
45
50
  let backoff = 1000;
46
51
  const maxBackoff = opts.maxBackoffMs ?? 30_000;
52
+ const testShutdown = readTestShutdownConfiguration(process.env);
53
+ const shutdownTimeoutMs = opts.shutdownTimeoutMs ?? testShutdown.timeoutMs ?? 30_000;
54
+ const createWebSocket = opts.createWebSocket ?? ((url) => new WebSocket(url));
55
+ let reconnectTimer = null;
56
+ let stopPromise = null;
47
57
  const executionJournal = opts.execution?.journal ?? createExecutionJournal(config.agentsRoot);
48
58
  const executionTelemetry = createExecutionTelemetryJournal(config.agentsRoot);
49
59
  const executeProtocol = opts.execution?.runExecution ?? runExecution;
50
60
  let detectedExecutionRuntimes = [];
51
61
  let runtimeFacts = null;
52
- const executionReady = executionJournal.reconcileAfterRestart().then(() => true, (error) => {
53
- dslog("execution.recovery_failed", "execution journal 恢复失败", {
54
- level: "ERROR", error_message: error.message,
55
- });
56
- return false;
57
- });
58
- const sharedActive = new Map();
59
- const sharedQueues = new Map();
62
+ const sharedSlots = createSharedSlotManager(config.executionLimits);
60
63
  const knownExecutionHashes = new Map();
61
64
  const executionReservations = new Map();
62
65
  const executionRuns = new Map();
63
66
  let executionFrameQueue = Promise.resolve();
64
67
  const cancellations = new Map();
68
+ const legacyRuns = new Map();
65
69
  const safeExecutionSend = (frame) => {
66
70
  try {
67
71
  if (ws?.readyState === WebSocket.OPEN)
@@ -124,114 +128,19 @@ export function serve(config, opts = {}) {
124
128
  }
125
129
  };
126
130
  const cancellationFor = (executionId) => {
127
- const startStop = (state) => {
128
- if (!state.requested || state.cancel === null)
129
- return Promise.resolve();
130
- state.stopPromise ??= Promise.resolve().then(state.cancel);
131
- return state.stopPromise;
132
- };
133
131
  const existing = cancellations.get(executionId);
134
- if (existing !== undefined) {
135
- return {
136
- isRequested: () => existing.requested,
137
- requested: existing.promise,
138
- register: (cancel) => {
139
- existing.cancel = cancel;
140
- void startStop(existing).catch(() => { });
141
- },
142
- waitForStop: () => startStop(existing),
143
- };
144
- }
145
- let resolve;
146
- const promise = new Promise((done) => { resolve = done; });
147
- const state = {
148
- requested: false, resolve, promise,
149
- cancel: null,
150
- stopPromise: null,
151
- };
152
- cancellations.set(executionId, state);
153
- return {
154
- isRequested: () => state.requested,
155
- requested: promise,
156
- register: (cancel) => {
157
- state.cancel = cancel;
158
- void startStop(state).catch(() => { });
159
- },
160
- waitForStop: () => startStop(state),
161
- };
132
+ if (existing !== undefined)
133
+ return existing.cancellation;
134
+ const controller = createRuntimeCancellation();
135
+ cancellations.set(executionId, controller);
136
+ return controller.cancellation;
162
137
  };
163
138
  const requestCancellation = (executionId) => {
164
139
  cancellationFor(executionId);
165
- const state = cancellations.get(executionId);
166
- if (state.requested)
167
- return;
168
- state.requested = true;
169
- state.resolve();
140
+ cancellations.get(executionId).request();
170
141
  const reservation = executionReservations.get(executionId);
171
- if (reservation?.isQueued()) {
142
+ if (reservation?.isQueued())
172
143
  reservation.release();
173
- }
174
- if (state.cancel !== null && state.stopPromise === null) {
175
- state.stopPromise = Promise.resolve().then(state.cancel);
176
- void state.stopPromise.catch(() => { });
177
- }
178
- };
179
- const promoteNext = (handle) => {
180
- const queue = sharedQueues.get(handle) ?? [];
181
- while ((sharedActive.get(handle) ?? 0) < config.executionLimits.maxParallelPerAgent && queue.length > 0) {
182
- const next = queue.shift();
183
- if (next.released)
184
- continue;
185
- next.promoted = true;
186
- sharedActive.set(handle, (sharedActive.get(handle) ?? 0) + 1);
187
- next.resolve();
188
- }
189
- if (queue.length === 0)
190
- sharedQueues.delete(handle);
191
- };
192
- const reserveSharedSlot = (handle, kind) => {
193
- const active = sharedActive.get(handle) ?? 0;
194
- const queued = sharedQueues.get(handle)?.length ?? 0;
195
- const facts = { activeForAgent: active, queuedForAgent: queued };
196
- if (kind === "execution" && active >= config.executionLimits.maxParallelPerAgent
197
- && queued >= config.executionLimits.maxQueuedPerAgent) {
198
- return { facts, ready: Promise.resolve(), isQueued: () => false, release: () => { } };
199
- }
200
- let resolve;
201
- const ready = new Promise((done) => { resolve = done; });
202
- const entry = { kind, released: false, promoted: active < config.executionLimits.maxParallelPerAgent, resolve };
203
- if (entry.promoted) {
204
- sharedActive.set(handle, active + 1);
205
- resolve();
206
- }
207
- else {
208
- const queue = sharedQueues.get(handle) ?? [];
209
- queue.push(entry);
210
- sharedQueues.set(handle, queue);
211
- }
212
- return {
213
- facts,
214
- state: entry.promoted ? "ready" : "queued",
215
- ready,
216
- isQueued: () => !entry.promoted && !entry.released,
217
- release: () => {
218
- if (entry.released)
219
- return;
220
- entry.released = true;
221
- if (entry.promoted) {
222
- sharedActive.set(handle, Math.max(0, (sharedActive.get(handle) ?? 1) - 1));
223
- }
224
- else {
225
- const queue = sharedQueues.get(handle);
226
- const index = queue?.indexOf(entry) ?? -1;
227
- if (queue !== undefined && index >= 0)
228
- queue.splice(index, 1);
229
- if (queue?.length === 0)
230
- sharedQueues.delete(handle);
231
- }
232
- promoteNext(handle);
233
- },
234
- };
235
144
  };
236
145
  let connectedAt = 0; // 本次 WS 连接建立时刻(断开日志算在线时长用)
237
146
  initSlog(config.serverUrl, config.machineToken);
@@ -241,18 +150,11 @@ export function serve(config, opts = {}) {
241
150
  // 每 agent 超过并行上限的任务继续进入 sharedQueues(不丢)。
242
151
  const running = new Set();
243
152
  const legacyTaskTails = new Map();
244
- const acquireSlot = async (handle) => {
245
- await reserveSharedSlot(handle, "legacy").ready;
246
- };
247
- const releaseSlot = (handle) => {
248
- sharedActive.set(handle, Math.max(0, (sharedActive.get(handle) ?? 1) - 1));
249
- promoteNext(handle);
250
- };
251
153
  const log = (s) => process.stdout.write(formatDaemonLogLine(s) + "\n");
252
154
  function connect() {
253
155
  if (stopped)
254
156
  return;
255
- ws = new WebSocket(wsUrl);
157
+ ws = createWebSocket(wsUrl);
256
158
  ws.on("open", () => {
257
159
  backoff = 1000;
258
160
  connectedAt = Date.now();
@@ -279,7 +181,7 @@ export function serve(config, opts = {}) {
279
181
  })
280
182
  .catch(() => { });
281
183
  opts.onOpen?.(ws);
282
- void executionReady.then((ready) => ready ? sendSnapshot(`reconnect-${randomUUID()}`) : undefined);
184
+ void sendSnapshot(`reconnect-${randomUUID()}`);
283
185
  void replayExecutionTelemetry().catch((error) => {
284
186
  dslog("execution.telemetry_replay_failed", "execution telemetry 重放失败", {
285
187
  level: "ERROR", error_message: error.message,
@@ -287,6 +189,8 @@ export function serve(config, opts = {}) {
287
189
  });
288
190
  });
289
191
  ws.on("message", async (data) => {
192
+ if (stopped)
193
+ return;
290
194
  let decoded;
291
195
  try {
292
196
  decoded = JSON.parse(data.toString());
@@ -316,7 +220,8 @@ export function serve(config, opts = {}) {
316
220
  }
317
221
  const frame = parsedExecution.data;
318
222
  executionFrameQueue = executionFrameQueue.then(async () => {
319
- const recovered = await executionReady;
223
+ if (stopped)
224
+ return;
320
225
  if (frame.type === "execution:completion-ack") {
321
226
  await executionJournal.acknowledgeCompletion(frame.executionId).catch(() => { });
322
227
  return;
@@ -343,14 +248,6 @@ export function serve(config, opts = {}) {
343
248
  return;
344
249
  }
345
250
  const spec = frame;
346
- if (!recovered) {
347
- safeExecutionSend(ExecutionRejectedSchema.parse({
348
- type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
349
- reason: "resource_limit", message: "Local execution journal recovery failed",
350
- at: new Date().toISOString(),
351
- }));
352
- return;
353
- }
354
251
  const hash = hashExecutionSpec(spec);
355
252
  const knownHash = knownExecutionHashes.get(spec.executionId);
356
253
  if (knownHash !== undefined) {
@@ -370,6 +267,8 @@ export function serve(config, opts = {}) {
370
267
  return;
371
268
  }
372
269
  const durableExisting = await executionJournal.get(spec.executionId);
270
+ if (stopped)
271
+ return;
373
272
  if (durableExisting !== null) {
374
273
  if (durableExisting.specHash !== hash) {
375
274
  safeExecutionSend(ExecutionRejectedSchema.parse({
@@ -384,19 +283,22 @@ export function serve(config, opts = {}) {
384
283
  return;
385
284
  }
386
285
  knownExecutionHashes.set(spec.executionId, hash);
387
- const reservation = reserveSharedSlot(spec.agent.handle, "execution");
286
+ const reservation = sharedSlots.reserve(spec.agent.handle, "execution");
388
287
  executionReservations.set(spec.executionId, reservation);
389
288
  const cancellation = cancellationFor(spec.executionId);
289
+ const cleanupExecutionReservation = () => {
290
+ reservation.release();
291
+ cancellations.delete(spec.executionId);
292
+ executionReservations.delete(spec.executionId);
293
+ knownExecutionHashes.delete(spec.executionId);
294
+ };
390
295
  let availableRuntimes;
391
296
  try {
392
297
  availableRuntimes = opts.execution?.availableRuntimes?.()
393
298
  ?? await (runtimeFacts ?? Promise.resolve(detectedExecutionRuntimes));
394
299
  }
395
300
  catch (error) {
396
- reservation.release();
397
- cancellations.delete(spec.executionId);
398
- executionReservations.delete(spec.executionId);
399
- knownExecutionHashes.delete(spec.executionId);
301
+ cleanupExecutionReservation();
400
302
  safeExecutionSend(ExecutionRejectedSchema.parse({
401
303
  type: "execution:rejected", protocolVersion: 1, executionId: spec.executionId,
402
304
  reason: "resource_limit", message: `Runtime detection failed: ${error.message}`,
@@ -404,6 +306,10 @@ export function serve(config, opts = {}) {
404
306
  }));
405
307
  return;
406
308
  }
309
+ if (stopped) {
310
+ cleanupExecutionReservation();
311
+ return;
312
+ }
407
313
  const execution = executeProtocol(config, spec, {
408
314
  ...opts.execution?.dependencies,
409
315
  journal: executionJournal,
@@ -417,11 +323,8 @@ export function serve(config, opts = {}) {
417
323
  }),
418
324
  cancellation,
419
325
  }).finally(() => {
420
- reservation.release();
421
- cancellations.delete(spec.executionId);
422
- executionReservations.delete(spec.executionId);
326
+ cleanupExecutionReservation();
423
327
  executionRuns.delete(spec.executionId);
424
- knownExecutionHashes.delete(spec.executionId);
425
328
  });
426
329
  executionRuns.set(spec.executionId, execution);
427
330
  void execution.catch(() => { });
@@ -556,75 +459,88 @@ export function serve(config, opts = {}) {
556
459
  return;
557
460
  }
558
461
  const queueWaitStart = Date.now();
462
+ const runStartedAt = Date.now();
463
+ const controller = createRuntimeCancellation();
464
+ let finishLegacyRun;
465
+ let failLegacyRun;
466
+ const legacyDone = new Promise((resolveDone, rejectDone) => {
467
+ finishLegacyRun = resolveDone;
468
+ failLegacyRun = rejectDone;
469
+ });
470
+ void legacyDone.catch(() => undefined);
471
+ legacyRuns.set(runId, { controller, done: legacyDone });
472
+ let legacyStopError;
473
+ let legacyReservation = null;
559
474
  let legacyQueueDone = null;
560
475
  let finishLegacyQueue = () => { };
561
- if (!scheduled) {
562
- const previous = legacyTaskTails.get(key);
563
- legacyQueueDone = new Promise((resolve) => { finishLegacyQueue = resolve; });
564
- legacyTaskTails.set(key, legacyQueueDone);
565
- if (previous) {
566
- log(`⏳ 排队(该任务已在运行): ${key}`);
567
- dslog("run.wake_queued", "唤醒已排队:该任务正在运行", { ...runKeys });
568
- await previous;
569
- }
570
- }
571
- running.add(key);
572
- // 并行槽:同 agent 超过配置上限的任务在此排队(不丢),有空位再跑。
573
- await acquireSlot(msg.agentHandle);
574
- const queueMs = Date.now() - queueWaitStart;
575
- const threadLabel = threadId ?? null;
576
- const from = msg.wake?.senderHandle ?? "?";
577
- const incoming = msg.wake?.content ?? "";
578
- dslog("run.start", `开始运行 ${msg.agentHandle}`, { ...runKeys, queue_ms: queueMs });
579
- const runStartedAt = Date.now();
580
- log(`\n${"─".repeat(56)}`);
581
- log(`🔔 唤醒 agent=${msg.agentHandle} reason=${msg.reason ?? "?"}`);
582
- log(` channel = ${msg.channelId}`);
583
- log(` thread = ${threadLabel ? `${threadLabel} (要求线程内回复)` : "(无,顶层回复)"}`);
584
- if (incoming)
585
- log(`📥 来信 @${from}: ${incoming.replace(/\s+/g, " ").slice(0, 200)}`);
586
- let actSeq = 0;
587
- const reportActivity = (a) => {
588
- const det = a.detail ? a.detail.replace(/\s+/g, " ").trim() : "";
589
- // 发消息时尽量打印回复正文/目标(--content "..." 或 heredoc 首行)
590
- let line = ` · ${a.label}`;
591
- if (a.kind === "sending") {
592
- const m = det.match(/--content\s+"([^"]*)"/) || det.match(/<<'?\w+'?\s*(.*)/);
593
- line = ` 💬 回复${threadLabel ? `(thread ${threadLabel})` : ""}: ${m ? m[1].slice(0, 160) : det.slice(0, 120)}`;
594
- }
595
- else if (det) {
596
- line += ` ${det.slice(0, 80)}`;
597
- }
598
- log(line);
599
- try {
600
- ws?.send(JSON.stringify({
601
- type: "agent:activity",
602
- agentHandle: msg.agentHandle,
603
- channelId: msg.channelId,
604
- activity: ACTIVITY_MAP[a.kind] ?? "working",
605
- detail: a.detail || a.label,
606
- seq: actSeq++,
607
- }));
608
- }
609
- catch { /* ws 非 OPEN,忽略 */ }
610
- };
611
- // 终端透传:把底层 claude 的每条 console 行按线程上送给 server(独立 seq,落库+广播给 web 终端窗口)。
612
- let conSeq = 0;
613
- const reportConsole = (c) => {
614
- try {
615
- ws?.send(JSON.stringify({
616
- type: "agent:console",
617
- agentHandle: msg.agentHandle,
618
- channelId: msg.channelId,
619
- threadId: threadId ?? null,
620
- stream: c.stream,
621
- text: c.text,
622
- seq: conSeq++,
623
- }));
624
- }
625
- catch { /* ws 非 OPEN,忽略 */ }
626
- };
627
476
  try {
477
+ if (!scheduled) {
478
+ const previous = legacyTaskTails.get(key);
479
+ legacyQueueDone = new Promise((resolve) => { finishLegacyQueue = resolve; });
480
+ legacyTaskTails.set(key, legacyQueueDone);
481
+ if (previous) {
482
+ log(`⏳ 排队(该任务已在运行): ${key}`);
483
+ dslog("run.wake_queued", "唤醒已排队:该任务正在运行", { ...runKeys });
484
+ await awaitWithCancellation(previous, controller.cancellation);
485
+ }
486
+ }
487
+ running.add(key);
488
+ legacyReservation = sharedSlots.reserve(msg.agentHandle, "legacy");
489
+ await awaitWithCancellation(legacyReservation.ready, controller.cancellation);
490
+ const queueMs = Date.now() - queueWaitStart;
491
+ const threadLabel = threadId ?? null;
492
+ const from = msg.wake?.senderHandle ?? "?";
493
+ const incoming = msg.wake?.content ?? "";
494
+ dslog("run.start", `开始运行 ${msg.agentHandle}`, { ...runKeys, queue_ms: queueMs });
495
+ log(`\n${"─".repeat(56)}`);
496
+ log(`🔔 唤醒 agent=${msg.agentHandle} reason=${msg.reason ?? "?"}`);
497
+ log(` channel = ${msg.channelId}`);
498
+ log(` thread = ${threadLabel ? `${threadLabel} (要求线程内回复)` : "(无,顶层回复)"}`);
499
+ if (incoming)
500
+ log(`📥 来信 @${from}: ${incoming.replace(/\s+/g, " ").slice(0, 200)}`);
501
+ let actSeq = 0;
502
+ const reportActivity = (a) => {
503
+ const det = a.detail ? a.detail.replace(/\s+/g, " ").trim() : "";
504
+ // 发消息时尽量打印回复正文/目标(--content "..." 或 heredoc 首行)
505
+ let line = ` · ${a.label}`;
506
+ if (a.kind === "sending") {
507
+ const m = det.match(/--content\s+"([^"]*)"/) || det.match(/<<'?\w+'?\s*(.*)/);
508
+ line = ` 💬 回复${threadLabel ? `(thread ${threadLabel})` : ""}: ${m ? m[1].slice(0, 160) : det.slice(0, 120)}`;
509
+ }
510
+ else if (det) {
511
+ line += ` ${det.slice(0, 80)}`;
512
+ }
513
+ log(line);
514
+ try {
515
+ ws?.send(JSON.stringify({
516
+ type: "agent:activity",
517
+ agentHandle: msg.agentHandle,
518
+ channelId: msg.channelId,
519
+ activity: ACTIVITY_MAP[a.kind] ?? "working",
520
+ detail: a.detail || a.label,
521
+ seq: actSeq++,
522
+ }));
523
+ }
524
+ catch { /* ws 非 OPEN,忽略 */ }
525
+ };
526
+ // 终端透传:把底层 claude 的每条 console 行按线程上送给 server(独立 seq,落库+广播给 web 终端窗口)。
527
+ let conSeq = 0;
528
+ const reportConsole = (c) => {
529
+ try {
530
+ ws?.send(JSON.stringify({
531
+ type: "agent:console",
532
+ agentHandle: msg.agentHandle,
533
+ channelId: msg.channelId,
534
+ threadId: threadId ?? null,
535
+ stream: c.stream,
536
+ text: c.text,
537
+ // 结构化负载(diff/命令/todo…):前端富渲染用;缺省 = 纯文本行
538
+ ...(c.payload !== undefined ? { payload: c.payload } : {}),
539
+ seq: conSeq++,
540
+ }));
541
+ }
542
+ catch { /* ws 非 OPEN,忽略 */ }
543
+ };
628
544
  // 线程聚合:触发消息即任务线程根,你的确认+后续所有回复都要发到它的线程里,
629
545
  // 不要发顶层——这样 task 讨论全部聚合在该 thread 下。
630
546
  const threadHint = threadId
@@ -681,7 +597,7 @@ export function serve(config, opts = {}) {
681
597
  ...(!scheduled && threadId ? { wakeMessageId: threadId } : {}),
682
598
  ...(!scheduled && msg.wake?.seq !== undefined ? { wakeContextUpToSeq: msg.wake.seq } : {}),
683
599
  ...(attemptWake ? { wake: attemptWake } : {}),
684
- }, reportActivity, reportConsole);
600
+ }, reportActivity, reportConsole, { cancellation: controller.cancellation });
685
601
  });
686
602
  const result = mergeRunAgentResults(guarded.results);
687
603
  // 本轮 token 用量上报:runner 已从 result 事件提取(含缓存读/写细分),
@@ -762,6 +678,9 @@ export function serve(config, opts = {}) {
762
678
  }
763
679
  }
764
680
  catch (e) {
681
+ if (controller.cancellation.isRequested() && !(e instanceof RuntimeCancelledError)) {
682
+ legacyStopError = e;
683
+ }
765
684
  log(`❌ runAgent 失败: ${e.message}`);
766
685
  dslog("run.error", `runAgent 失败: ${e.message}`, {
767
686
  level: "ERROR", ...runKeys, duration_ms: Date.now() - runStartedAt,
@@ -796,11 +715,16 @@ export function serve(config, opts = {}) {
796
715
  }
797
716
  finally {
798
717
  running.delete(key);
799
- releaseSlot(msg.agentHandle);
718
+ legacyReservation?.release();
800
719
  finishLegacyQueue();
801
720
  if (legacyQueueDone && legacyTaskTails.get(key) === legacyQueueDone) {
802
721
  legacyTaskTails.delete(key);
803
722
  }
723
+ legacyRuns.delete(runId);
724
+ if (legacyStopError === undefined)
725
+ finishLegacyRun();
726
+ else
727
+ failLegacyRun(legacyStopError);
804
728
  void flushSlog(); // 每轮收尾冲一次,保证 run.end 尽快可查
805
729
  }
806
730
  });
@@ -823,22 +747,67 @@ export function serve(config, opts = {}) {
823
747
  running_tasks: [...running].join(","),
824
748
  });
825
749
  void flushSlog();
826
- setTimeout(connect, backoff);
750
+ reconnectTimer = setTimeout(() => {
751
+ reconnectTimer = null;
752
+ connect();
753
+ }, backoff);
827
754
  backoff = Math.min(backoff * 2, maxBackoff);
828
755
  });
829
- ws.on("error", () => ws?.close());
756
+ ws.on("error", () => {
757
+ if (!stopped)
758
+ ws?.close();
759
+ });
830
760
  }
831
- connect();
832
- return {
833
- stop: () => {
761
+ const ready = (async () => {
762
+ await reconcileExecutionJournal(executionJournal, {
763
+ agentsRoot: config.agentsRoot,
764
+ serverUrl: config.serverUrl,
765
+ ...(opts.profileName === undefined ? {} : { profileName: opts.profileName }),
766
+ log: dslog,
767
+ flush: flushSlog,
768
+ writeStderr: (line) => process.stderr.write(line),
769
+ });
770
+ connect();
771
+ })();
772
+ const stop = () => {
773
+ if (stopPromise !== null)
774
+ return stopPromise;
775
+ stopPromise = (async () => {
834
776
  stopped = true;
835
- ws?.close();
836
- for (const executionId of executionRuns.keys())
837
- requestCancellation(executionId);
838
- void Promise.race([
839
- Promise.allSettled([...executionRuns.values()]).then(() => true),
840
- new Promise((resolve) => setTimeout(() => resolve(false), 10_000)),
841
- ]).then((settled) => settled ? executionJournal.close() : undefined);
842
- },
777
+ const deadline = createShutdownDeadline(shutdownTimeoutMs);
778
+ if (reconnectTimer !== null) {
779
+ clearTimeout(reconnectTimer);
780
+ reconnectTimer = null;
781
+ }
782
+ const webSocketClosed = closeWebSocketWithinDeadline(ws, deadline.signal);
783
+ const pending = [
784
+ webSocketClosed,
785
+ executionFrameQueue,
786
+ ...executionRuns.values(),
787
+ ...[...legacyRuns.values()].map((run) => run.done),
788
+ ...(testShutdown.barrier === null ? [] : [testShutdown.barrier]),
789
+ ];
790
+ try {
791
+ for (const executionId of executionRuns.keys())
792
+ requestCancellation(executionId);
793
+ for (const run of legacyRuns.values())
794
+ run.controller.request();
795
+ await deadline.waitFor(Promise.all(pending));
796
+ await executionJournal.close({ signal: deadline.signal });
797
+ }
798
+ finally {
799
+ deadline.dispose();
800
+ }
801
+ })();
802
+ return stopPromise;
803
+ };
804
+ return {
805
+ ready,
806
+ stop,
807
+ shutdownSnapshot: () => ({
808
+ activeExecutionCount: executionRuns.size,
809
+ activeLegacyCount: legacyRuns.size,
810
+ deadlineMs: shutdownTimeoutMs,
811
+ }),
843
812
  };
844
813
  }
@@ -0,0 +1,68 @@
1
+ export function createSharedSlotManager(limits) {
2
+ const activeByHandle = new Map();
3
+ const queuesByHandle = new Map();
4
+ const promoteNext = (handle) => {
5
+ const queue = queuesByHandle.get(handle) ?? [];
6
+ while ((activeByHandle.get(handle) ?? 0) < limits.maxParallelPerAgent && queue.length > 0) {
7
+ const next = queue.shift();
8
+ if (next.released)
9
+ continue;
10
+ next.promoted = true;
11
+ activeByHandle.set(handle, (activeByHandle.get(handle) ?? 0) + 1);
12
+ next.resolve();
13
+ }
14
+ if (queue.length === 0)
15
+ queuesByHandle.delete(handle);
16
+ };
17
+ return {
18
+ reserve: (handle, kind) => {
19
+ const active = activeByHandle.get(handle) ?? 0;
20
+ const queued = queuesByHandle.get(handle)?.length ?? 0;
21
+ const facts = { activeForAgent: active, queuedForAgent: queued };
22
+ if (kind === "execution" && active >= limits.maxParallelPerAgent
23
+ && queued >= limits.maxQueuedPerAgent) {
24
+ return { facts, ready: Promise.resolve(), isQueued: () => false, release: () => { } };
25
+ }
26
+ let resolveReady;
27
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
28
+ const entry = {
29
+ kind,
30
+ released: false,
31
+ promoted: active < limits.maxParallelPerAgent,
32
+ resolve: resolveReady,
33
+ };
34
+ if (entry.promoted) {
35
+ activeByHandle.set(handle, active + 1);
36
+ resolveReady();
37
+ }
38
+ else {
39
+ const queue = queuesByHandle.get(handle) ?? [];
40
+ queue.push(entry);
41
+ queuesByHandle.set(handle, queue);
42
+ }
43
+ return {
44
+ facts,
45
+ state: entry.promoted ? "ready" : "queued",
46
+ ready,
47
+ isQueued: () => !entry.promoted && !entry.released,
48
+ release: () => {
49
+ if (entry.released)
50
+ return;
51
+ entry.released = true;
52
+ if (entry.promoted) {
53
+ activeByHandle.set(handle, Math.max(0, (activeByHandle.get(handle) ?? 1) - 1));
54
+ }
55
+ else {
56
+ const queue = queuesByHandle.get(handle);
57
+ const index = queue?.indexOf(entry) ?? -1;
58
+ if (queue !== undefined && index >= 0)
59
+ queue.splice(index, 1);
60
+ if (queue?.length === 0)
61
+ queuesByHandle.delete(handle);
62
+ }
63
+ promoteNext(handle);
64
+ },
65
+ };
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,32 @@
1
+ export function readTestShutdownConfiguration(env) {
2
+ if (env.NODE_ENV !== "test")
3
+ return { barrier: null };
4
+ const timeoutMs = env.CREW_DAEMON_TEST_SHUTDOWN_TIMEOUT_MS === undefined
5
+ ? undefined
6
+ : Number(env.CREW_DAEMON_TEST_SHUTDOWN_TIMEOUT_MS);
7
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
8
+ throw new RangeError("CREW_DAEMON_TEST_SHUTDOWN_TIMEOUT_MS must be a positive number");
9
+ }
10
+ return {
11
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
12
+ barrier: env.CREW_DAEMON_TEST_SHUTDOWN_BARRIER === "1" ? new Promise(() => { }) : null,
13
+ };
14
+ }
15
+ export function createShutdownDeadline(timeoutMs) {
16
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
17
+ throw new RangeError("shutdown timeout must be a positive finite number");
18
+ }
19
+ const controller = new AbortController();
20
+ const timeoutError = new Error(`Timed out waiting for daemon shutdown after ${timeoutMs}ms`);
21
+ let rejectExpired;
22
+ const expired = new Promise((_resolve, reject) => { rejectExpired = reject; });
23
+ const timer = setTimeout(() => {
24
+ controller.abort(timeoutError);
25
+ rejectExpired(timeoutError);
26
+ }, timeoutMs);
27
+ return {
28
+ signal: controller.signal,
29
+ waitFor: async (operation) => Promise.race([operation, expired]),
30
+ dispose: () => clearTimeout(timer),
31
+ };
32
+ }