create-walle 0.9.28 → 0.9.30

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 (140) hide show
  1. package/README.md +2 -2
  2. package/bin/create-walle.js +166 -6
  3. package/package.json +1 -1
  4. package/template/bin/ctm-launch.sh +70 -18
  5. package/template/bin/dev.sh +18 -0
  6. package/template/bin/ensure-stable-node.js +11 -0
  7. package/template/bin/node-bin.sh +9 -0
  8. package/template/claude-task-manager/api-prompts.js +214 -23
  9. package/template/claude-task-manager/db.js +884 -50
  10. package/template/claude-task-manager/docs/backfill-incremental-no-main-fallback.md +48 -0
  11. package/template/claude-task-manager/docs/conversation-import-freshness.md +21 -0
  12. package/template/claude-task-manager/docs/conversation-log-redesign.html +587 -0
  13. package/template/claude-task-manager/docs/session-title-authority.md +8 -3
  14. package/template/claude-task-manager/lib/auth-rules.js +13 -0
  15. package/template/claude-task-manager/lib/claude-desktop-sessions.js +63 -0
  16. package/template/claude-task-manager/lib/codex-config-guard.js +124 -0
  17. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +93 -0
  18. package/template/claude-task-manager/lib/coding-agent-models.js +5 -4
  19. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +114 -0
  20. package/template/claude-task-manager/lib/db-owner-task-queue.js +67 -0
  21. package/template/claude-task-manager/lib/db-owner-worker-client.js +5 -1
  22. package/template/claude-task-manager/lib/desktop-fork.js +81 -0
  23. package/template/claude-task-manager/lib/headless-term-service.js +251 -4
  24. package/template/claude-task-manager/lib/message-identity.js +115 -0
  25. package/template/claude-task-manager/lib/mirror-feed-guards.js +25 -0
  26. package/template/claude-task-manager/lib/mirror-feed-sanitize.js +45 -0
  27. package/template/claude-task-manager/lib/path-suggest.js +77 -0
  28. package/template/claude-task-manager/lib/prompt-index-inputs.js +136 -0
  29. package/template/claude-task-manager/lib/real-node.js +36 -4
  30. package/template/claude-task-manager/lib/restore-auto-resume-policy.js +67 -0
  31. package/template/claude-task-manager/lib/restore-resume-batch.js +20 -0
  32. package/template/claude-task-manager/lib/restore-terminal-dims.js +109 -0
  33. package/template/claude-task-manager/lib/resume-cwd.js +124 -3
  34. package/template/claude-task-manager/lib/runtime-approval-recorder.js +152 -0
  35. package/template/claude-task-manager/lib/runtime-context-truth.js +236 -0
  36. package/template/claude-task-manager/lib/runtime-contract.js +195 -0
  37. package/template/claude-task-manager/lib/runtime-history-builder.js +205 -0
  38. package/template/claude-task-manager/lib/runtime-hook-bus.js +98 -0
  39. package/template/claude-task-manager/lib/runtime-input-queue.js +114 -0
  40. package/template/claude-task-manager/lib/runtime-input-recorder.js +156 -0
  41. package/template/claude-task-manager/lib/runtime-lineage.js +189 -0
  42. package/template/claude-task-manager/lib/runtime-registry.js +263 -0
  43. package/template/claude-task-manager/lib/runtime-session-history.js +41 -0
  44. package/template/claude-task-manager/lib/scrollback-snapshot-policy.js +37 -0
  45. package/template/claude-task-manager/lib/server-phase-conditions.js +103 -0
  46. package/template/claude-task-manager/lib/session-content-backfill.js +55 -8
  47. package/template/claude-task-manager/lib/session-db-read-contract.js +67 -0
  48. package/template/claude-task-manager/lib/session-history.js +93 -5
  49. package/template/claude-task-manager/lib/session-host-manager.js +154 -2
  50. package/template/claude-task-manager/lib/session-messages-defer.js +50 -0
  51. package/template/claude-task-manager/lib/session-messages-page.js +13 -0
  52. package/template/claude-task-manager/lib/session-messages-projection.js +48 -29
  53. package/template/claude-task-manager/lib/session-stream.js +80 -17
  54. package/template/claude-task-manager/lib/session-title-signals.js +54 -0
  55. package/template/claude-task-manager/lib/session-token-usage.js +13 -0
  56. package/template/claude-task-manager/lib/state-sync/cell-diff.js +41 -0
  57. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +214 -0
  58. package/template/claude-task-manager/lib/state-sync/frame-rate.js +75 -0
  59. package/template/claude-task-manager/lib/state-sync/row-serializer.js +166 -0
  60. package/template/claude-task-manager/lib/terminal-fingerprint.js +19 -3
  61. package/template/claude-task-manager/lib/transcript-ingest-chunker.js +41 -0
  62. package/template/claude-task-manager/lib/transcript-store.js +99 -7
  63. package/template/claude-task-manager/lib/wal-checkpoint-policy.js +40 -0
  64. package/template/claude-task-manager/lib/walle-session-model-catalog.js +100 -9
  65. package/template/claude-task-manager/lib/worktree-output-binding.js +93 -0
  66. package/template/claude-task-manager/lib/write-coalescer.js +83 -0
  67. package/template/claude-task-manager/public/css/walle-session.css +4 -0
  68. package/template/claude-task-manager/public/css/walle.css +0 -66
  69. package/template/claude-task-manager/public/index.html +1707 -266
  70. package/template/claude-task-manager/public/js/feedback.js +8 -1
  71. package/template/claude-task-manager/public/js/message-renderer.js +72 -2
  72. package/template/claude-task-manager/public/js/session-phase.js +4 -0
  73. package/template/claude-task-manager/public/js/session-status-precedence.js +7 -173
  74. package/template/claude-task-manager/public/js/setup.js +46 -3
  75. package/template/claude-task-manager/public/js/state-sync-client.js +257 -0
  76. package/template/claude-task-manager/public/js/state-sync-predictor.js +41 -0
  77. package/template/claude-task-manager/public/js/stream-view.js +113 -9
  78. package/template/claude-task-manager/public/js/terminal-reconciler.js +24 -4
  79. package/template/claude-task-manager/public/js/walle-session.js +239 -19
  80. package/template/claude-task-manager/public/js/walle.js +32 -119
  81. package/template/claude-task-manager/queue-engine.js +140 -0
  82. package/template/claude-task-manager/server.js +2802 -416
  83. package/template/claude-task-manager/session-integrity.js +16 -1
  84. package/template/claude-task-manager/workers/db-owner-worker.js +23 -6
  85. package/template/claude-task-manager/workers/read-pool-worker.js +55 -1
  86. package/template/claude-task-manager/workers/session-host-pool-process.js +193 -0
  87. package/template/claude-task-manager/workers/session-host-process.js +47 -11
  88. package/template/claude-task-manager/workers/state-detectors/codex.js +33 -0
  89. package/template/package.json +1 -1
  90. package/template/wall-e/agent.js +191 -31
  91. package/template/wall-e/api-walle.js +97 -52
  92. package/template/wall-e/auth/flow-manager.js +78 -1
  93. package/template/wall-e/auth/provider-flows.js +56 -2
  94. package/template/wall-e/bin/walle-mcp-stdio.js +138 -5
  95. package/template/wall-e/brain.js +175 -13
  96. package/template/wall-e/chat.js +46 -1
  97. package/template/wall-e/embeddings.js +70 -0
  98. package/template/wall-e/events/event-bus.js +11 -1
  99. package/template/wall-e/http/auth.js +3 -1
  100. package/template/wall-e/http/model-admin.js +22 -0
  101. package/template/wall-e/lib/brain-owner-worker-client.js +36 -4
  102. package/template/wall-e/lib/diagnostics-flags.js +9 -0
  103. package/template/wall-e/lib/event-loop-monitor.js +84 -5
  104. package/template/wall-e/lib/mcp-scan-lifecycle.js +247 -0
  105. package/template/wall-e/lib/parent-brain-owner-client.js +109 -0
  106. package/template/wall-e/lib/runtime-process-inventory.js +114 -0
  107. package/template/wall-e/lib/runtime-worker-pool.js +214 -23
  108. package/template/wall-e/lib/scheduler-worker-jobs.js +49 -4
  109. package/template/wall-e/lib/scheduler.js +320 -35
  110. package/template/wall-e/lib/slack-identity.js +120 -0
  111. package/template/wall-e/lib/slack-permalink.js +107 -0
  112. package/template/wall-e/lib/slack-web.js +174 -0
  113. package/template/wall-e/lib/worker-thread-pool.js +55 -4
  114. package/template/wall-e/llm/claude-cli.js +21 -3
  115. package/template/wall-e/llm/cli-binary.js +90 -0
  116. package/template/wall-e/llm/codex-cli.js +113 -49
  117. package/template/wall-e/llm/default-fallback.js +10 -4
  118. package/template/wall-e/llm/mlx.js +46 -8
  119. package/template/wall-e/llm/model-catalog.js +129 -17
  120. package/template/wall-e/llm/provider-detector.js +112 -22
  121. package/template/wall-e/loops/backfill.js +32 -16
  122. package/template/wall-e/loops/ingest.js +50 -16
  123. package/template/wall-e/loops/tasks.js +521 -25
  124. package/template/wall-e/mcp-server.js +215 -6
  125. package/template/wall-e/memory/ctm-session-context.js +93 -0
  126. package/template/wall-e/skills/_bundled/google-calendar/run.js +15 -23
  127. package/template/wall-e/skills/_bundled/gws-workspace/gws-router +237 -0
  128. package/template/wall-e/skills/_bundled/gws-workspace/setup.js +112 -1
  129. package/template/wall-e/skills/_bundled/mcp-scan/run.js +265 -41
  130. package/template/wall-e/skills/_bundled/slack-mentions/run.js +434 -93
  131. package/template/wall-e/skills/internal-skill-registry.js +27 -5
  132. package/template/wall-e/skills/mcp-client.js +18 -3
  133. package/template/wall-e/skills/script-skill-runner.js +53 -5
  134. package/template/wall-e/skills/skill-planner.js +5 -26
  135. package/template/wall-e/training/real-trajectory-miner.js +24 -114
  136. package/template/wall-e/utils/dedup.js +165 -66
  137. package/template/wall-e/weather-runtime.js +12 -4
  138. package/template/wall-e/workers/brain-owner-worker.js +68 -0
  139. package/template/wall-e/workers/runtime-worker.js +4 -0
  140. package/template/website/index.html +3 -0
