create-walle 0.9.25 → 0.9.26

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 (179) hide show
  1. package/README.md +8 -0
  2. package/bin/create-walle.js +815 -45
  3. package/package.json +2 -2
  4. package/template/bin/ctm-dev-cleanup.js +90 -4
  5. package/template/bin/ctm-launch.sh +49 -1
  6. package/template/bin/dev.sh +45 -1
  7. package/template/bin/ensure-stable-node.js +132 -0
  8. package/template/bin/install-service.sh +9 -0
  9. package/template/claude-task-manager/api-prompts.js +899 -119
  10. package/template/claude-task-manager/approval-agent.js +360 -40
  11. package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
  12. package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
  13. package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
  14. package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
  15. package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
  16. package/template/claude-task-manager/db.js +399 -48
  17. package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
  18. package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
  19. package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
  20. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
  21. package/template/claude-task-manager/lib/approval-hook.js +200 -0
  22. package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
  23. package/template/claude-task-manager/lib/auth-rules.js +11 -0
  24. package/template/claude-task-manager/lib/background-llm.js +32 -4
  25. package/template/claude-task-manager/lib/codesign-identity.js +140 -0
  26. package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
  27. package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
  28. package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
  29. package/template/claude-task-manager/lib/codex-paths.js +73 -0
  30. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
  31. package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
  32. package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
  33. package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
  34. package/template/claude-task-manager/lib/command-targets.js +163 -0
  35. package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
  36. package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
  37. package/template/claude-task-manager/lib/escalation-review.js +80 -3
  38. package/template/claude-task-manager/lib/flow-control.js +52 -0
  39. package/template/claude-task-manager/lib/fs-watcher.js +24 -15
  40. package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
  41. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
  42. package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
  43. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
  44. package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
  45. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
  46. package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
  47. package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
  48. package/template/claude-task-manager/lib/perf-tracker.js +29 -2
  49. package/template/claude-task-manager/lib/permission-match.js +146 -16
  50. package/template/claude-task-manager/lib/project-slug.js +33 -0
  51. package/template/claude-task-manager/lib/prompt-intent.js +51 -4
  52. package/template/claude-task-manager/lib/read-pool-client.js +48 -3
  53. package/template/claude-task-manager/lib/real-node.js +73 -0
  54. package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
  55. package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
  56. package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
  57. package/template/claude-task-manager/lib/session-history.js +5 -7
  58. package/template/claude-task-manager/lib/session-host-manager.js +19 -0
  59. package/template/claude-task-manager/lib/session-jobs.js +6 -0
  60. package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
  61. package/template/claude-task-manager/lib/session-messages-page.js +211 -0
  62. package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
  63. package/template/claude-task-manager/lib/session-standup.js +8 -0
  64. package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
  65. package/template/claude-task-manager/lib/session-token-usage.js +30 -8
  66. package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
  67. package/template/claude-task-manager/lib/storage-migration.js +2 -1
  68. package/template/claude-task-manager/lib/transcript-store.js +179 -12
  69. package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
  70. package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
  71. package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
  72. package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
  73. package/template/claude-task-manager/package.json +5 -2
  74. package/template/claude-task-manager/prompt-harvest.js +31 -11
  75. package/template/claude-task-manager/providers/claude-code.js +29 -1
  76. package/template/claude-task-manager/providers/codex.js +13 -1
  77. package/template/claude-task-manager/public/css/setup.css +11 -0
  78. package/template/claude-task-manager/public/css/walle-session.css +132 -4
  79. package/template/claude-task-manager/public/css/walle.css +89 -0
  80. package/template/claude-task-manager/public/icon-16.png +0 -0
  81. package/template/claude-task-manager/public/icon-32.png +0 -0
  82. package/template/claude-task-manager/public/icon-512.png +0 -0
  83. package/template/claude-task-manager/public/index.html +2483 -165
  84. package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
  85. package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
  86. package/template/claude-task-manager/public/js/message-renderer.js +60 -1
  87. package/template/claude-task-manager/public/js/prompts.js +13 -1
  88. package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
  89. package/template/claude-task-manager/public/js/setup.js +54 -10
  90. package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
  91. package/template/claude-task-manager/public/js/stream-view.js +78 -0
  92. package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
  93. package/template/claude-task-manager/public/js/tool-state.js +155 -0
  94. package/template/claude-task-manager/public/js/walle-session.js +887 -326
  95. package/template/claude-task-manager/public/js/walle.js +306 -195
  96. package/template/claude-task-manager/public/m/app.css +1 -0
  97. package/template/claude-task-manager/public/m/app.js +33 -3
  98. package/template/claude-task-manager/queue-engine.js +45 -1
  99. package/template/claude-task-manager/server.js +3367 -540
  100. package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
  101. package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
  102. package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
  103. package/template/claude-task-manager/workers/session-host-process.js +10 -0
  104. package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
  105. package/template/package.json +2 -3
  106. package/template/shared/icons/AppIcon-ctm.icns +0 -0
  107. package/template/shared/icons/AppIcon-walle.icns +0 -0
  108. package/template/wall-e/agent.js +139 -18
  109. package/template/wall-e/api-walle.js +201 -22
  110. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
  111. package/template/wall-e/brain.js +1049 -39
  112. package/template/wall-e/chat.js +427 -86
  113. package/template/wall-e/coding/acceptance-contract.js +26 -1
  114. package/template/wall-e/coding/action-memory-policy.js +353 -0
  115. package/template/wall-e/coding/action-memory-store.js +814 -0
  116. package/template/wall-e/coding/initial-messages.js +197 -0
  117. package/template/wall-e/coding/no-progress-guard.js +327 -0
  118. package/template/wall-e/coding/permission-service.js +88 -22
  119. package/template/wall-e/coding/session-workspaces.js +81 -0
  120. package/template/wall-e/coding/shell-sandbox.js +124 -0
  121. package/template/wall-e/coding/stream-processor.js +63 -2
  122. package/template/wall-e/coding/tool-execution-controller.js +14 -1
  123. package/template/wall-e/coding/tool-registry.js +1 -1
  124. package/template/wall-e/coding/transcript-writer.js +3 -0
  125. package/template/wall-e/coding-orchestrator.js +636 -35
  126. package/template/wall-e/coding-prompts.js +51 -2
  127. package/template/wall-e/docs/model-routing-policy.md +59 -0
  128. package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
  129. package/template/wall-e/extraction/knowledge-extractor.js +76 -23
  130. package/template/wall-e/http/chat-api.js +30 -12
  131. package/template/wall-e/http/model-admin.js +93 -1
  132. package/template/wall-e/lib/background-lanes.js +133 -0
  133. package/template/wall-e/lib/boot-profile.js +11 -0
  134. package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
  135. package/template/wall-e/lib/brain-read-pool-client.js +311 -0
  136. package/template/wall-e/lib/diagnostics-flags.js +87 -0
  137. package/template/wall-e/lib/event-loop-monitor.js +74 -3
  138. package/template/wall-e/lib/mcp-integration.js +7 -1
  139. package/template/wall-e/lib/real-node.js +98 -0
  140. package/template/wall-e/lib/runtime-health.js +206 -0
  141. package/template/wall-e/lib/runtime-worker-pool.js +101 -0
  142. package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
  143. package/template/wall-e/lib/scheduler.js +446 -17
  144. package/template/wall-e/lib/service-health.js +61 -2
  145. package/template/wall-e/lib/service-readiness.js +258 -0
  146. package/template/wall-e/lib/usage.js +152 -0
  147. package/template/wall-e/lib/worker-thread-pool.js +389 -0
  148. package/template/wall-e/llm/client.js +81 -4
  149. package/template/wall-e/llm/default-fallback.js +54 -8
  150. package/template/wall-e/llm/mlx.js +536 -73
  151. package/template/wall-e/llm/mlx.plugin.json +1 -1
  152. package/template/wall-e/llm/ollama.js +342 -43
  153. package/template/wall-e/llm/provider-error.js +18 -1
  154. package/template/wall-e/llm/provider-health-state.js +176 -0
  155. package/template/wall-e/llm/routing-policy.js +796 -0
  156. package/template/wall-e/llm/supported-models.js +5 -0
  157. package/template/wall-e/loops/tasks.js +60 -14
  158. package/template/wall-e/loops/think.js +89 -24
  159. package/template/wall-e/mcp-server.js +192 -28
  160. package/template/wall-e/server.js +32 -7
  161. package/template/wall-e/skills/script-skill-runner.js +8 -1
  162. package/template/wall-e/skills/skill-planner.js +64 -1
  163. package/template/wall-e/tools/builtin-middleware.js +67 -2
  164. package/template/wall-e/tools/local-tools.js +116 -26
  165. package/template/wall-e/tools/permission-checker.js +52 -4
  166. package/template/wall-e/tools/permission-rules.js +36 -0
  167. package/template/wall-e/tools/shell-analyzer.js +46 -1
  168. package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
  169. package/template/wall-e/training/real-trajectory-miner.js +2617 -0
  170. package/template/wall-e/training/replay-eval-analysis.js +151 -0
  171. package/template/wall-e/training/run-shell-command-selector.js +277 -0
  172. package/template/wall-e/training/tool-sft-dataset.js +312 -0
  173. package/template/wall-e/training/tool-sft-renderers.js +144 -0
  174. package/template/wall-e/training/tool-trace-harvester.js +1440 -0
  175. package/template/wall-e/training/trajectory-action-selector.js +364 -0
  176. package/template/wall-e/weather-runtime.js +232 -0
  177. package/template/wall-e/workers/brain-owner-worker.js +162 -0
  178. package/template/wall-e/workers/brain-read-worker.js +148 -0
  179. package/template/wall-e/workers/runtime-worker.js +145 -0
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
  const EventEmitter = require('events');
