@nowcrew/daemon 0.6.33 → 0.6.34

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.
Files changed (36) hide show
  1. package/dist/execution-concurrency-telemetry.js +115 -0
  2. package/dist/execution-journal.js +103 -20
  3. package/dist/execution-runner.js +56 -14
  4. package/dist/local-executor.js +22 -2
  5. package/dist/project-skills/serve-startup.js +15 -0
  6. package/dist/remote/claude-bridge.js +558 -0
  7. package/dist/remote/claude-channel.js +164 -0
  8. package/dist/remote/codex-client.js +451 -0
  9. package/dist/remote/codex-runtime.js +77 -0
  10. package/dist/remote/config.js +135 -0
  11. package/dist/remote/gateway.js +879 -0
  12. package/dist/remote/identity.js +39 -0
  13. package/dist/remote/owner.js +77 -0
  14. package/dist/remote/protocol.js +211 -0
  15. package/dist/remote/remote-cli.js +254 -0
  16. package/dist/remote/runtime-probe.js +182 -0
  17. package/dist/remote/session-discovery.js +249 -0
  18. package/dist/remote/wrapper.js +40 -0
  19. package/dist/remote-web/assets/index-B_6VM_tw.js +94 -0
  20. package/dist/remote-web/assets/index-L6EiQbJn.css +1 -0
  21. package/dist/remote-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  22. package/dist/remote-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  23. package/dist/remote-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  24. package/dist/remote-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  25. package/dist/remote-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  26. package/dist/remote-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  27. package/dist/remote-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  28. package/dist/remote-web/icons/nowwork-192.png +0 -0
  29. package/dist/remote-web/icons/nowwork-512.png +0 -0
  30. package/dist/remote-web/icons/nowwork.svg +7 -0
  31. package/dist/remote-web/index.html +20 -0
  32. package/dist/remote-web/manifest.webmanifest +13 -0
  33. package/dist/remote-web/sw.js +12 -0
  34. package/dist/runner.js +2 -2
  35. package/dist/serve.js +42 -6
  36. package/package.json +1 -1