@@ -7,10 +7,22 @@ const runtimeHealth = require('../lib/runtime-health');
7
7
  const { retryOnWriteLockBusy } = require('../shared/sqlite-write-lock');
8
8
 
9
9
  const WALL_E_DIR = path.join(__dirname, '..');
10
+ const SLACK_AUTO_RESUME_KV = 'tasks:slack_auto_resume_next_at';
11
+ const DEFAULT_SLACK_AUTO_RESUME_COOLDOWN_MS = 15 * 60 * 1000;
12
+ const DEFAULT_RECURRING_SCHEDULE_REPAIR_INTERVAL_MS = 5 * 60 * 1000;
13
+ const DEFAULT_MAX_TASKS_PER_TICK = 1;
14
+ const DEFAULT_MAX_TASK_TICK_MS = 5000;
15
+ const DEFAULT_TASK_CONTINUE_DELAY_MS = 2000;
16
+ const DEFAULT_TASK_MAINTENANCE_MIN_REMAINING_MS = 750;
17
+ const DEFAULT_TASK_EXECUTION_TIMEOUT_MS = 120000;
18
+ const DEFAULT_SCRIPT_TASK_EXECUTION_TIMEOUT_MS = 30000;
19
+ const DEFAULT_TASK_KILL_GRACE_MS = 1500;
20
+ const CALENDAR_ACCESS_QUESTION = 'Calendar access denied — WALL-E cannot sync Calendar until you grant macOS Calendar permission.';
10
21
 