3
+ const runtimeHealth = require('./runtime-health');
4
+ const { runtimeDiagnosticsEnabled, perfLogsEnabled } = require('./diagnostics-flags');
3
5
 
4
6
  let _instance = null;
5
7
 
@@ -24,6 +26,9 @@ const MIN_REFIRE_GAP_MS = 2_000;
24
26
  */
25
27
  const DEFAULT_MISSED_JOB_STAGGER_MS = 5_000;
26
28
  const DEFAULT_MAX_MISSED_JOBS_PER_RESTART = 5;
29
+ const DEFAULT_SLOW_TICK_MS = 250;
30
+ const DEFAULT_SLOW_JOB_MS = 2_000;
31
+ const PERF_LOG_THROTTLE_MS = 30_000;
27
32
 
28
33
  /** Default cascade check: true if any numeric value in result is > 0 */
29
34
  function _defaultCascadeCheck(result) {
@@ -39,6 +44,8 @@ class Scheduler extends EventEmitter {
39
44
  * @param {number} [opts.tickMs=5000]
40
45
  * @param {Object} [opts.pools] - Resource pool limits: { llm: 2, ollama: 1, ... }
41
46
  * @param {number} [opts.shutdownTimeoutMs=5000]
47
+ * @param {Object} [opts.workerPool] - Optional WorkerThreadPool for off-thread job bodies
48
+ * @param {number} [opts.defaultWorkerJobTimeoutMs=0] - Default timeout for worker-backed jobs
42
49
  */
43
50
  constructor(opts = {}) {
44
51
  super();
@@ -68,6 +75,12 @@ class Scheduler extends EventEmitter {
68
75
  this._persistBrain = opts.persistBrain || null;
69
76
  this._missedJobStaggerMs = opts.missedJobStaggerMs ?? DEFAULT_MISSED_JOB_STAGGER_MS;
70
77
  this._maxMissedJobsPerRestart = opts.maxMissedJobsPerRestart ?? DEFAULT_MAX_MISSED_JOBS_PER_RESTART;
78
+ this._workerPool = opts.workerPool || null;
79
+ this._closeWorkerPoolOnShutdown = opts.closeWorkerPoolOnShutdown === true;
80
+ this._workerJobNames = new Set(Array.isArray(opts.workerJobs) ? opts.workerJobs : []);
81
+ this._defaultWorkerJobTimeoutMs = Number.isFinite(Number(opts.defaultWorkerJobTimeoutMs))
82
+ ? Math.max(0, Math.trunc(Number(opts.defaultWorkerJobTimeoutMs)))
83
+ : 0;
71
84
 
72
85
  this._poolLimits = {
73
86
  llm: 2,
@@ -85,6 +98,25 @@ class Scheduler extends EventEmitter {
85
98
  for (const pool of Object.keys(this._poolLimits)) {
86
99
  this._poolActive.set(pool, new Set());
87
100
  }
101
+
102
+ this._metrics = {
103
+ startedAt: Date.now(),
104
+ tickCount: 0,
105
+ tickErrorCount: 0,
106
+ dispatchCount: 0,
107
+ completionCount: 0,
108
+ errorCount: 0,
109
+ timeoutCount: 0,
110
+ poolFullCount: 0,
111
+ maxTickDurationMs: 0,
112
+ lastTickAt: null,
113
+ lastTickDurationMs: null,
114
+ lastTickSummary: null,
115
+ lastError: null,
116
+ ineligibleCounts: {},
117
+ jobs: new Map(),
118
+ perfLogLastAt: new Map(),
119
+ };
88
120
  }
89
121
 
90
122
  static getInstance() { return _instance; }
@@ -107,6 +139,9 @@ class Scheduler extends EventEmitter {
107
139
  * @param {Function} [desc.shouldRun] - () => boolean guard
108
140
  * @param {Function} [desc.onResult] - (result) => void
109
141
  * @param {Function} [desc.onError] - (err) => void
142
+ * @param {boolean} [desc.worker] - run body through opts.workerPool
143
+ * @param {string} [desc.workerJobName] - worker-side job name (defaults to desc.name)
144
+ * @param {boolean} [desc.workerFallbackLocal=true] - fallback to desc.run if worker unavailable
110
145
  * @returns {Scheduler}
111
146
  */
112
147
  register(desc) {
@@ -114,7 +149,15 @@ class Scheduler extends EventEmitter {
114
149
  throw new Error('Job requires name, run, and intervalMs');
115
150
  }
116
151
  // Clone to avoid mutating caller's object
117
- desc = { priority: 5, enabled: true, ...desc };
152
+ desc = {
153
+ priority: 5,
154
+ enabled: true,
155
+ worker: this._workerJobNames.has(desc.name),
156
+ ...desc,
157
+ };
158
+ if (desc.worker && !desc.timeoutMs && this._defaultWorkerJobTimeoutMs > 0) {
159
+ desc.timeoutMs = this._defaultWorkerJobTimeoutMs;
160
+ }
118
161
 
119
162
  const now = Date.now();
120
163
  const initialState = {
@@ -333,6 +376,10 @@ class Scheduler extends EventEmitter {
333
376
  pool: desc.pool || null,
334
377
  priority: desc.priority,
335
378
  intervalMs: desc.intervalMs,
379
+ timeoutMs: desc.timeoutMs || null,
380
+ workerTimeoutMs: desc.workerTimeoutMs || null,
381
+ worker: !!desc.worker,
382
+ workerJobName: desc.workerJobName || null,
336
383
  };
337
384
  }
338
385
 
@@ -351,13 +398,181 @@ class Scheduler extends EventEmitter {
351
398
  queued,
352
399
  };
353
400
  }
401
+ if (this._workerPool && typeof this._workerPool.getStatus === 'function') {
402
+ result.worker_threads = this._workerPool.getStatus();
403
+ }
354
404
  return result;
355
405
  }
356
406
 
407
+ getActiveSnapshot() {
408
+ const jobs = [];
409
+ for (const [name, job] of this._jobs) {
410
+ if (job.state.status === 'running' || job.state.status === 'queued') {
411
+ jobs.push({
412
+ name,
413
+ status: job.state.status,
414
+ pool: job.descriptor.pool || null,
415
+ worker: !!job.descriptor.worker,
416
+ workerTimeoutMs: job.descriptor.workerTimeoutMs ?? job.descriptor.timeoutMs ?? null,
417
+ age_ms: job.state.lastRunAt ? Math.max(0, Date.now() - job.state.lastRunAt) : 0,
418
+ });
419
+ }
420
+ }
421
+ return {
422
+ jobs,
423
+ pools: this.getPoolState(),
424
+ };
425
+ }
426
+
357
427
  uptime() {
358
428
  return this._startedAt ? Date.now() - this._startedAt : 0;
359
429
  }
360
430
 
431
+ _metricsEnabled() {
432
+ return runtimeDiagnosticsEnabled();
433
+ }
434
+
435
+ _safeMetricName(name) {
436
+ return String(name || 'job')
437
+ .replace(/[^a-zA-Z0-9_.:-]/g, '_')
438
+ .slice(0, 80) || 'job';
439
+ }
440
+
441
+ _maybeLogSlow(kind, name, durationMs, thresholdMs, meta = {}) {
442
+ if (!perfLogsEnabled() || durationMs < thresholdMs) return;
443
+ const key = `${kind}:${name}`;
444
+ const now = Date.now();
445
+ const last = this._metrics.perfLogLastAt.get(key) || 0;
446
+ if (now - last < PERF_LOG_THROTTLE_MS) return;
447
+ this._metrics.perfLogLastAt.set(key, now);
448
+ const safeMeta = {};
449
+ for (const [k, v] of Object.entries(meta || {})) {
450
+ if (typeof v === 'number' || typeof v === 'boolean') safeMeta[k] = v;
451
+ else if (typeof v === 'string') safeMeta[k] = v.slice(0, 80);
452
+ }
453
+ console.warn(`[scheduler-perf] ${kind} ${name} took ${durationMs}ms ${JSON.stringify(safeMeta)}`);
454
+ }
455
+
456
+ _recordTickMetric(summary, durationMs, ok = true, err = null) {
457
+ if (!this._metricsEnabled()) return;
458
+ const meta = {
459
+ jobs: summary?.jobs || 0,
460
+ candidates: summary?.candidates || 0,
461
+ dispatched: summary?.dispatched || 0,
462
+ queued: summary?.queued || 0,
463
+ poolFull: summary?.poolFull || 0,
464
+ watchdogReset: summary?.watchdogReset || 0,
465
+ resetQueued: summary?.resetQueued || 0,
466
+ };
467
+ this._metrics.tickCount++;
468
+ if (!ok) this._metrics.tickErrorCount++;
469
+ this._metrics.lastTickAt = new Date().toISOString();
470
+ this._metrics.lastTickDurationMs = durationMs;
471
+ this._metrics.maxTickDurationMs = Math.max(this._metrics.maxTickDurationMs || 0, durationMs);
472
+ this._metrics.lastTickSummary = summary || null;
473
+ this._metrics.lastError = err ? (err.message || String(err)).slice(0, 160) : null;
474
+ if (summary?.ineligible) {
475
+ for (const [reason, count] of Object.entries(summary.ineligible)) {
476
+ this._metrics.ineligibleCounts[reason] = (this._metrics.ineligibleCounts[reason] || 0) + count;
477
+ }
478
+ }
479
+ this._metrics.poolFullCount += summary?.poolFull || 0;
480
+ runtimeHealth.recordOperation('scheduler.tick', durationMs, {
481
+ ok,
482
+ error: err ? (err.code || err.name || 'error') : undefined,
483
+ meta,
484
+ });
485
+ this._maybeLogSlow('tick', 'scheduler.tick', durationMs, DEFAULT_SLOW_TICK_MS, meta);
486
+ }
487
+
488
+ _recordDispatchMetric() {
489
+ if (!this._metricsEnabled()) return;
490
+ this._metrics.dispatchCount++;
491
+ }
492
+
493
+ _recordJobMetric(name, durationMs, ok = true, err = null, meta = {}) {
494
+ if (!this._metricsEnabled()) return;
495
+ const safeName = this._safeMetricName(name);
496
+ let bucket = this._metrics.jobs.get(safeName);
497
+ if (!bucket) {
498
+ bucket = {
499
+ name: safeName,
500
+ count: 0,
501
+ failed: 0,
502
+ totalMs: 0,
503
+ maxMs: 0,
504
+ lastDurationMs: 0,
505
+ lastAt: null,
506
+ lastError: null,
507
+ lastMeta: {},
508
+ };
509
+ this._metrics.jobs.set(safeName, bucket);
510
+ }
511
+ bucket.count++;
512
+ bucket.totalMs += durationMs;
513
+ bucket.maxMs = Math.max(bucket.maxMs, durationMs);
514
+ bucket.lastDurationMs = durationMs;
515
+ bucket.lastAt = new Date().toISOString();
516
+ bucket.lastMeta = { ...meta };
517
+ if (!ok) {
518
+ bucket.failed++;
519
+ bucket.lastError = err ? (err.message || err.code || err.name || String(err)).slice(0, 160) : 'error';
520
+ this._metrics.errorCount++;
521
+ } else {
522
+ this._metrics.completionCount++;
523
+ bucket.lastError = null;
524
+ }
525
+ this._maybeLogSlow('job', safeName, durationMs, DEFAULT_SLOW_JOB_MS, meta);
526
+ }
527
+
528
+ getMetrics(options = {}) {
529
+ if (!this._metricsEnabled()) {
530
+ return {
531
+ enabled: false,
532
+ uptimeMs: this.uptime(),
533
+ jobsRegistered: this._jobs.size,
534
+ };
535
+ }
536
+ const jobLimit = Math.max(1, Math.min(100, Number(options.jobLimit) || 20));
537
+ const jobs = Array.from(this._metrics.jobs.values())
538
+ .map((job) => ({
539
+ name: job.name,
540
+ count: job.count,
541
+ failed: job.failed,
542
+ avgMs: job.count ? Math.round(job.totalMs / job.count) : 0,
543
+ maxMs: job.maxMs,
544
+ lastDurationMs: job.lastDurationMs,
545
+ lastAt: job.lastAt,
546
+ lastError: job.lastError,
547
+ lastMeta: job.lastMeta || {},
548
+ }))
549
+ .sort((a, b) => b.maxMs - a.maxMs || b.count - a.count)
550
+ .slice(0, jobLimit);
551
+ return {
552
+ enabled: true,
553
+ uptimeMs: this.uptime(),
554
+ startedAt: this._startedAt ? new Date(this._startedAt).toISOString() : null,
555
+ tickCount: this._metrics.tickCount,
556
+ tickErrorCount: this._metrics.tickErrorCount,
557
+ dispatchCount: this._metrics.dispatchCount,
558
+ completionCount: this._metrics.completionCount,
559
+ errorCount: this._metrics.errorCount,
560
+ timeoutCount: this._metrics.timeoutCount,
561
+ poolFullCount: this._metrics.poolFullCount,
562
+ maxTickDurationMs: this._metrics.maxTickDurationMs,
563
+ lastTickAt: this._metrics.lastTickAt,
564
+ lastTickDurationMs: this._metrics.lastTickDurationMs,
565
+ lastTickSummary: this._metrics.lastTickSummary,
566
+ lastError: this._metrics.lastError,
567
+ ineligibleCounts: { ...this._metrics.ineligibleCounts },
568
+ activeJobs: this.getActiveSnapshot(),
569
+ jobs,
570
+ workerPool: this._workerPool && typeof this._workerPool.getStatus === 'function'
571
+ ? this._workerPool.getStatus()
572
+ : null,
573
+ };
574
+ }
575
+
361
576
  async shutdown() {
362
577
  if (this._shuttingDown) return;
363
578
  this._shuttingDown = true;
@@ -397,27 +612,50 @@ class Scheduler extends EventEmitter {
397
612
  }
398
613
 
399
614
  this.emit('shutdown');
615
+ if (this._workerPool && this._closeWorkerPoolOnShutdown && typeof this._workerPool.close === 'function') {
616
+ try { await this._workerPool.close({ timeoutMs: this._shutdownTimeoutMs }); } catch {}
617
+ }
400
618
  }
401
619
 
402
620
  _tick() {
403
621
  if (this._shuttingDown) return;
404
622
 
623
+ const startedAt = Date.now();
624
+ let summary = null;
405
625
  try {
406
- this._tickInner();
626
+ summary = this._tickInner();
627
+ this._recordTickMetric(summary, Date.now() - startedAt, true);
628
+ return summary;
407
629
  } catch (err) {
630
+ this._recordTickMetric(summary, Date.now() - startedAt, false, err);
408
631
  // Log and recover — don't let a single bad tick kill the scheduler loop.
409
632
  // setInterval keeps firing, so the next tick will retry.
410
633
  console.error('[scheduler] Tick error (recovering):', err);
411
634
  this.emit('tickError', err);
635
+ return null;
412
636
  }
413
637
  }
414
638
 
415
639
  _tickInner() {
416
640
  const now = Date.now();
641
+ const summary = {
642
+ jobs: this._jobs.size,
643
+ resetQueued: 0,
644
+ watchdogReset: 0,
645
+ candidates: 0,
646
+ dispatched: 0,
647
+ queued: 0,
648
+ poolFull: 0,
649
+ ineligible: {},
650
+ poolFullByPool: {},
651
+ };
417
652
 
418
653
  // 0. Reset queued→idle so they're reconsidered this tick
419
654
  for (const [, job] of this._jobs) {
420
- if (job.state.status === 'queued') job.state.status = 'idle';
655
+ if (job.state.status === 'queued') {
656
+ job.state.status = 'idle';
657
+ summary.resetQueued++;
658
+ }
421
659
  }
422
660
 
423
661
  // 1. Watchdog: check for stuck jobs
@@ -426,6 +664,7 @@ class Scheduler extends EventEmitter {
426
664
  if (now - job.state.lastRunAt > job.descriptor.timeoutMs) {
427
665
  console.error(`[scheduler] Job "${name}" stuck for ${now - job.state.lastRunAt}ms, force-resetting`);
428
666
  this._forceReset(name);
667
+ summary.watchdogReset++;
429
668
  }
430
669
  }
431
670
  }
@@ -433,22 +672,44 @@ class Scheduler extends EventEmitter {
433
672
  // 2. Collect due candidates (idle, past nextEligibleAt, dependencies met, guard passes)
434
673
  const candidates = [];
435
674
  for (const [, job] of this._jobs) {
436
- if (this._ineligibleReason(job, now)) continue;
675
+ const reason = this._ineligibleReason(job, now);
676
+ if (reason) {
677
+ summary.ineligible[reason] = (summary.ineligible[reason] || 0) + 1;
678
+ continue;
679
+ }
437
680
  candidates.push(job);
438
681
  }
682
+ summary.candidates = candidates.length;
439
683
 
440
684
  // 3. Sort by priority (lower = higher priority)
441
685
  candidates.sort((a, b) => a.descriptor.priority - b.descriptor.priority);
442
686
 
443
- // 4. Dispatch respecting pool limits
687
+ // 4. Dispatch respecting pool limits. Worker-thread requests register
688
+ // asynchronously inside WorkerThreadSlot.request(), so reserve the slots we
689
+ // consume during this tick to avoid oversubscribing the pool before the
690
+ // parent-side pending counters catch up.
691
+ let workerSlotsRemaining = this._workerPoolCapacityAvailable();
444
692
  for (const job of candidates) {
445
693
  const pool = job.descriptor.pool;
446
694
  if (pool && !this._poolHasCapacity(pool)) {
447
695
  job.state.status = 'queued';
696
+ summary.queued++;
697
+ summary.poolFull++;
698
+ summary.poolFullByPool[pool] = (summary.poolFullByPool[pool] || 0) + 1;
699
+ continue;
700
+ }
701
+ if (job.descriptor.worker && this._workerPool && workerSlotsRemaining <= 0) {
702
+ job.state.status = 'queued';
703
+ summary.queued++;
704
+ summary.poolFull++;
705
+ summary.poolFullByPool.worker_threads = (summary.poolFullByPool.worker_threads || 0) + 1;
448
706
  continue;
449
707
  }
708
+ if (job.descriptor.worker && this._workerPool) workerSlotsRemaining--;
450
709
  this._dispatch(job);
710
+ summary.dispatched++;
451
711
  }
712
+ return summary;
452
713
  }
453
714
 
454
715
  _poolHasCapacity(pool) {
@@ -458,6 +719,37 @@ class Scheduler extends EventEmitter {
458
719
  return active.size < limit;
459
720
  }
460
721
 
722
+ _workerPoolHasCapacity() {
723
+ return this._workerPoolCapacityAvailable() > 0;
724
+ }
725
+
726
+ _workerPoolCapacityAvailable() {
727
+ if (!this._workerPool || typeof this._workerPool.getStatus !== 'function') return Number.POSITIVE_INFINITY;
728
+ try {
729
+ const status = this._workerPool.getStatus();
730
+ const size = Number(status?.size) || (Array.isArray(status?.workers) ? status.workers.length : 0);
731
+ if (size <= 0) return Number.POSITIVE_INFINITY;
732
+ const queuedOrRunning = Number.isFinite(Number(status?.queuedOrRunning))
733
+ ? Number(status.queuedOrRunning)
734
+ : (Array.isArray(status?.workers)
735
+ ? status.workers.reduce((sum, worker) => {
736
+ return sum
737
+ + (Number(worker.pendingClientRequests) || 0)
738
+ + (worker.active ? 1 : 0)
739
+ + (Number(worker.pending) || 0);
740
+ }, 0)
741
+ : 0);
742
+ return Math.max(0, size - queuedOrRunning);
743
+ } catch {
744
+ return Number.POSITIVE_INFINITY;
745
+ }
746
+ }
747
+
748
+ _markWorkerPoolQueued(job, skipped) {
749
+ job.state.status = 'queued';
750
+ skipped.push({ job, reason: 'worker-pool-full' });
751
+ }
752
+
461
753
  _dependenciesMet(descriptor) {
462
754
  if (!descriptor.dependsOn || descriptor.dependsOn.length === 0) return true;
463
755
  return descriptor.dependsOn.every(dep => {
@@ -515,6 +807,32 @@ class Scheduler extends EventEmitter {
515
807
  job.state.status = 'running';
516
808
  job.state.lastRunAt = Date.now();
517
809
  const epoch = job._epoch; // capture epoch to detect force-reset
810
+ const startedAt = Date.now();
811
+ const trigger = job._wakeTrigger || 'interval';
812
+ const safeName = this._safeMetricName(name);
813
+ const workerTimeoutMs = job.descriptor.worker
814
+ ? (job.descriptor.workerTimeoutMs ?? job.descriptor.timeoutMs ?? this._defaultWorkerJobTimeoutMs ?? 0)
815
+ : 0;
816
+ const op = runtimeHealth.beginOperation(`scheduler.job.${safeName}`, {
817
+ pool: pool || 'none',
818
+ trigger,
819
+ worker_requested: !!job.descriptor.worker,
820
+ ...(workerTimeoutMs > 0 ? { worker_timeout_ms: workerTimeoutMs } : {}),
821
+ });
822
+ let failed = false;
823
+ let failure = null;
824
+ let workerMeta = {
825
+ worker_requested: !!job.descriptor.worker,
826
+ worker_used: false,
827
+ worker_fallback: false,
828
+ };
829
+ this._recordDispatchMetric();
830
+ job._activeRun = {
831
+ op,
832
+ startedAt,
833
+ trigger,
834
+ workerMeta,
835
+ };
518
836
 
519
837
  if (pool) this._poolActive.get(pool)?.add(name);
520
838
 
@@ -531,10 +849,7 @@ class Scheduler extends EventEmitter {
531
849
  if (overrides && overrides[name]) modelOverride = overrides[name];
532
850
  }
533
851
  } catch {}
534
- const result = await run({
535
- trigger: job._wakeTrigger || 'interval',
536
- modelOverride: modelOverride || null,
537
- });
852
+ const result = await this._runJob(job, { trigger, modelOverride: modelOverride || null }, workerMeta);
538
853
  // If force-reset happened while we were running, this promise is stale — bail
539
854
  if (job._epoch !== epoch) return;
540
855
  job._wakeTrigger = null;
@@ -570,6 +885,8 @@ class Scheduler extends EventEmitter {
570
885
  this._checkCascades(name, result);
571
886
  } catch (err) {
572
887
  if (job._epoch !== epoch) return; // stale after force-reset
888
+ failed = true;
889
+ failure = err;
573
890
  job.state.lastError = err.message;
574
891
  job.state.errorCount++;
575
892
 
@@ -580,11 +897,23 @@ class Scheduler extends EventEmitter {
580
897
  if (onError) onError(err);
581
898
  this.emit('jobError', name, err);
582
899
  } finally {
583
- if (job._epoch !== epoch) return; // stale — _forceReset already cleaned up
900
+ const stale = job._epoch !== epoch;
901
+ const finishedAt = Date.now();
902
+ const durationMs = finishedAt - startedAt;
903
+ const metricMeta = { pool: pool || 'none', trigger, stale, ...workerMeta };
904
+ const phaseMeta = this._workerPhaseMetric(job.state.lastResult);
905
+ Object.assign(metricMeta, phaseMeta);
906
+ const metricError = failed ? failure : (stale ? new Error('stale_epoch') : null);
907
+ if (job._activeRun?.op === op) {
908
+ op.end({ ok: !failed && !stale, error: metricError, meta: metricMeta });
909
+ job._activeRun = null;
910
+ this._recordJobMetric(name, durationMs, !failed && !stale, metricError, metricMeta);
911
+ }
912
+ if (stale) return; // stale — _forceReset already cleaned up
584
913
  // Job may have been unregistered by self-stop
585
914
  if (this._jobs.has(name)) {
586
915
  job.state.status = 'idle';
587
- job.state.lastFinishedAt = Date.now();
916
+ job.state.lastFinishedAt = finishedAt;
588
917
  job.state.lastDurationMs = job.state.lastFinishedAt - job.state.lastRunAt;
589
918
  // Floor next-eligible at lastFinishedAt + minRefireGapMs so a
590
919
  // misconfigured interval (0/NaN/negative) cannot spin-loop.
@@ -601,6 +930,58 @@ class Scheduler extends EventEmitter {
601
930
  })();
602
931
  }
603
932
 
933
+ async _runJob(job, context, workerMeta = {}) {
934
+ const desc = job.descriptor;
935
+ if (!desc.worker || !this._workerPool) {
936
+ return await desc.run(context);
937
+ }
938
+ workerMeta.worker_requested = true;
939
+ const payload = {
940
+ jobName: desc.workerJobName || desc.name,
941
+ trigger: context.trigger,
942
+ modelOverride: context.modelOverride || null,
943
+ };
944
+ // Scheduler watchdogs own job timeouts. The worker request uses the same
945
+ // boundary so a timed-out job retires its occupied worker slot instead of
946
+ // leaving future jobs queued behind an orphaned operation.
947
+ const timeoutMs = desc.workerTimeoutMs ?? desc.timeoutMs ?? this._defaultWorkerJobTimeoutMs ?? 0;
948
+ workerMeta.worker_timeout_ms = timeoutMs || 0;
949
+ try {
950
+ const result = await this._workerPool.request('scheduler.runJob', payload, {
951
+ timeoutMs,
952
+ heavy: true,
953
+ terminateOnTimeout: timeoutMs > 0,
954
+ });
955
+ workerMeta.worker_used = true;
956
+ return result;
957
+ } catch (err) {
958
+ if (err?.code === 'WALL_E_WORKER_REQUEST_TIMEOUT') {
959
+ workerMeta.worker_timeout = true;
960
+ throw err;
961
+ }
962
+ const fallbackLocal = desc.workerFallbackLocal !== false;
963
+ if (!fallbackLocal) throw err;
964
+ workerMeta.worker_fallback = true;
965
+ workerMeta.worker_error = err.code || err.message || 'worker_error';
966
+ console.warn(`[scheduler] Worker job "${desc.name}" failed; falling back to local run: ${err.message}`);
967
+ return await desc.run(context);
968
+ }
969
+ }
970
+
971
+ _workerPhaseMetric(result) {
972
+ const phases = Array.isArray(result?._worker_phases) ? result._worker_phases : [];
973
+ if (phases.length === 0) return {};
974
+ let slowest = phases[0];
975
+ for (const phase of phases) {
976
+ if ((phase.duration_ms || 0) > (slowest.duration_ms || 0)) slowest = phase;
977
+ }
978
+ return {
979
+ worker_phase_count: phases.length,
980
+ worker_slowest_phase: String(slowest.name || 'unknown').slice(0, 80),
981
+ worker_slowest_phase_ms: Number(slowest.duration_ms) || 0,
982
+ };
983
+ }
984
+
604
985
  _checkCascades(completedJobName, result) {
605
986
  if (!result || typeof result !== 'object') return;
606
987
 
@@ -617,6 +998,22 @@ class Scheduler extends EventEmitter {
617
998
  _forceReset(name) {
618
999
  const job = this._jobs.get(name);
619
1000
  if (!job) return;
1001
+ if (this._metricsEnabled()) this._metrics.timeoutCount++;
1002
+ const timeoutErr = new Error('watchdog timeout');
1003
+ timeoutErr.code = 'WALL_E_SCHEDULER_WATCHDOG_TIMEOUT';
1004
+ const run = job._activeRun || {};
1005
+ const durationMs = run.startedAt ? Date.now() - run.startedAt : 0;
1006
+ const metricMeta = {
1007
+ pool: job.descriptor.pool || 'none',
1008
+ trigger: run.trigger || job._wakeTrigger || 'unknown',
1009
+ watchdog_reset: true,
1010
+ ...(run.workerMeta || {}),
1011
+ };
1012
+ if (run.op && typeof run.op.end === 'function') {
1013
+ run.op.end({ ok: false, error: timeoutErr, meta: metricMeta });
1014
+ }
1015
+ this._recordJobMetric(name, durationMs, false, timeoutErr, metricMeta);
1016
+ job._activeRun = null;
620
1017
  job._epoch++; // invalidate the orphaned promise so its finally block no-ops
621
1018
  job.state.status = 'idle';
622
1019
  job.state.errorCount++;
@@ -633,7 +1030,7 @@ class Scheduler extends EventEmitter {
633
1030
  if (job.descriptor.pool) {
634
1031
  this._poolActive.get(job.descriptor.pool)?.delete(name);
635
1032
  }
636
- this._recordJobError(name, new Error('watchdog timeout'), job.state);
1033
+ this._recordJobError(name, timeoutErr, job.state);
637
1034
  this._persistState(name, job);
638
1035
  this.emit('jobTimeout', name);
639
1036
  }
@@ -654,11 +1051,17 @@ class Scheduler extends EventEmitter {
654
1051
  * Returns a summary so the caller can log + telemeter.
655
1052
  */
656
1053
  async runMissedJobs(opts = {}) {
657
- if (!this._persistJobState || !this._persistBrain) {
658
- return { ran: 0, deferred: 0, reason: 'persistence-disabled' };
659
- }
660
1054
  const max = opts.max ?? this._maxMissedJobsPerRestart;
661
1055
  const staggerMs = opts.staggerMs ?? this._missedJobStaggerMs;
1056
+ const op = runtimeHealth.beginOperation('scheduler.runMissedJobs', {
1057
+ max,
1058
+ staggerMs,
1059
+ });
1060
+ if (!this._persistJobState || !this._persistBrain) {
1061
+ const summary = { ran: 0, deferred: 0, reason: 'persistence-disabled' };
1062
+ op.end({ ok: true, meta: summary });
1063
+ return summary;
1064
+ }
662
1065
  const skipNames = new Set(opts.skipNames || []);
663
1066
 
664
1067
  let states;
@@ -666,7 +1069,9 @@ class Scheduler extends EventEmitter {
666
1069
  states = this._persistBrain.listSchedulerJobStates();
667
1070
  } catch (e) {
668
1071
  console.warn(`[scheduler] runMissedJobs read failed: ${e.message}`);
669
- return { ran: 0, deferred: 0, reason: 'read-error' };
1072
+ const summary = { ran: 0, deferred: 0, reason: 'read-error' };
1073
+ op.end({ ok: false, error: e, meta: summary });
1074
+ return summary;
670
1075
  }
671
1076
 
672
1077
  const now = Date.now();
@@ -689,6 +1094,10 @@ class Scheduler extends EventEmitter {
689
1094
  skipped.push({ job, reason: 'pool-full' });
690
1095
  continue;
691
1096
  }
1097
+ if (job.descriptor.worker && this._workerPool && !this._workerPoolHasCapacity()) {
1098
+ this._markWorkerPoolQueued(job, skipped);
1099
+ continue;
1100
+ }
692
1101
  candidates.push({ stored, job });
693
1102
  }
694
1103
  // Sort by oldest-due first (most lag = highest priority)
@@ -719,6 +1128,8 @@ class Scheduler extends EventEmitter {
719
1128
  }
720
1129
 
721
1130
  let ran = 0;
1131
+ let workMs = 0;
1132
+ let staggerWaitMs = 0;
722
1133
  for (let i = 0; i < immediate.length; i++) {
723
1134
  const { job } = immediate[i];
724
1135
  // Mark eligible RIGHT NOW so _dispatch picks it up
@@ -728,18 +1139,22 @@ class Scheduler extends EventEmitter {
728
1139
  // hooks all apply uniformly. Wait for the run to finish before
729
1140
  // staggering the next one.
730
1141
  try {
1142
+ const workStartedAt = Date.now();
731
1143
  this._dispatch(job);
732
1144
  if (job.runPromise) await job.runPromise;
1145
+ workMs += Date.now() - workStartedAt;
733
1146
  ran++;
734
1147
  } catch (e) {
735
1148
  console.warn(`[scheduler] Missed-job replay for "${job.descriptor.name}" threw: ${e.message}`);
736
1149
  }
737
1150
  if (i < immediate.length - 1 && staggerMs > 0) {
1151
+ const waitStartedAt = Date.now();
738
1152
  await new Promise((r) => setTimeout(r, staggerMs));
1153
+ staggerWaitMs += Date.now() - waitStartedAt;
739
1154
  }
740
1155
  }
741
1156
 
742
- return {
1157
+ const summary = {
743
1158
  ran,
744
1159
  deferred: deferred.length + skipped.length,
745
1160
  jobs_ran: immediate.map((c) => c.stored.job_name),
@@ -748,7 +1163,21 @@ class Scheduler extends EventEmitter {
748
1163
  ...skipped.map((c) => c.job.descriptor.name),
749
1164
  ],
750
1165
  jobs_skipped: skipped.map((c) => ({ name: c.job.descriptor.name, reason: c.reason })),
1166
+ work_ms: workMs,
1167
+ stagger_wait_ms: staggerWaitMs,
751
1168
  };
1169
+ runtimeHealth.recordOperation('scheduler.runMissedJobs.work', workMs, {
1170
+ ok: true,
1171
+ meta: { ran, deferred: summary.deferred },
1172
+ });
1173
+ if (staggerWaitMs > 0) {
1174
+ runtimeHealth.recordOperation('scheduler.runMissedJobs.staggerWait', staggerWaitMs, {
1175
+ ok: true,
1176
+ meta: { ran },
1177
+ });
1178
+ }
1179
+ op.end({ ok: true, meta: { ran: summary.ran, deferred: summary.deferred, work_ms: workMs, stagger_wait_ms: staggerWaitMs } });
1180
+ return summary;
752
1181
  }
753
1182
  }
754
1183