@@ -0,0 +1,115 @@
1
+ import { dslog } from "./slog.js";
2
+ const RUNTIMES = [
3
+ "claude",
4
+ "codex",
5
+ "kimi",
6
+ "hermes",
7
+ "opencode",
8
+ "deepseek-harness",
9
+ ];
10
+ function snapshotFields(snapshot, tracked) {
11
+ const runtimeFields = {};
12
+ for (const runtime of RUNTIMES) {
13
+ for (const phase of ["queued", "preparing", "starting", "running"]) {
14
+ runtimeFields[`${runtime.replaceAll("-", "_")}_${phase}_total`] = [...tracked.values()]
15
+ .filter((entry) => entry.runtime === runtime && entry.phase === phase).length;
16
+ }
17
+ }
18
+ return {
19
+ active_total: snapshot.activeTotal,
20
+ admitted_total: snapshot.admittedTotal,
21
+ queued_total: snapshot.queuedTotal,
22
+ preparing_total: snapshot.preparingTotal,
23
+ starting_total: snapshot.startingTotal,
24
+ running_total: snapshot.runningTotal,
25
+ memory_prune_admitted_total: snapshot.memoryPruneAdmittedTotal,
26
+ memory_prune_queued_total: snapshot.memoryPruneQueuedTotal,
27
+ memory_prune_preparing_total: snapshot.memoryPrunePreparingTotal,
28
+ memory_prune_starting_total: snapshot.memoryPruneStartingTotal,
29
+ memory_prune_running_total: snapshot.memoryPruneRunningTotal,
30
+ runtime_tracked_total: tracked.size,
31
+ ...runtimeFields,
32
+ };
33
+ }
34
+ function hasWork(snapshot, tracked) {
35
+ return tracked.size > 0
36
+ || snapshot.admittedTotal > 0
37
+ || snapshot.queuedTotal > 0
38
+ || snapshot.memoryPruneAdmittedTotal > 0
39
+ || snapshot.memoryPruneQueuedTotal > 0;
40
+ }
41
+ export function createExecutionConcurrencyTelemetry(slots, options = {}) {
42
+ const tracked = new Map();
43
+ const log = options.log ?? dslog;
44
+ const intervalMs = options.intervalMs ?? 30_000;
45
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
46
+ throw new RangeError("concurrency telemetry intervalMs must be positive");
47
+ }
48
+ let timer = null;
49
+ const writeSnapshot = (reason) => {
50
+ const current = slots.snapshot();
51
+ if (!hasWork(current, tracked))
52
+ return;
53
+ log("execution.concurrency_snapshot", "execution 并发快照", {
54
+ reason,
55
+ ...snapshotFields(current, tracked),
56
+ });
57
+ };
58
+ const ensureTimer = () => {
59
+ if (timer !== null)
60
+ return;
61
+ timer = setInterval(() => writeSnapshot("periodic"), intervalMs);
62
+ timer.unref?.();
63
+ };
64
+ const stopTimerIfIdle = () => {
65
+ if (timer === null || hasWork(slots.snapshot(), tracked))
66
+ return;
67
+ clearInterval(timer);
68
+ timer = null;
69
+ };
70
+ return {
71
+ transition: (identity, phase, fields = {}) => {
72
+ const previousPhase = tracked.get(identity.executionId)?.phase ?? null;
73
+ tracked.set(identity.executionId, { runtime: identity.runtime, phase });
74
+ ensureTimer();
75
+ log("execution.phase_changed", "execution 阶段变更", {
76
+ execution_id: identity.executionId,
77
+ agent_handle: identity.agentHandle,
78
+ runtime: identity.runtime,
79
+ execution_class: identity.executionClass,
80
+ protocol: identity.protocol,
81
+ previous_phase: previousPhase,
82
+ phase,
83
+ ...snapshotFields(slots.snapshot(), tracked),
84
+ ...fields,
85
+ });
86
+ },
87
+ release: (identity, reason, fields = {}) => {
88
+ const previousPhase = tracked.get(identity.executionId)?.phase ?? null;
89
+ tracked.delete(identity.executionId);
90
+ log("execution.phase_changed", "execution 已释放执行名额", {
91
+ execution_id: identity.executionId,
92
+ agent_handle: identity.agentHandle,
93
+ runtime: identity.runtime,
94
+ execution_class: identity.executionClass,
95
+ protocol: identity.protocol,
96
+ previous_phase: previousPhase,
97
+ phase: "released",
98
+ release_reason: reason,
99
+ ...snapshotFields(slots.snapshot(), tracked),
100
+ ...fields,
101
+ });
102
+ stopTimerIfIdle();
103
+ },
104
+ snapshot: (reason) => {
105
+ writeSnapshot(reason);
106
+ if (hasWork(slots.snapshot(), tracked))
107
+ ensureTimer();
108
+ },
109
+ stop: () => {
110
+ if (timer !== null)
111
+ clearInterval(timer);
112
+ timer = null;
113
+ },
114
+ };
115
+ }
@@ -129,10 +129,45 @@ const DEFAULT_TERMINATION_GRACE_MS = 30_000;
129
129
  const DEFAULT_KILL_VERIFICATION_DELAY_MS = 100;
130
130
  const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
131
131
  const directoryQueues = new Map();