11
22
  // ── Live log buffer — in-memory, keyed by task ID ──
12
23
  const taskLogs = new Map(); // task_id → { lines: string[], startedAt, status }
13
24
  const taskProcesses = new Map(); // task_id → child process (for stopping)
25
+ let lastRecurringScheduleRepairMs = 0;
14
26
 
15
27
  function stopTask(taskId) {
16
28
  const child = taskProcesses.get(taskId);
@@ -77,6 +89,137 @@ async function safeUpdateTask(taskId, updates, options = {}) {
77
89
  }
78
90
  }
79
91
 
92
+ function positiveIntEnv(name, fallback) {
93
+ const n = Number.parseInt(process.env[name] || '', 10);
94
+ return Number.isFinite(n) && n > 0 ? n : fallback;
95
+ }
96
+
97
+ function positiveIntValue(value, fallback, max = Number.POSITIVE_INFINITY) {
98
+ const n = Number.parseInt(String(value || ''), 10);
99
+ if (!Number.isFinite(n) || n <= 0) return fallback;
100
+ return Math.min(max, Math.max(1, n));
101
+ }
102
+
103
+ function nonNegativeIntValue(value, fallback, max = Number.POSITIVE_INFINITY) {
104
+ const n = Number.parseInt(String(value ?? ''), 10);
105
+ if (!Number.isFinite(n) || n < 0) return fallback;
106
+ return Math.min(max, Math.max(0, n));
107
+ }
108
+
109
+ function createTaskTimeoutError(task, timeoutMs) {
110
+ const title = task?.title ? ` "${String(task.title).slice(0, 120)}"` : '';
111
+ const err = new Error(`Task${title} timed out after ${timeoutMs}ms`);
112
+ err.code = 'WALL_E_TASK_TIMEOUT';
113
+ err.timeoutMs = timeoutMs;
114
+ err.taskId = task?.id || null;
115
+ err.taskTitle = task?.title || null;
116
+ return err;
117
+ }
118
+
119
+ function slackAutoResumeCooldownMs() {
120
+ return positiveIntEnv('WALL_E_SLACK_AUTO_RESUME_COOLDOWN_MS', DEFAULT_SLACK_AUTO_RESUME_COOLDOWN_MS);
121
+ }
122
+
123
+ function recurringScheduleRepairIntervalMs() {
124
+ return positiveIntEnv('WALL_E_RECURRING_TASK_REPAIR_INTERVAL_MS', DEFAULT_RECURRING_SCHEDULE_REPAIR_INTERVAL_MS);
125
+ }
126
+
127
+ function getSlackAutoResumeNextProbeMs() {
128
+ try {
129
+ const raw = brain.getKv(SLACK_AUTO_RESUME_KV);
130
+ const next = Date.parse(raw || '');
131
+ return Number.isFinite(next) ? next : 0;
132
+ } catch {
133
+ return 0;
134
+ }
135
+ }
136
+
137
+ function shouldRunSlackAutoResumeProbe(nowMs = Date.now()) {
138
+ const nextProbeAt = getSlackAutoResumeNextProbeMs();
139
+ return !nextProbeAt || nowMs >= nextProbeAt;
140
+ }
141
+
142
+ function setSlackAutoResumeCooldown(nowMs = Date.now()) {
143
+ const next = new Date(nowMs + slackAutoResumeCooldownMs()).toISOString();
144
+ try {
145
+ brain.setKv(SLACK_AUTO_RESUME_KV, next);
146
+ } catch (err) {
147
+ console.warn(`[tasks] Failed to persist Slack auto-resume cooldown: ${err.message}`);
148
+ }
149
+ return next;
150
+ }
151
+
152
+ function clearSlackAutoResumeCooldown() {
153
+ try {
154
+ brain.setKv(SLACK_AUTO_RESUME_KV, null);
155
+ } catch (err) {
156
+ console.warn(`[tasks] Failed to clear Slack auto-resume cooldown: ${err.message}`);
157
+ }
158
+ }
159
+
160
+ function isCalendarAccessDeniedError(task = {}, errMsg = '') {
161
+ const skill = String(task.skill || '').toLowerCase();
162
+ const title = String(task.title || '').toLowerCase();
163
+ const text = String(errMsg || '');
164
+ const isCalendarTask = skill.includes('calendar') || title.includes('calendar');
165
+ return isCalendarTask && /access_denied|calendar access(?: (?:was|still))? denied/i.test(text);
166
+ }
167
+
168
+ function classifyNonRetryableTaskBlocker(task = {}) {
169
+ if (task.type === 'recurring' && isCalendarAccessDeniedError(task, task.error || '')) {
170
+ return {
171
+ type: 'calendar_permission',
172
+ question: CALENDAR_ACCESS_QUESTION,
173
+ error: 'Calendar access denied. Grant macOS Calendar access to Wall-E, then resume this task.',
174
+ instructions: 'Grant Calendar access to the Wall-E/terminal process in System Settings > Privacy & Security > Calendars, then resume the Calendar task.',
175
+ };
176
+ }
177
+ return null;
178
+ }
179
+
180
+ async function pauseBlockedRecurringTask(task, blocker, reason = 'non-retryable-task-blocker') {
181
+ if (!task || !blocker) return false;
182
+ const pausedAt = new Date().toISOString();
183
+ ensureActionQuestionOnce({
184
+ question: blocker.question,
185
+ match: q => String(q.question || '').includes('Calendar access denied'),
186
+ context: {
187
+ action: blocker.type,
188
+ failed_task: task.title,
189
+ error: task.error || blocker.error,
190
+ instructions: blocker.instructions,
191
+ },
192
+ priority: 'high',
193
+ });
194
+ const ok = await safeUpdateTask(task.id, {
195
+ status: 'paused',
196
+ error: blocker.error,
197
+ completed_at: pausedAt,
198
+ last_run_at: task.last_run_at || pausedAt,
199
+ next_run_at: null,
200
+ }, { reason });
201
+ if (ok) console.log(`[tasks] Auto-paused: ${task.title} (${blocker.type})`);
202
+ return ok;
203
+ }
204
+
205
+ function ensureActionQuestionOnce({ question, context, priority = 'high', match }) {
206
+ try {
207
+ const existing = brain
208
+ .listQuestions({ status: 'pending', question_type: 'action_required' })
209
+ .find(q => typeof match === 'function' ? match(q) : q.question === question);
210
+ if (existing) return existing;
211
+ return brain.insertQuestion({
212
+ question_type: 'action_required',
213
+ question,
214
+ context: context ? JSON.stringify(context) : null,
215
+ priority,
216
+ });
217
+ } catch (err) {
218
+ console.error('[tasks] Failed to create action question:', err.message);
219
+ return null;
220
+ }
221
+ }
222
+
80
223
  /**
81
224
  * On daemon startup, recover tasks that were interrupted (stuck in 'running').
82
225
  * Resets them to 'pending' so they get re-picked up.
@@ -98,6 +241,64 @@ function recoverInterruptedTasks() {
98
241
  return stuck.length;
99
242
  }
100
243
 
244
+ /**
245
+ * Recurring tasks must have an explicit next_run_at. A null next_run_at on a
246
+ * recurring task used to make it eligible every scheduler tick, so a blocked
247
+ * external sync (Calendar/Gmail/Slack) could create a permanent 10s task loop.
248
+ * Repair the malformed rows on a low-frequency cadence; unparseable schedules
249
+ * are paused so the UI can surface them instead of spinning silently.
250
+ */
251
+ async function repairRecurringTaskSchedules(nowMs = Date.now()) {
252
+ const intervalMs = recurringScheduleRepairIntervalMs();
253
+ if (intervalMs > 0 && nowMs - lastRecurringScheduleRepairMs < intervalMs) {
254
+ return { repaired: 0, paused: 0, skipped: true };
255
+ }
256
+ lastRecurringScheduleRepairMs = nowMs;
257
+
258
+ let rows = [];
259
+ try {
260
+ rows = brain.getDb().prepare(`
261
+ SELECT id, title, schedule
262
+ FROM tasks
263
+ WHERE status = 'pending'
264
+ AND type = 'recurring'
265
+ AND (next_run_at IS NULL OR trim(next_run_at) = '')
266
+ ORDER BY updated_at ASC
267
+ LIMIT 25
268
+ `).all();
269
+ } catch (err) {
270
+ console.warn(`[tasks] Recurring schedule repair skipped: ${err.message}`);
271
+ return { repaired: 0, paused: 0, error: err.message };
272
+ }
273
+
274
+ let repaired = 0;
275
+ let paused = 0;
276
+ for (const row of rows) {
277
+ const nextRunAt = computeNextDue(row.schedule);
278
+ if (nextRunAt) {
279
+ const ok = await safeUpdateTask(row.id, {
280
+ next_run_at: nextRunAt,
281
+ error: null,
282
+ }, { bestEffort: true, reason: 'recurring-schedule-repair' });
283
+ if (ok) repaired++;
284
+ continue;
285
+ }
286
+
287
+ const ok = await safeUpdateTask(row.id, {
288
+ status: 'paused',
289
+ error: 'Recurring task has no next run time and its schedule could not be parsed. Edit the schedule or run it manually.',
290
+ completed_at: new Date(nowMs).toISOString(),
291
+ next_run_at: null,
292
+ }, { bestEffort: true, reason: 'recurring-schedule-invalid' });
293
+ if (ok) paused++;
294
+ }
295
+
296
+ if (repaired > 0 || paused > 0) {
297
+ console.log(`[tasks] Repaired recurring task schedule state: ${repaired} rescheduled, ${paused} paused`);
298
+ }
299
+ return { repaired, paused, skipped: false };
300
+ }
301
+
101
302
  /**
102
303
  * Try to auto-resume Slack tasks that were paused due to token expiry.
103
304
  * Runs at the start of each task loop cycle.
@@ -110,10 +311,22 @@ async function tryResumeSlackTasks() {
110
311
  (t.error && t.error.includes('Slack token expired'))
111
312
  ));
112
313
  if (pausedSlack.length === 0) return;
314
+ if (!shouldRunSlackAutoResumeProbe()) return;
113
315
 
114
- // Try refreshing the Slack token
115
- const slackMcp = require('../tools/slack-mcp');
116
- const valid = await slackMcp.ensureValidToken();
316
+ // Token refresh can hit network/auth paths. Cool it down persistently so
317
+ // paused Slack tasks do not make every scheduler tick expensive.
318
+ setSlackAutoResumeCooldown();
319
+ const op = runtimeHealth.beginOperation('tasks.slackAutoResumeProbe', { paused: pausedSlack.length });
320
+ let valid = false;
321
+ try {
322
+ const slackMcp = require('../tools/slack-mcp');
323
+ valid = await slackMcp.ensureValidToken();
324
+ op.end({ meta: { valid } });
325
+ } catch (err) {
326
+ op.end({ ok: false, error: err, meta: { valid: false } });
327
+ console.warn(`[tasks] Slack auto-resume probe failed; retrying after cooldown: ${err.message}`);
328
+ return;
329
+ }
117
330
  if (!valid) return;
118
331
 
119
332
  // Token is valid — un-pause all Slack tasks
@@ -125,23 +338,189 @@ async function tryResumeSlackTasks() {
125
338
  }, { reason: 'slack-auto-resume' });
126
339
  console.log(`[tasks] Auto-resumed: ${t.title} (Slack token refreshed)`);
127
340
  }
128
- } catch {}
341
+ clearSlackAutoResumeCooldown();
342
+ } catch (err) {
343
+ console.warn(`[tasks] Slack auto-resume skipped: ${err.message}`);
344
+ }
129
345
  }
130
346
 
131
347
  /**
132
348
  * Task executor loop — picks up due tasks and runs them.
133
349
  */
134
- async function runDueTasks() {
135
- // Try to auto-resume Slack tasks paused due to token expiry
136
- await tryResumeSlackTasks();
350
+ async function runDueTasks(options = {}) {
351
+ const maxTasks = positiveIntValue(
352
+ options.maxTasks ?? process.env.WALL_E_TASKS_MAX_PER_TICK ?? process.env.WALLE_TASKS_MAX_PER_TICK,
353
+ DEFAULT_MAX_TASKS_PER_TICK,
354
+ 25,
355
+ );
356
+ const maxTickMs = positiveIntValue(
357
+ options.maxTickMs ?? process.env.WALL_E_TASKS_MAX_TICK_MS ?? process.env.WALLE_TASKS_MAX_TICK_MS,
358
+ DEFAULT_MAX_TASK_TICK_MS,
359
+ 120000,
360
+ );
361
+ const continueDelayMs = positiveIntValue(
362
+ options.continueDelayMs ?? process.env.WALL_E_TASKS_CONTINUE_DELAY_MS ?? process.env.WALLE_TASKS_CONTINUE_DELAY_MS,
363
+ DEFAULT_TASK_CONTINUE_DELAY_MS,
364
+ 60000,
365
+ );
366
+ const maintenanceMinRemainingMs = nonNegativeIntValue(
367
+ options.maintenanceMinRemainingMs
368
+ ?? process.env.WALL_E_TASKS_MAINTENANCE_MIN_REMAINING_MS
369
+ ?? process.env.WALLE_TASKS_MAINTENANCE_MIN_REMAINING_MS,
370
+ DEFAULT_TASK_MAINTENANCE_MIN_REMAINING_MS,
371
+ 30000,
372
+ );
373
+ const taskExecutionTimeoutMs = nonNegativeIntValue(
374
+ options.taskExecutionTimeoutMs
375
+ ?? process.env.WALL_E_TASK_EXECUTION_TIMEOUT_MS
376
+ ?? process.env.WALLE_TASK_EXECUTION_TIMEOUT_MS,
377
+ DEFAULT_TASK_EXECUTION_TIMEOUT_MS,
378
+ 240000,
379
+ );
380
+ const scriptTimeoutFallbackMs = taskExecutionTimeoutMs === 0
381
+ ? 0
382
+ : Math.min(taskExecutionTimeoutMs, DEFAULT_SCRIPT_TASK_EXECUTION_TIMEOUT_MS);
383
+ const scriptTaskExecutionTimeoutMs = nonNegativeIntValue(
384
+ options.scriptTaskExecutionTimeoutMs
385
+ ?? process.env.WALL_E_SCRIPT_TASK_EXECUTION_TIMEOUT_MS
386
+ ?? process.env.WALLE_SCRIPT_TASK_EXECUTION_TIMEOUT_MS,
387
+ scriptTimeoutFallbackMs,
388
+ 120000,
389
+ );
390
+ const chatTaskExecutionTimeoutMs = nonNegativeIntValue(
391
+ options.chatTaskExecutionTimeoutMs
392
+ ?? process.env.WALL_E_CHAT_TASK_EXECUTION_TIMEOUT_MS
393
+ ?? process.env.WALLE_CHAT_TASK_EXECUTION_TIMEOUT_MS,
394
+ taskExecutionTimeoutMs,
395
+ 240000,
396
+ );
397
+ const startedAt = Date.now();
398
+ const phaseDurations = [];
399
+ const maintenance = {};
400
+ const elapsedMs = () => Date.now() - startedAt;
401
+ const remainingMs = () => Math.max(0, maxTickMs - elapsedMs());
402
+ const runPhase = async (name, fn, extraMeta = {}) => {
403
+ const phaseStart = Date.now();
404
+ try {
405
+ const value = await fn();
406
+ const durationMs = Date.now() - phaseStart;
407
+ phaseDurations.push({ name, durationMs, ok: true, ...extraMeta });
408
+ runtimeHealth.recordOperation(`tasks.phase.${name}`, durationMs, {
409
+ meta: { remainingMs: remainingMs(), maxTickMs, ...extraMeta },
410
+ });
411
+ return value;
412
+ } catch (err) {
413
+ const durationMs = Date.now() - phaseStart;
414
+ phaseDurations.push({
415
+ name,
416
+ durationMs,
417
+ ok: false,
418
+ error: String(err?.message || err).slice(0, 120),
419
+ ...extraMeta,
420
+ });
421
+ runtimeHealth.recordOperation(`tasks.phase.${name}`, durationMs, {
422
+ ok: false,
423
+ error: err?.code || err?.name || 'error',
424
+ meta: { remainingMs: remainingMs(), maxTickMs, ...extraMeta },
425
+ });
426
+ throw err;
427
+ }
428
+ };
429
+
430
+ if (options.runMaintenance !== false) {
431
+ maintenance.repair = await runPhase(
432
+ 'maintenance.repairRecurringSchedules',
433
+ () => repairRecurringTaskSchedules(),
434
+ );
435
+
436
+ if (remainingMs() >= maintenanceMinRemainingMs) {
437
+ maintenance.slackAutoResume = await runPhase(
438
+ 'maintenance.slackAutoResume',
439
+ () => tryResumeSlackTasks(),
440
+ );
441
+ } else {
442
+ maintenance.slackAutoResume = { skipped: true, reason: 'tick_budget_low' };
443
+ phaseDurations.push({
444
+ name: 'maintenance.slackAutoResume',
445
+ durationMs: 0,
446
+ ok: true,
447
+ skipped: true,
448
+ reason: 'tick_budget_low',
449
+ });
450
+ }
451
+ }
452
+
453
+ if (elapsedMs() >= maxTickMs) {
454
+ return {
455
+ processed: 0,
456
+ reconciled: 0,
457
+ considered: 0,
458
+ fetched: 0,
459
+ maxTasks,
460
+ maxTickMs,
461
+ taskExecutionTimeoutMs,
462
+ scriptTaskExecutionTimeoutMs,
463
+ chatTaskExecutionTimeoutMs,
464
+ maintenance,
465
+ phaseDurations,
466
+ budgetExhausted: true,
467
+ _continue: true,
468
+ _continueDelayMs: continueDelayMs,
469
+ };
470
+ }
471
+
472
+ const fetched = await runPhase('scanDueTasks', () => brain.getDueTasks({ limit: maxTasks + 1 }));
473
+ if (fetched.length === 0) {
474
+ return {
475
+ processed: 0,
476
+ reconciled: 0,
477
+ considered: 0,
478
+ fetched: 0,
479
+ maxTasks,
480
+ maxTickMs,
481
+ taskExecutionTimeoutMs,
482
+ scriptTaskExecutionTimeoutMs,
483
+ chatTaskExecutionTimeoutMs,
484
+ maintenance,
485
+ phaseDurations,
486
+ budgetExhausted: false,
487
+ _continue: false,
488
+ };
489
+ }
137
490
 
138
- const tasks = brain.getDueTasks();
139
- if (tasks.length === 0) return { processed: 0 };
491
+ const tasks = fetched.slice(0, maxTasks);
492
+ const hadMore = fetched.length > maxTasks;
140
493
 
141
494
  let processed = 0;
495
+ let reconciled = 0;
496
+ let budgetExhausted = false;
142
497
  for (const task of tasks) {
498
+ if (elapsedMs() >= maxTickMs && (processed > 0 || reconciled > 0)) {
499
+ budgetExhausted = true;
500
+ break;
501
+ }
502
+
503
+ const taskPhaseStart = Date.now();
504
+ const taskPhase = {
505
+ name: `task.${task.execution || (task.skill ? 'skill' : 'chat')}`,
506
+ durationMs: 0,
507
+ ok: true,
508
+ taskId: task.id,
509
+ taskType: task.type || '',
510
+ };
511
+
143
512
  if (task.status === 'running') continue;
144
513
 
514
+ const blocker = classifyNonRetryableTaskBlocker(task);
515
+ if (blocker) {
516
+ await pauseBlockedRecurringTask(task, blocker, 'stored-task-blocker-reconcile');
517
+ reconciled++;
518
+ taskPhase.name = 'task.reconcileBlocked';
519
+ taskPhase.durationMs = Date.now() - taskPhaseStart;
520
+ phaseDurations.push(taskPhase);
521
+ continue;
522
+ }
523
+
145
524
  const now = new Date().toISOString();
146
525
  const hasCheckpoint = task.checkpoint ? ' (resuming from checkpoint)' : '';
147
526
  await safeUpdateTask(task.id, { status: 'running', started_at: now }, { reason: 'task-start' });
@@ -151,11 +530,18 @@ async function runDueTasks() {
151
530
  let resultText;
152
531
 
153
532
  if (task.skill) {
154
- resultText = await executeSkill(task.id, task);
533
+ resultText = await executeSkill(task.id, task, {
534
+ timeoutMs: taskExecutionTimeoutMs,
535
+ scriptTimeoutMs: scriptTaskExecutionTimeoutMs,
536
+ chatTimeoutMs: chatTaskExecutionTimeoutMs,
537
+ });
155
538
  } else if (task.execution === 'script' && task.script) {
156
- resultText = await executeScript(task.id, task.script, task.checkpoint);
539
+ resultText = await executeScript(task.id, task.script, task.checkpoint, null, {
540
+ timeoutMs: scriptTaskExecutionTimeoutMs,
541
+ taskTitle: task.title,
542
+ });
157
543
  } else {
158
- resultText = await executeChat(task.id, task);
544
+ resultText = await executeChat(task.id, task, { timeoutMs: chatTaskExecutionTimeoutMs });
159
545
  }
160
546
 
161
547
  const completedAt = new Date().toISOString();
@@ -230,7 +616,13 @@ async function runDueTasks() {
230
616
 
231
617
  processed++;
232
618
  console.log(`[tasks] Completed: ${task.title} (${task.execution || 'chat'})`);
619
+ taskPhase.durationMs = Date.now() - taskPhaseStart;
620
+ phaseDurations.push(taskPhase);
233
621
  } catch (err) {
622
+ taskPhase.ok = false;
623
+ taskPhase.durationMs = Date.now() - taskPhaseStart;
624
+ taskPhase.error = String(err?.message || err).slice(0, 120);
625
+ phaseDurations.push(taskPhase);
234
626
  console.error('[tasks] Failed:', task.title, err.message);
235
627
  appendLog(task.id, `[ERROR] ${err.message}`);
236
628
 
@@ -252,6 +644,7 @@ async function runDueTasks() {
252
644
  // Detect actionable errors and create pending questions
253
645
  const errMsg = err.message || '';
254
646
  const isSlackAuth = errMsg.includes('Slack token expired') || errMsg.includes('invalid_auth') || errMsg.includes('401') || errMsg.includes('not authenticated');
647
+ const isCalendarDenied = isCalendarAccessDeniedError(task, errMsg);
255
648
 
256
649
  // For Slack auth errors, try auto-refresh before pausing
257
650
  let slackRefreshSucceeded = false;
@@ -301,6 +694,22 @@ async function runDueTasks() {
301
694
  }
302
695
  }
303
696
 
697
+ if (isCalendarDenied && task.type === 'recurring') {
698
+ await pauseBlockedRecurringTask({
699
+ ...task,
700
+ error: errMsg,
701
+ last_run_at: new Date().toISOString(),
702
+ }, classifyNonRetryableTaskBlocker({
703
+ ...task,
704
+ error: errMsg,
705
+ }), 'calendar-access-denied-pause');
706
+ await safeUpdateTask(task.id, {
707
+ run_count: (task.run_count || 0) + 1,
708
+ }, { bestEffort: true, reason: 'calendar-access-denied-count' });
709
+ appendLog(task.id, 'Paused recurring task until Calendar access is granted.');
710
+ continue;
711
+ }
712
+
304
713
  await safeUpdateTask(task.id, {
305
714
  status: task.type === 'recurring' ? 'pending' : 'failed',
306
715
  error: isSlackAuth
@@ -319,12 +728,28 @@ async function runDueTasks() {
319
728
  }
320
729
  }
321
730
 
322
- return { processed };
731
+ const shouldContinue = hadMore || budgetExhausted;
732
+ return {
733
+ processed,
734
+ reconciled,
735
+ considered: tasks.length,
736
+ fetched: fetched.length,
737
+ maxTasks,
738
+ maxTickMs,
739
+ taskExecutionTimeoutMs,
740
+ scriptTaskExecutionTimeoutMs,
741
+ chatTaskExecutionTimeoutMs,
742
+ maintenance,
743
+ phaseDurations,
744
+ budgetExhausted,
745
+ _continue: shouldContinue,
746
+ ...(shouldContinue ? { _continueDelayMs: continueDelayMs } : {}),
747
+ };
323
748
  }
324
749
 
325
750
  // ── Skill execution ──
326
751
 
327
- async function executeSkill(taskId, task) {
752
+ async function executeSkill(taskId, task, options = {}) {
328
753
  const skill = findSkill(task.skill);
329
754
  if (!skill) throw new Error(`Skill "${task.skill}" not found`);
330
755
 
@@ -334,6 +759,8 @@ async function executeSkill(taskId, task) {
334
759
  const { runScriptSkill } = require('../skills/script-skill-runner');
335
760
  return runScriptSkill(skill, task, {
336
761
  log: line => appendLog(taskId, line),
762
+ timeoutMs: options.scriptTimeoutMs ?? options.timeoutMs,
763
+ killGraceMs: options.killGraceMs,
337
764
  onCheckpoint: checkpoint => {
338
765
  void safeUpdateTask(taskId, { checkpoint }, {
339
766
  bestEffort: true,
@@ -355,7 +782,10 @@ async function executeSkill(taskId, task) {
355
782
  description: skill.instructions || skill.description,
356
783
  };
357
784
  appendLog(taskId, `Agent skill: sending instructions to Claude...`);
358
- return executeChat(taskId, agentTask);
785
+ return executeChat(taskId, agentTask, {
786
+ ...options,
787
+ timeoutMs: options.chatTimeoutMs ?? options.timeoutMs,
788
+ });
359
789
  }
360
790
 
361
791
  throw new Error(`Unknown skill execution mode: ${skill.execution}`);
@@ -363,7 +793,7 @@ async function executeSkill(taskId, task) {
363
793
 
364
794
  // ── Script execution with live log streaming ──
365
795
 
366
- function executeScript(taskId, script, checkpoint, extraEnv) {
796
+ function executeScript(taskId, script, checkpoint, extraEnv, options = {}) {
367
797
  return new Promise((resolve, reject) => {
368
798
  // Pass checkpoint as env var so scripts can resume from where they left off
369
799
  const env = { ...process.env, HOME: process.env.HOME };
@@ -379,7 +809,37 @@ function executeScript(taskId, script, checkpoint, extraEnv) {
379
809
 
380
810
  let stdout = '';
381
811
  let stderr = '';
382
- // No timeout — tasks run until completion. User can stop via UI.
812
+ let timedOut = false;
813
+ let settled = false;
814
+ let timeoutTimer = null;
815
+ let killTimer = null;
816
+ const timeoutMs = nonNegativeIntValue(options.timeoutMs, 0, 240000);
817
+ const killGraceMs = nonNegativeIntValue(options.killGraceMs, DEFAULT_TASK_KILL_GRACE_MS, 10000);
818
+ const clearTimers = () => {
819
+ if (timeoutTimer) clearTimeout(timeoutTimer);
820
+ if (killTimer) clearTimeout(killTimer);
821
+ timeoutTimer = null;
822
+ killTimer = null;
823
+ };
824
+ const finish = (fn, value) => {
825
+ if (settled) return;
826
+ settled = true;
827
+ clearTimers();
828
+ taskProcesses.delete(taskId);
829
+ fn(value);
830
+ };
831
+ if (timeoutMs > 0) {
832
+ timeoutTimer = setTimeout(() => {
833
+ timedOut = true;
834
+ appendLog(taskId, `[timeout] Task exceeded ${timeoutMs}ms; stopping script process...`);
835
+ try { child.kill('SIGTERM'); } catch {}
836
+ killTimer = setTimeout(() => {
837
+ try { child.kill('SIGKILL'); } catch {}
838
+ }, killGraceMs);
839
+ killTimer.unref?.();
840
+ }, timeoutMs);
841
+ timeoutTimer.unref?.();
842
+ }
383
843
 
384
844
  child.stdout.on('data', (chunk) => {
385
845
  const text = chunk.toString();
@@ -405,28 +865,31 @@ function executeScript(taskId, script, checkpoint, extraEnv) {
405
865
  });
406
866
 
407
867
  child.on('close', (code) => {
408
- taskProcesses.delete(taskId);
868
+ if (timedOut) {
869
+ finish(reject, createTaskTimeoutError({ id: taskId, title: options.taskTitle || 'script task' }, timeoutMs));
870
+ return;
871
+ }
409
872
  if (code !== 0 && code !== null) {
410
- reject(new Error(`Exit code ${code}: ${stderr.slice(0, 500)}`));
873
+ finish(reject, new Error(`Exit code ${code}: ${stderr.slice(0, 500)}`));
411
874
  } else {
412
875
  let output = stdout.trim();
413
876
  if (stderr.trim()) output += '\n[stderr] ' + stderr.trim();
414
- resolve(output);
877
+ finish(resolve, output);
415
878
  }
416
879
  });
417
880
 
418
881
  child.on('error', (err) => {
419
- reject(err);
882
+ finish(reject, err);
420
883
  });
421
884
  });
422
885
  }
423
886
 
424
887
  // ── Chat execution with log streaming ──
425
888
 
426
- async function executeChat(taskId, task) {
889
+ async function executeChat(taskId, task, options = {}) {
427
890
  // Use multi-turn for complex tasks
428
891
  if (task.execution === 'multi-turn') {
429
- return executeMultiTurnChat(taskId, task);
892
+ return executeMultiTurnChat(taskId, task, options);
430
893
  }
431
894
 
432
895
  // Single-turn (existing behavior)
@@ -438,6 +901,8 @@ async function executeChat(taskId, task) {
438
901
  const result = await chatModule.chat(prompt, {
439
902
  channel: 'task',
440
903
  session_id: `task-${task.id}`,
904
+ ...(options.timeoutMs > 0 ? { timeoutMs: options.timeoutMs } : {}),
905
+ ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
441
906
  onProgress: (event) => {
442
907
  if (event.type === 'tool_call') appendLog(taskId, event.summary);
443
908
  else if (event.type === 'tool_done') appendLog(taskId, `Done: ${event.summary}`);
@@ -452,13 +917,19 @@ async function executeChat(taskId, task) {
452
917
  * Execute a task using multiple chat turns, maintaining session state.
453
918
  * Each turn can use tools, and progress is checkpointed between turns.
454
919
  */
455
- async function executeMultiTurnChat(taskId, task) {
920
+ async function executeMultiTurnChat(taskId, task, options = {}) {
456
921
  const chatModule = require('../chat');
457
922
  const sessionId = `task-${task.id}`;
458
923
  const MAX_TASK_TURNS = 5;
924
+ const timeoutMs = nonNegativeIntValue(options.timeoutMs, 0, 240000);
925
+ const deadlineMs = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
459
926
  let lastReply = '';
460
927
 
461
928
  for (let turn = 0; turn < MAX_TASK_TURNS; turn++) {
929
+ const remainingMs = deadlineMs > 0 ? deadlineMs - Date.now() : 0;
930
+ if (deadlineMs > 0 && remainingMs <= 0) {
931
+ throw createTaskTimeoutError(task, timeoutMs);
932
+ }
462
933
  const prompt = turn === 0
463
934
  ? buildTaskPrompt(task)
464
935
  : 'Continue working on this task. Review your progress so far and take the next step. If you are done, include [TASK COMPLETE] in your response.';
@@ -468,6 +939,8 @@ async function executeMultiTurnChat(taskId, task) {
468
939
  const result = await chatModule.chat(prompt, {
469
940
  channel: 'task',
470
941
  session_id: sessionId,
942
+ ...(remainingMs > 0 ? { timeoutMs: remainingMs } : {}),
943
+ ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
471
944
  onProgress: (event) => {
472
945
  if (event.type === 'tool_call') appendLog(taskId, event.summary);
473
946
  else if (event.type === 'tool_done') appendLog(taskId, `Done: ${event.summary}`);
@@ -762,4 +1235,27 @@ async function retrySlackDelivery(task, resultText) {
762
1235
  }
763
1236
  }
764
1237
 
765
- module.exports = { runDueTasks, runTaskById, recoverInterruptedTasks, buildTaskPrompt, computeNextDue, getTaskLogs, clearTaskLogs, stopTask, executeSkill, executeMultiTurnChat, consolidateBriefingItems, taskLogs };
1238
+ module.exports = {
1239
+ runDueTasks,
1240
+ runTaskById,
1241
+ recoverInterruptedTasks,
1242
+ buildTaskPrompt,
1243
+ computeNextDue,
1244
+ getTaskLogs,
1245
+ clearTaskLogs,
1246
+ stopTask,
1247
+ executeSkill,
1248
+ executeMultiTurnChat,
1249
+ consolidateBriefingItems,
1250
+ taskLogs,
1251
+ _internals: {
1252
+ isCalendarAccessDeniedError,
1253
+ repairRecurringTaskSchedules,
1254
+ tryResumeSlackTasks,
1255
+ shouldRunSlackAutoResumeProbe,
1256
+ setSlackAutoResumeCooldown,
1257
+ clearSlackAutoResumeCooldown,
1258
+ positiveIntValue,
1259
+ nonNegativeIntValue,
1260
+ },
1261
+ };