132
- function runSerialized(directory, operation) {
133
- const previous = directoryQueues.get(directory) ?? Promise.resolve();
134
- const result = previous.then(operation, operation);
135
- directoryQueues.set(directory, result.then(() => undefined, () => undefined));
132
+ function directoryQueue(directory) {
133
+ const existing = directoryQueues.get(directory);
134
+ if (existing !== undefined)
135
+ return existing;
136
+ const created = {
137
+ exclusiveTail: Promise.resolve(),
138
+ keyedTails: new Map(),
139
+ };
140
+ directoryQueues.set(directory, created);
141
+ return created;
142
+ }
143
+ /**
144
+ * Serialize mutations for one execution while allowing unrelated execution
145
+ * records to make progress concurrently. Directory maintenance scheduled
146
+ * before this operation remains an ordering barrier.
147
+ */
148
+ function runKeyedSerialized(directory, key, operation) {
149
+ const queue = directoryQueue(directory);
150
+ const previousKey = queue.keyedTails.get(key) ?? Promise.resolve();
151
+ const result = Promise.all([queue.exclusiveTail, previousKey]).then(operation);
152
+ const tail = result.then(() => undefined, () => undefined);
153
+ queue.keyedTails.set(key, tail);
154
+ void tail.then(() => {
155
+ if (queue.keyedTails.get(key) === tail)
156
+ queue.keyedTails.delete(key);
157
+ });
158
+ return result;
159
+ }
160
+ /**
161
+ * Directory-wide work is an exclusive barrier: it waits for every mutation
162
+ * already scheduled, and later keyed mutations wait for it. Recovery, prune,
163
+ * initialization, and close use this path because they inspect directory-wide
164
+ * state or journal lease ownership.
165
+ */
166
+ function runDirectoryExclusive(directory, operation) {
167
+ const queue = directoryQueue(directory);
168
+ const blockers = [queue.exclusiveTail, ...queue.keyedTails.values()];
169
+ const result = Promise.all(blockers).then(operation);
170
+ queue.exclusiveTail = result.then(() => undefined, () => undefined);
136
171
  return result;
137
172
  }
138
173
  function runCommand(command, args, options = {}) {
@@ -312,6 +347,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
312
347
  if (!Number.isInteger(maxEntries))
313
348
  throw new RangeError("maxEntries must be an integer");
314
349
  let initialized = false;
350
+ let initializationPromise = null;
315
351
  const recordPath = (executionId) => join(directory, `${executionId}.json`);
316
352
  const runtimeReadyPath = (executionId) => join(directory, `${executionId}${RUNTIME_READY_SUFFIX}`);
317
353
  const parseRecord = (path, raw) => {
@@ -528,11 +564,57 @@ export function createExecutionJournal(agentsRoot, options = {}) {
528
564
  await pruneInternal();
529
565
  initialized = true;
530
566
  };
531
- const serialized = (operation) => runSerialized(directory, async () => {
532
- lease.assertUsable();
533
- await initialize();
534
- return operation();
535
- });
567
+ const ensureInitialized = () => {
568
+ if (initialized)
569
+ return Promise.resolve();
570
+ if (initializationPromise !== null)
571
+ return initializationPromise;
572
+ const initializing = runDirectoryExclusive(directory, async () => {
573
+ lease.assertUsable();
574
+ await initialize();
575
+ });
576
+ initializationPromise = initializing;
577
+ void initializing.catch(() => {
578
+ if (initializationPromise === initializing)
579
+ initializationPromise = null;
580
+ });
581
+ return initializing;
582
+ };
583
+ const serializedExecution = async (executionId, operationName, operation) => {
584
+ await ensureInitialized();
585
+ const queuedAt = Date.now();
586
+ return runKeyedSerialized(directory, executionId, async () => {
587
+ lease.assertUsable();
588
+ const operationStartedAt = Date.now();
589
+ let outcome = "succeeded";
590
+ try {
591
+ return await operation();
592
+ }
593
+ catch (error) {
594
+ outcome = "failed";
595
+ throw error;
596
+ }
597
+ finally {
598
+ try {
599
+ options.onOperationTiming?.({
600
+ executionId,
601
+ operation: operationName,
602
+ queueWaitMs: operationStartedAt - queuedAt,
603
+ operationMs: Date.now() - operationStartedAt,
604
+ outcome,
605
+ });
606
+ }
607
+ catch { /* diagnostics must never affect journal durability */ }
608
+ }
609
+ });
610
+ };
611
+ const serializedDirectory = async (operation) => {
612
+ await ensureInitialized();
613
+ return runDirectoryExclusive(directory, async () => {
614
+ lease.assertUsable();
615
+ return operation();
616
+ });
617
+ };
536
618
  const requireRecord = async (executionId) => {
537
619
  const entry = await readRecord(executionId);
538
620
  if (entry === null)
@@ -636,7 +718,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
636
718
  validateExecutionId(executionId);
637
719
  if (specHash.length === 0)
638
720
  throw new TypeError("specHash must not be empty");
639
- return serialized(async () => {
721
+ return serializedExecution(executionId, "accept", async () => {
640
722
  const existing = await readRecord(executionId);
641
723
  if (existing !== null) {
642
724
  if (existing.specHash !== specHash) {
@@ -669,7 +751,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
669
751
  startGuarded: async (executionId, processStartedAt, startDormant, hooks) => {
670
752
  validateExecutionId(executionId);
671
753
  TimestampSchema.parse(processStartedAt);
672
- return serialized(async () => {
754
+ return serializedExecution(executionId, "start_guarded", async () => {
673
755
  const entry = await requireRecord(executionId);
674
756
  if (entry.state !== "accepted") {
675
757
  return { kind: "existing", entry: await confirmDurable(entry) };
@@ -729,7 +811,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
729
811
  markRuntimeReady: async (executionId, runtimeReadyAt) => {
730
812
  validateExecutionId(executionId);
731
813
  TimestampSchema.parse(runtimeReadyAt);
732
- return serialized(async () => {
814
+ return serializedExecution(executionId, "mark_runtime_ready", async () => {
733
815
  const entry = await requireRecord(executionId);
734
816
  if (entry.state !== "running") {
735
817
  throw new JournalTransitionError(`Cannot mark ${entry.state} execution runtime-ready`);
@@ -751,7 +833,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
751
833
  if (completion.executionId !== executionId) {
752
834
  throw new JournalConflictError("Completion executionId does not match the journal record");
753
835
  }
754
- return serialized(async () => {
836
+ return serializedExecution(executionId, "complete", async () => {
755
837
  const entry = await requireRecord(executionId);
756
838
  if (entry.state === "completed" || entry.state === "interrupted") {
757
839
  if (sameValue(entry.completion, completion))
@@ -772,7 +854,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
772
854
  },
773
855
  acknowledgeCompletion: async (executionId) => {
774
856
  validateExecutionId(executionId);
775
- return serialized(async () => {
857
+ const updated = await serializedExecution(executionId, "acknowledge_completion", async () => {
776
858
  const entry = await requireRecord(executionId);
777
859
  if (entry.state !== "completed" && entry.state !== "interrupted") {
778
860
  throw new JournalTransitionError(`Cannot acknowledge ${entry.state} execution`);
@@ -785,20 +867,21 @@ export function createExecutionJournal(agentsRoot, options = {}) {
785
867
  updatedAt: now().toISOString(),
786
868
  });
787
869
  await writeRecord(updated);
788
- await pruneInternal();
789
870
  return updated;
790
871
  });
872
+ await serializedDirectory(pruneInternal);
873
+ return updated;
791
874
  },
792
875
  get: async (executionId) => {
793
876
  validateExecutionId(executionId);
794
- return serialized(() => readRecord(executionId));
877
+ return serializedExecution(executionId, "get", () => readRecord(executionId));
795
878
  },
796
- replay: async () => serialized(async () => (await readAll())
879
+ replay: async () => serializedDirectory(async () => (await readAll())
797
880
  .filter((entry) => entry.state === "accepted"
798
881
  || entry.state === "running"
799
882
  || !entry.completionAcknowledged)
800
883
  .sort((left, right) => left.executionId.localeCompare(right.executionId))),
801
- reconcileAfterRestart: async () => serialized(async () => {
884
+ reconcileAfterRestart: async () => serializedDirectory(async () => {
802
885
  const active = (await readAll()).filter((entry) => entry.state === "accepted" || entry.state === "running");
803
886
  const results = await Promise.allSettled(active.map(async (entry) => {
804
887
  if (entry.state === "running")
@@ -812,11 +895,11 @@ export function createExecutionJournal(agentsRoot, options = {}) {
812
895
  throw new AggregateError(failures, "One or more executions could not be reconciled");
813
896
  }
814
897
  }),
815
- prune: async () => serialized(pruneInternal),
898
+ prune: async () => serializedDirectory(pruneInternal),
816
899
  close: async (closeOptions = {}) => {
817
900
  const signal = closeOptions.signal;
818
901
  let started = false;
819
- const closing = runSerialized(directory, async () => {
902
+ const closing = runDirectoryExclusive(directory, async () => {
820
903
  started = true;
821
904
  if (signal?.aborted)
822
905
  throw signal.reason ?? new Error("Journal close aborted");
@@ -4,7 +4,7 @@ import { DaemonToServerExecutionFrameSchema, ExecutionCompletedSchema, Execution
4
4
  import { JournalConflictError } from "./execution-journal.js";
5
5
  import { boundExecutionFrame } from "./execution-event-limit.js";
6
6
  import { mintAgentToken } from "./token.js";
7
- import { executeLocal, withLocalExecutionFacts, } from "./local-executor.js";
7
+ import { executeLocal, RuntimeRunningCallbackTimeoutError, withLocalExecutionFacts, } from "./local-executor.js";
8
8
  import { startDormantSupervisor, } from "./execution-supervisor.js";
9
9
  import { CLAUDE_EFFORT_LEVELS } from "./runtimes/claude.js";
10
10
  import { CODEX_EFFORT_LEVELS } from "./runtimes/codex.js";
@@ -21,6 +21,7 @@ import { createProjectRegistry } from "./project-skills/registry.js";
21
21
  import { ProjectContextUnavailableError, resolveProjectContext } from "./project-workspaces/resolver.js";
22
22
  import { PROJECT_WORKSPACES_CAPABILITY } from "./machine-info.js";
23
23
  import { PROJECT_SKILL_PROJECTION_V2_CAPABILITY } from "./project-skills/types.js";
24
+ import { dslog } from "./slog.js";
24
25
  export { supervisorLaunch } from "./supervised-runtime.js";
25
26
  const ACTIVITY_KIND = {
26
27
  init: "working",
@@ -456,6 +457,7 @@ export async function runExecution(config, input, dependencies) {
456
457
  let timeout;
457
458
  let timedOut = false;
458
459
  let completion;
460
+ let abandonedRuntimeRunningCompletion = null;
459
461
  let memoryCaptureFinalText = null;
460
462
  let boundImDecision = spec.reporting.allowBoundImDecision
461
463
  ? "silent"
@@ -498,9 +500,11 @@ export async function runExecution(config, input, dependencies) {
498
500
  let externalOutputSequence = 0;
499
501
  const callbacks = {
500
502
  onRuntimeStarting: () => {
501
- dependencies.onRuntimePhase?.("starting");
503
+ dependencies.onRuntimePhase?.("starting", spec.runtime.name);
502
504
  },
503
505
  onRuntimeRunning: async () => {
506
+ const callbackStartedAt = Date.now();
507
+ let callbackStage = "journal";
504
508
  if (dependencies.cancellation?.isRequested())
505
509
  return;
506
510
  const cancel = runtimeCancel;
@@ -515,18 +519,50 @@ export async function runExecution(config, input, dependencies) {
515
519
  }
516
520
  }, effectiveTimeoutMs);
517
521
  }
518
- dependencies.onRuntimePhase?.("running");
519
- const ready = await dependencies.journal.markRuntimeReady(spec.executionId, now().toISOString());
520
- if (ready.runtimeReadyAt === null) {
521
- throw new Error(`Execution ${spec.executionId} runtime readiness was not persisted`);
522
+ dependencies.onRuntimePhase?.("running", spec.runtime.name);
523
+ const journalStartedAt = Date.now();
524
+ try {
525
+ const ready = await dependencies.journal.markRuntimeReady(spec.executionId, now().toISOString());
526
+ const journalMs = Date.now() - journalStartedAt;
527
+ if (ready.runtimeReadyAt === null) {
528
+ throw new Error(`Execution ${spec.executionId} runtime readiness was not persisted`);
529
+ }
530
+ if (abandonedRuntimeRunningCompletion !== null) {
531
+ await dependencies.journal.complete(spec.executionId, abandonedRuntimeRunningCompletion);
532
+ await dependencies.report(abandonedRuntimeRunningCompletion);
533
+ return;
534
+ }
535
+ startedAt = ready.runtimeReadyAt;
536
+ callbackStage = "started_report";
537
+ const reportStartedAt = Date.now();
538
+ const reportDelivered = await reportBestEffort(dependencies.report, boundExecutionFrame(DaemonToServerExecutionFrameSchema.parse({
539
+ type: "execution:started",
540
+ protocolVersion: 1,
541
+ executionId: spec.executionId,
542
+ at: startedAt,
543
+ }), config.executionLimits.maxEventBytes), bestEffortTimeoutMs);
544
+ dslog("execution.runtime_running_callback", "runtime-running callback 已完成", {
545
+ execution_id: spec.executionId,
546
+ runtime: spec.runtime.name,
547
+ outcome: "succeeded",
548
+ journal_ms: journalMs,
549
+ started_report_ms: Date.now() - reportStartedAt,
550
+ started_report_delivered: reportDelivered,
551
+ callback_total_ms: Date.now() - callbackStartedAt,
552
+ });
553
+ }
554
+ catch (error) {
555
+ dslog("execution.runtime_running_callback", "runtime-running callback 失败", {
556
+ level: "ERROR",
557
+ execution_id: spec.executionId,
558
+ runtime: spec.runtime.name,
559
+ outcome: "failed",
560
+ failed_stage: callbackStage,
561
+ callback_total_ms: Date.now() - callbackStartedAt,
562
+ error_message: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
563
+ });
564
+ throw error;
522
565
  }
523
- startedAt = ready.runtimeReadyAt;
524
- await reportBestEffort(dependencies.report, boundExecutionFrame(DaemonToServerExecutionFrameSchema.parse({
525
- type: "execution:started",
526
- protocolVersion: 1,
527
- executionId: spec.executionId,
528
- at: startedAt,
529
- }), config.executionLimits.maxEventBytes), bestEffortTimeoutMs);
530
566
  },
531
567
  ...(spec.reporting.streamActivity ? {
532
568
  onActivity: (activity) => {
@@ -812,7 +848,7 @@ export async function runExecution(config, input, dependencies) {
812
848
  else {
813
849
  await proveActiveSupervisorStopped(error, spec.agent.projectSkillBindingGeneration !== undefined);
814
850
  }
815
- completion = cancelled
851
+ const failureCompletion = cancelled
816
852
  ? ExecutionCompletedSchema.parse({
817
853
  type: "execution:completed",
818
854
  protocolVersion: 1,
@@ -827,6 +863,12 @@ export async function runExecution(config, input, dependencies) {
827
863
  finishedAt: now().toISOString(),
828
864
  })
829
865
  : failedCompletion(spec, error, startedAt, now().toISOString());
866
+ if (error instanceof RuntimeRunningCallbackTimeoutError) {
867
+ abandonedRuntimeRunningCompletion = boundExecutionFrame(failureCompletion, config.executionLimits.maxEventBytes);
868
+ await telemetry.closeAndDrain();
869
+ throw error;
870
+ }
871
+ completion = failureCompletion;
830
872
  }
831
873
  completion = boundExecutionFrame(completion, config.executionLimits.maxEventBytes);
832
874
  await telemetry.closeAndDrain();
@@ -60,8 +60,18 @@ function logMemoryPruneDiagnosticsFailure(input, traceId, phase, error) {
60
60
  error_code: errorCode,
61
61
  });
62
62
  }
63
+ export class RuntimeRunningCallbackTimeoutError extends Error {
64
+ constructor(runtime, timeoutMs) {
65
+ super(`${runtime} runtime-running callback timed out after ${timeoutMs}ms`);
66
+ this.name = "RuntimeRunningCallbackTimeoutError";
67
+ }
68
+ }
63
69
  const STDERR_TAIL_CAP = 2_000;
64
- const RUNTIME_RUNNING_CALLBACK_TIMEOUT_MS = 10_000;
70
+ // This is a liveness fuse, not a normal durability latency budget. Journal
71
+ // mutations are keyed per execution, so a healthy callback should finish in
72
+ // milliseconds; 30s tolerates a transient filesystem stall without reviving
73
+ // the old failure mode where a callback could hold execution slots forever.
74
+ const RUNTIME_RUNNING_CALLBACK_TIMEOUT_MS = 30_000;
65
75
  const MEMORY_PRUNE_DIAGNOSTICS_TIMEOUT_MS = 2_000;
66
76
  const LOCAL_MEMORY_DIAGNOSTICS_TIMEOUT_MS = 250;
67
77
  const CLAUDE_INSTRUCTION_WARNING = "claude_additional_directory_instructions_unverified";
@@ -682,19 +692,29 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
682
692
  if (runtimeRunning)
683
693
  return;
684
694
  runtimeRunning = true;
695
+ const callbackStartedAt = Date.now();
685
696
  let callbackTimer;
686
697
  runtimeRunningNotification = Promise.race([
687
698
  Promise.resolve().then(() => callbacks.onRuntimeRunning?.(runtime.name)),
688
699
  new Promise((_resolve, reject) => {
689
700
  const callbackTimeoutMs = dependencies.runtimeRunningCallbackTimeoutMs
690
701
  ?? RUNTIME_RUNNING_CALLBACK_TIMEOUT_MS;
691
- callbackTimer = setTimeout(() => reject(new Error(`${runtime.name} runtime-running callback timed out after ${callbackTimeoutMs}ms`)), callbackTimeoutMs);
702
+ callbackTimer = setTimeout(() => reject(new RuntimeRunningCallbackTimeoutError(runtime.name, callbackTimeoutMs)), callbackTimeoutMs);
692
703
  }),
693
704
  ]).finally(() => {
694
705
  if (callbackTimer !== undefined)
695
706
  clearTimeout(callbackTimer);
696
707
  });
697
708
  void runtimeRunningNotification.catch((error) => {
709
+ dslog("runtime.running_callback_failed", "runtime-running 回调失败", {
710
+ level: "ERROR",
711
+ execution_id: input.executionId,
712
+ runtime: runtime.name,
713
+ callback_ms: Date.now() - callbackStartedAt,
714
+ error_message: error instanceof Error
715
+ ? error.message.slice(0, 500)
716
+ : String(error).slice(0, 500),
717
+ });
698
718
  // Persisting the running phase is part of execution liveness. Propagate
699
719
  // its failure immediately: cancellation is best-effort and must not be
700
720
  // allowed to keep the execution or its slots alive indefinitely.
@@ -8,6 +8,21 @@ export async function initializeProjectSkillProtectedExecutionJournal(input) {
8
8
  input.protectedExecutionIds.add(executionId);
9
9
  const journal = input.injectedJournal ?? createExecutionJournal(input.config.agentsRoot, {
10
10
  protectedExecutionIds: () => input.protectedExecutionIds,
11
+ onOperationTiming: (event) => {
12
+ if (event.operation !== "mark_runtime_ready"
13
+ && event.queueWaitMs < 250
14
+ && event.operationMs < 250)
15
+ return;
16
+ dslog("execution.journal_operation", "execution journal 操作完成", {
17
+ level: event.outcome === "succeeded" ? "INFO" : "ERROR",
18
+ execution_id: event.executionId,
19
+ operation: event.operation,
20
+ outcome: event.outcome,
21
+ queue_wait_ms: event.queueWaitMs,
22
+ operation_ms: event.operationMs,
23
+ total_ms: event.queueWaitMs + event.operationMs,
24
+ });
25
+ },
11
26
  });
12
27
  await reconcileExecutionJournal(journal, {
13
28
  agentsRoot: input.config.agentsRoot,