@swarmclawai/swarmclaw 1.9.38 → 1.9.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -151,6 +151,18 @@ openclaw skills install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/waydelyle/swarmclaw)
153
153
 
154
+ ## v1.9.39 Highlights
155
+
156
+ Scheduled-run reliability release: stale workspace rebinding, fail-fast credential checks, and durable delivery evidence for schedule-driven sends.
157
+
158
+ - **Legacy workspace cwd migration.** Scheduled reruns and reused schedule sessions pinned to a pre-migration workspace root (e.g. `~/.swarmclaw/workspace`) are now rebound to the current `WORKSPACE_DIR` automatically; intentional custom working directories are never touched.
159
+ - **Orphan recovery dedup.** Startup queue recovery now recovers each orphaned task once instead of re-logging it every tick, and dead-letters tasks that repeatedly return to the queue without starting.
160
+ - **Schedule delivery status.** Every scheduled run now writes `lastDeliveryStatus`/`lastDeliveryError` back to its schedule, so failures are visible without digging through task records.
161
+ - **Credential preflight for schedules.** Scheduled runs on API-key providers fail fast with an actionable error when no credential resolves, instead of dying on a 401 deep in execution.
162
+ - **Empty-run classification.** Runs that produce no text, no tool calls, and no error now fail with a clear provider-configuration message instead of a generic validation failure.
163
+ - **Durable connector delivery evidence.** Task follow-up sends route through the connector outbox with task/schedule linkage, retries with backoff, and per-run dedupe, so triage can prove whether a scheduled send succeeded, failed, or was never attempted.
164
+ - **Regression coverage.** Added tests for workspace path normalization, orphan recovery, schedule outcome writes, credential preflight, empty-run classification, and outbox-backed follow-ups.
165
+
154
166
  ## v1.9.38 Highlights
155
167
 
156
168
  PR integration release for provider catalog coverage, OpenRouter context meters, and safer unsigned macOS desktop artifacts.
@@ -488,6 +500,18 @@ Operational docs: https://swarmclaw.ai/docs/observability
488
500
 
489
501
  ## Releases
490
502
 
503
+ ### v1.9.39 Highlights
504
+
505
+ Scheduled-run reliability release: stale workspace rebinding, fail-fast credential checks, and durable delivery evidence for schedule-driven sends.
506
+
507
+ - **Legacy workspace cwd migration.** Scheduled reruns and reused schedule sessions pinned to a pre-migration workspace root (e.g. `~/.swarmclaw/workspace`) are now rebound to the current `WORKSPACE_DIR` automatically; intentional custom working directories are never touched.
508
+ - **Orphan recovery dedup.** Startup queue recovery now recovers each orphaned task once instead of re-logging it every tick, and dead-letters tasks that repeatedly return to the queue without starting.
509
+ - **Schedule delivery status.** Every scheduled run now writes `lastDeliveryStatus`/`lastDeliveryError` back to its schedule, so failures are visible without digging through task records.
510
+ - **Credential preflight for schedules.** Scheduled runs on API-key providers fail fast with an actionable error when no credential resolves, instead of dying on a 401 deep in execution.
511
+ - **Empty-run classification.** Runs that produce no text, no tool calls, and no error now fail with a clear provider-configuration message instead of a generic validation failure.
512
+ - **Durable connector delivery evidence.** Task follow-up sends route through the connector outbox with task/schedule linkage, retries with backoff, and per-run dedupe, so triage can prove whether a scheduled send succeeded, failed, or was never attempted.
513
+ - **Regression coverage.** Added tests for workspace path normalization, orphan recovery, schedule outcome writes, credential preflight, empty-run classification, and outbox-backed follow-ups.
514
+
491
515
  ### v1.9.38 Highlights
492
516
 
493
517
  PR integration release for provider catalog coverage, OpenRouter context meters, and safer unsigned macOS desktop artifacts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.38",
3
+ "version": "1.9.39",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -88,7 +88,7 @@
88
88
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/electron-signing-config.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
89
89
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
90
90
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
91
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/autonomy/supervisor-settings.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/autonomy/supervisor-reflection.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/memory/dream-service.test.ts src/lib/server/memory/memory-consolidation.test.ts src/lib/server/messages/message-repository.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/compaction-generation-preference.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/connectors/slack.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/search/route.test.ts src/app/api/settings/settings-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/gateways/control-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
91
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/autonomy/supervisor-settings.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/autonomy/supervisor-reflection.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/memory/dream-service.test.ts src/lib/server/memory/memory-consolidation.test.ts src/lib/server/messages/message-repository.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/compaction-generation-preference.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/connectors/slack.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/tasks/task-lifecycle.test.ts src/lib/server/tasks/task-followups.test.ts src/lib/server/tasks/task-result.test.ts src/lib/server/schedules/schedule-lifecycle.test.ts src/lib/server/workspace-paths.test.ts src/lib/server/runtime/queue/orphan-recovery.test.ts src/lib/server/runtime/scheduled-run-preflight.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/search/route.test.ts src/app/api/settings/settings-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/gateways/control-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
92
92
  "test:builder": "tsx --test src/features/protocols/builder/utils/builder-template-access.test.ts src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
93
93
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
94
94
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -5,7 +5,7 @@ import path from 'node:path'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { describe, it } from 'node:test'
7
7
 
8
- import { assessAutonomyRun } from '@/lib/server/autonomy/supervisor-reflection'
8
+ import { assessAutonomyRun, classifyRuntimeFailure } from '@/lib/server/autonomy/supervisor-reflection'
9
9
  import type { Session } from '@/types'
10
10
 
11
11
  const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
@@ -615,4 +615,13 @@ describe('supervisor-reflection', () => {
615
615
  assert.doesNotMatch(String(incident?.details || ''), /<!doctype html>/i)
616
616
  assert.match(String(incident?.details || ''), /singleton is not defined/i)
617
617
  })
618
+
619
+ it('classifies the scheduled-run credential preflight error as provider auth', () => {
620
+ const failure = classifyRuntimeFailure({
621
+ source: 'task',
622
+ message: 'Provider authentication preflight failed: no API credential configured for provider "openai".',
623
+ })
624
+ assert.equal(failure.family, 'provider_auth')
625
+ assert.equal(failure.severity, 'high')
626
+ })
618
627
  })
@@ -43,6 +43,10 @@ export interface ConnectorOutboxEntry extends Record<string, unknown> {
43
43
  threadId?: string
44
44
  ptt?: boolean
45
45
  dedupeKey?: string | null
46
+ /** Originating task, when this entry delivers a task follow-up */
47
+ taskId?: string | null
48
+ /** Originating schedule, when the task was schedule-driven */
49
+ scheduleId?: string | null
46
50
  lastError?: string | null
47
51
  deliveredAt?: number | null
48
52
  lastMessageId?: string | null
@@ -78,8 +82,8 @@ function normalizeEntry(value: unknown): ConnectorOutboxEntry | null {
78
82
  if (!id || !channelId) return null
79
83
  return {
80
84
  id,
81
-
82
-
85
+ connectorId: typeof row.connectorId === 'string' ? row.connectorId : undefined,
86
+ platform: typeof row.platform === 'string' ? row.platform : undefined,
83
87
  channelId,
84
88
  text: typeof row.text === 'string' ? row.text : '',
85
89
  sessionId: typeof row.sessionId === 'string' ? row.sessionId : null,
@@ -99,6 +103,8 @@ function normalizeEntry(value: unknown): ConnectorOutboxEntry | null {
99
103
  attemptCount: typeof row.attemptCount === 'number' ? row.attemptCount : 0,
100
104
  maxAttempts: typeof row.maxAttempts === 'number' ? row.maxAttempts : DEFAULT_MAX_ATTEMPTS,
101
105
  dedupeKey: typeof row.dedupeKey === 'string' ? row.dedupeKey : null,
106
+ taskId: typeof row.taskId === 'string' ? row.taskId : null,
107
+ scheduleId: typeof row.scheduleId === 'string' ? row.scheduleId : null,
102
108
  lastError: typeof row.lastError === 'string' ? row.lastError : null,
103
109
  deliveredAt: typeof row.deliveredAt === 'number' ? row.deliveredAt : null,
104
110
  lastMessageId: typeof row.lastMessageId === 'string' ? row.lastMessageId : null,
@@ -301,6 +307,20 @@ export function enqueueConnectorOutbox(
301
307
  return { outboxId: entry.id, sendAt: entry.sendAt }
302
308
  }
303
309
 
310
+ /**
311
+ * True when a delivery for this dedupe key already reached a successful
312
+ * terminal state. `findPendingConnectorOutboxByDedupe` only sees non-terminal
313
+ * entries, so it cannot prevent re-sends after a success.
314
+ */
315
+ export function hasSentConnectorOutboxForDedupe(dedupeKey: string): boolean {
316
+ const normalizedKey = dedupeKey.trim()
317
+ if (!normalizedKey) return false
318
+ return listEntries().some((entry) =>
319
+ entry.dedupeKey === normalizedKey
320
+ && (entry.status === 'sent' || entry.status === 'suppressed'),
321
+ )
322
+ }
323
+
304
324
  export function findPendingConnectorOutboxByDedupe(dedupeKey: string, now = Date.now()): ConnectorOutboxEntry | null {
305
325
  const normalizedKey = dedupeKey.trim()
306
326
  if (!normalizedKey) return null
@@ -8,7 +8,8 @@ import { logActivity } from '@/lib/server/activity/activity-log'
8
8
  import { loadAgents } from '@/lib/server/agents/agent-repository'
9
9
  import { withTransaction } from '@/lib/server/persistence/transaction'
10
10
  import { loadQueue, saveQueue } from '@/lib/server/runtime/queue-repository'
11
- import { loadSchedules, saveSchedules } from '@/lib/server/schedules/schedule-repository'
11
+ import { loadSchedules, saveSchedules, upsertSchedule } from '@/lib/server/schedules/schedule-repository'
12
+ import { applyScheduleRunOutcome } from '@/lib/server/schedules/schedule-lifecycle'
12
13
  import { loadSessions, saveSessions } from '@/lib/server/sessions/session-repository'
13
14
  import { loadSettings } from '@/lib/server/settings/settings-repository'
14
15
  import { loadTasks, saveTasks } from '@/lib/server/tasks/task-repository'
@@ -16,13 +17,20 @@ import { notify } from '@/lib/server/ws-hub'
16
17
  import { getMessages, getLastMessage, appendMessage } from '@/lib/server/messages/message-repository'
17
18
  import { perf } from '@/lib/server/runtime/perf'
18
19
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
20
+ import { normalizeLegacyWorkspacePath } from '@/lib/server/workspace-paths'
21
+ import {
22
+ MAX_ORPHAN_RECOVERY_ATTEMPTS,
23
+ pruneOrphanRecovery,
24
+ trackOrphanRecovery,
25
+ } from '@/lib/server/runtime/queue/orphan-recovery'
26
+ import { preflightProviderCredential } from '@/lib/server/runtime/scheduled-run-preflight'
19
27
  import { createAgentTaskSession } from '@/lib/server/agents/task-session'
20
28
  import { formatValidationFailure } from '@/lib/server/tasks/task-validation'
21
29
  import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-loop'
22
30
  import type { ExecuteChatTurnResult } from '@/lib/server/chat-execution/chat-execution-types'
23
31
  import { checkAgentBudgetLimits } from '@/lib/server/cost'
24
32
  import { enqueueExecution } from '@/lib/server/execution-engine'
25
- import { extractTaskResult, formatResultBody } from '@/lib/server/tasks/task-result'
33
+ import { classifyEmptyRunOutcome, EMPTY_RUN_OUTCOME_MESSAGE, extractTaskResult, formatResultBody } from '@/lib/server/tasks/task-result'
26
34
  import { checkoutTask } from '@/lib/server/tasks/task-checkout'
27
35
  import { queueSwarmFeedTaskCompletionWake } from '@/lib/server/swarmfeed-runtime'
28
36
  import {
@@ -64,6 +72,7 @@ const _queueState = hmrSingleton('__swarmclaw_queue__', () => ({
64
72
  activeCount: 0,
65
73
  maxConcurrent: 3,
66
74
  pendingKick: false,
75
+ orphanRecoveryAttempts: {} as Record<string, number>,
67
76
  }))
68
77
 
69
78
  function normalizeInt(value: unknown, fallback: number, min: number, max: number): number {
@@ -499,7 +508,10 @@ function inferWorkspaceProjectCwd(task: Pick<BoardTask, 'title' | 'description'
499
508
  function resolveTaskExecutionCwd(task: ScheduleTaskMeta, sessions: Record<string, SessionLike>): string {
500
509
  const workspaceRoot = path.resolve(WORKSPACE_DIR)
501
510
 
502
- const explicitCwd = normalizeDirCandidate(task.cwd, workspaceRoot)
511
+ const explicitCwd = normalizeDirCandidate(
512
+ normalizeLegacyWorkspacePath(typeof task.cwd === 'string' ? task.cwd : '', { workspaceRoot, taskId: task.id }),
513
+ workspaceRoot,
514
+ )
503
515
  if (explicitCwd) return explicitCwd
504
516
 
505
517
  const projectId = typeof task.projectId === 'string' ? task.projectId.trim() : ''
@@ -520,13 +532,19 @@ function resolveTaskExecutionCwd(task: ScheduleTaskMeta, sessions: Record<string
520
532
 
521
533
  const sourceSessionId = typeof task.createdInSessionId === 'string' ? task.createdInSessionId.trim() : ''
522
534
  const sourceSessionCwd = sourceSessionId
523
- ? normalizeDirCandidate(sessions[sourceSessionId]?.cwd, workspaceRoot)
535
+ ? normalizeDirCandidate(
536
+ normalizeLegacyWorkspacePath(sessions[sourceSessionId]?.cwd, { workspaceRoot, taskId: task.id }),
537
+ workspaceRoot,
538
+ )
524
539
  : null
525
540
  if (sourceSessionCwd && path.resolve(sourceSessionCwd) !== workspaceRoot) return sourceSessionCwd
526
541
 
527
542
  const runSessionId = typeof task.sessionId === 'string' ? task.sessionId.trim() : ''
528
543
  const runSessionCwd = runSessionId
529
- ? normalizeDirCandidate(sessions[runSessionId]?.cwd, workspaceRoot)
544
+ ? normalizeDirCandidate(
545
+ normalizeLegacyWorkspacePath(sessions[runSessionId]?.cwd, { workspaceRoot, taskId: task.id }),
546
+ workspaceRoot,
547
+ )
530
548
  : null
531
549
  if (runSessionCwd && path.resolve(runSessionCwd) !== workspaceRoot) return runSessionCwd
532
550
 
@@ -708,6 +726,7 @@ export function reconcileFinishedRunningTasks(): { reconciled: number; deadLette
708
726
  if (!fallbackText && !task.result) {
709
727
  task.status = 'failed'
710
728
  task.result = 'Agent session finished without producing output.'
729
+ task.error = EMPTY_RUN_OUTCOME_MESSAGE.slice(0, 500)
711
730
  task.checkoutRunId = null
712
731
  task.updatedAt = now
713
732
  tasksDirty = true
@@ -854,7 +873,20 @@ function deliverTaskConnectorFollowups(task: BoardTask, sessions: Record<string,
854
873
  })
855
874
  }
856
875
 
876
+ /** Reflects a terminal scheduled-run outcome back onto the originating schedule. */
877
+ function recordScheduleRunOutcome(task: BoardTask): void {
878
+ const meta = task as ScheduleTaskMeta
879
+ const sourceScheduleId = typeof meta.sourceScheduleId === 'string' ? meta.sourceScheduleId.trim() : ''
880
+ if (!sourceScheduleId) return
881
+ const schedule = loadSchedules()[sourceScheduleId]
882
+ if (!schedule) return
883
+ if (!applyScheduleRunOutcome(schedule, task, Date.now())) return
884
+ upsertSchedule(sourceScheduleId, schedule)
885
+ notify('schedules')
886
+ }
887
+
857
888
  function handleTerminalTaskResultDeliveries(task: BoardTask): void {
889
+ recordScheduleRunOutcome(task)
858
890
  const sessions = loadSessions() as Record<string, SessionLike>
859
891
  pushUserFacingTaskResult(task, sessions)
860
892
  deliverTaskConnectorFollowups(task, sessions)
@@ -1114,25 +1146,68 @@ export async function processNext() {
1114
1146
  const allTasks = loadTasks()
1115
1147
  const currentQueue = loadQueue()
1116
1148
  const queueSet = new Set(currentQueue)
1149
+ // Backfill for hmrSingleton state created before this field existed
1150
+ _queueState.orphanRecoveryAttempts ??= {}
1151
+ const orphanAttempts = _queueState.orphanRecoveryAttempts
1152
+ const stillOrphanedIds = new Set<string>()
1153
+ const deadLetteredOrphans: BoardTask[] = []
1117
1154
  let recovered = false
1118
1155
  let tasksDirty = false
1119
1156
  for (const [id, t] of Object.entries(allTasks) as [string, BoardTask][]) {
1120
- if (t.status === 'queued' && !queueSet.has(id)) {
1157
+ if (t.status !== 'queued' || queueSet.has(id)) continue
1158
+ const decision = trackOrphanRecovery(orphanAttempts, id)
1159
+ if (decision.action === 'dead_letter') {
1160
+ // Recovery keeps re-queueing this task but it never starts. Stop the
1161
+ // loop with one terminal reason instead of spamming recovery forever.
1162
+ const now = Date.now()
1163
+ t.status = 'failed'
1164
+ t.deadLetteredAt = now
1165
+ t.retryScheduledAt = null
1166
+ t.checkoutRunId = null
1167
+ t.updatedAt = now
1168
+ t.error = `Orphan recovery exhausted after ${MAX_ORPHAN_RECOVERY_ATTEMPTS} attempts: task repeatedly returned to "queued" without starting.`
1169
+ if (!t.comments) t.comments = []
1170
+ t.comments.push({
1171
+ id: genId(),
1172
+ author: 'System',
1173
+ text: t.error,
1174
+ createdAt: now,
1175
+ })
1176
+ delete orphanAttempts[id]
1177
+ tasksDirty = true
1178
+ deadLetteredOrphans.push(t)
1179
+ log.warn(TAG, `[queue] Dead-lettered orphaned queued task after ${decision.attempt - 1} recovery attempts: "${t.title}" (${id})`)
1180
+ continue
1181
+ }
1182
+ stillOrphanedIds.add(id)
1183
+ if (decision.firstAttempt) {
1121
1184
  log.info(TAG, `[queue] Recovering orphaned queued task: "${t.title}" (${id})`)
1122
- // Defence in depth: a queued task must not carry a stale checkoutRunId
1123
- // (left over from pre-1.5.38 retries). If it does, checkoutTask() will
1124
- // reject every attempt and this orphan-recovery loop will spin at 100%
1125
- // CPU re-queueing a task that can never run.
1126
- if (t.checkoutRunId) {
1127
- t.checkoutRunId = null
1128
- tasksDirty = true
1129
- }
1130
- pushQueueUnique(currentQueue, id)
1131
- recovered = true
1185
+ } else {
1186
+ log.debug(TAG, `[queue] Re-recovering orphaned queued task (attempt ${decision.attempt}): "${t.title}" (${id})`)
1187
+ }
1188
+ // Defence in depth: a queued task must not carry a stale checkoutRunId
1189
+ // (left over from pre-1.5.38 retries). If it does, checkoutTask() will
1190
+ // reject every attempt and this orphan-recovery loop will spin at 100%
1191
+ // CPU re-queueing a task that can never run.
1192
+ if (t.checkoutRunId) {
1193
+ t.checkoutRunId = null
1194
+ tasksDirty = true
1132
1195
  }
1196
+ pushQueueUnique(currentQueue, id)
1197
+ recovered = true
1133
1198
  }
1199
+ pruneOrphanRecovery(orphanAttempts, stillOrphanedIds)
1134
1200
  if (tasksDirty) saveTasks(allTasks)
1135
1201
  if (recovered) saveQueue(currentQueue)
1202
+ for (const t of deadLetteredOrphans) {
1203
+ notify('tasks')
1204
+ logActivity({ entityType: 'task', entityId: t.id, action: 'failed', actor: 'system', actorId: t.agentId, summary: `Task failed: "${t.title}" (orphan recovery exhausted)` })
1205
+ pushMainLoopEventToMainSessions({
1206
+ type: 'task_failed',
1207
+ text: `Task failed: "${t.title}" (${t.id}): orphan recovery exhausted.`,
1208
+ })
1209
+ handleTerminalTaskResultDeliveries(t)
1210
+ }
1136
1211
  }
1137
1212
 
1138
1213
  // Process ONE task per invocation (no while loop)
@@ -1261,6 +1336,61 @@ export async function processNext() {
1261
1336
  } catch {}
1262
1337
  }
1263
1338
 
1339
+ // Credential preflight for scheduled runs: fail fast with an actionable
1340
+ // error instead of letting the schedule die on a 401 deep in execution.
1341
+ // Retries cannot succeed without a key, so this dead-letters immediately.
1342
+ if ((task as ScheduleTaskMeta).sourceType === 'schedule') {
1343
+ const preflight = preflightProviderCredential({
1344
+ provider: typedAgent.provider,
1345
+ ollamaMode: typedAgent.ollamaMode ?? null,
1346
+ credentialId: typedAgent.credentialId ?? null,
1347
+ fallbackCredentialIds: typedAgent.fallbackCredentialIds || null,
1348
+ })
1349
+ if (!preflight.ok) {
1350
+ const now = Date.now()
1351
+ task.status = 'failed'
1352
+ task.deadLetteredAt = now
1353
+ task.retryScheduledAt = null
1354
+ task.checkoutRunId = null
1355
+ task.error = preflight.error.slice(0, 500)
1356
+ task.updatedAt = now
1357
+ if (!task.comments) task.comments = []
1358
+ task.comments.push({
1359
+ id: genId(),
1360
+ author: 'System',
1361
+ text: preflight.error,
1362
+ createdAt: now,
1363
+ })
1364
+ saveTasks(latestTasks)
1365
+ notify('tasks')
1366
+ const failure = classifyRuntimeFailure({ source: 'task', message: preflight.error })
1367
+ recordSupervisorIncident({
1368
+ runId: task.id,
1369
+ sessionId: task.sessionId || '',
1370
+ taskId: task.id,
1371
+ agentId: typedAgent.id,
1372
+ source: 'task',
1373
+ kind: 'runtime_failure',
1374
+ severity: failure.severity,
1375
+ summary: `Scheduled run blocked by credential preflight: ${preflight.error}`.slice(0, 320),
1376
+ details: preflight.error,
1377
+ failureFamily: failure.family,
1378
+ remediation: failure.remediation,
1379
+ repairPrompt: failure.repairPrompt,
1380
+ autoAction: null,
1381
+ })
1382
+ logActivity({ entityType: 'task', entityId: task.id, action: 'failed', actor: 'system', actorId: typedAgent.id, summary: `Task failed credential preflight: "${task.title}"` })
1383
+ pushMainLoopEventToMainSessions({
1384
+ type: 'task_failed',
1385
+ text: `Task failed: "${task.title}" (${task.id}): ${preflight.error.slice(0, 200)}`,
1386
+ })
1387
+ handleTerminalTaskResultDeliveries(task)
1388
+ cleanupTerminalOneOffSchedule(task)
1389
+ log.warn(TAG, `[queue] Scheduled task "${task.title}" (${taskId}) failed credential preflight: ${preflight.error}`)
1390
+ return
1391
+ }
1392
+ }
1393
+
1264
1394
  // Atomic checkout — prevents two runners from starting the same task
1265
1395
  const runId = genId()
1266
1396
  task = checkoutTask(taskId, runId) as BoardTask | undefined
@@ -1296,8 +1426,17 @@ export async function processNext() {
1296
1426
  : ''
1297
1427
  if (existingSessionId) {
1298
1428
  const sessions = loadSessions()
1299
- if (sessions[existingSessionId]) {
1429
+ const existingSession = sessions[existingSessionId]
1430
+ if (existingSession) {
1300
1431
  sessionId = existingSessionId
1432
+ // Rebind sessions still pinned to a legacy workspace root (e.g. a
1433
+ // pre-migration ~/.swarmclaw/workspace path) onto the current root.
1434
+ const sessionCwd = typeof existingSession.cwd === 'string' ? existingSession.cwd : ''
1435
+ if (sessionCwd && normalizeLegacyWorkspacePath(sessionCwd, { taskId: task.id }) !== sessionCwd) {
1436
+ existingSession.cwd = taskCwd
1437
+ saveSessions(sessions)
1438
+ log.info(TAG, `[queue] Rebound stale schedule session cwd to ${taskCwd} (session ${existingSessionId})`)
1439
+ }
1301
1440
  }
1302
1441
  }
1303
1442
  if (!sessionId) {
@@ -1467,7 +1606,10 @@ export async function processNext() {
1467
1606
  createdAt: now,
1468
1607
  })
1469
1608
  } else {
1470
- const failureReason = formatValidationFailure(validation.reasons).slice(0, 500)
1609
+ // A run with no text, no tool calls, and no error gets an actionable
1610
+ // reason instead of the generic "Result summary is empty." message.
1611
+ const emptyRunReason = classifyEmptyRunOutcome(taskRun)
1612
+ const failureReason = (emptyRunReason || formatValidationFailure(validation.reasons)).slice(0, 500)
1471
1613
  const retryState = scheduleRetryOrDeadLetter(t2[taskId], failureReason)
1472
1614
  t2[taskId].completedAt = retryState === 'dead_lettered' ? null : t2[taskId].completedAt
1473
1615
  t2[taskId].comments!.push({
@@ -0,0 +1,49 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ MAX_ORPHAN_RECOVERY_ATTEMPTS,
6
+ pruneOrphanRecovery,
7
+ trackOrphanRecovery,
8
+ } from './orphan-recovery'
9
+
10
+ test('allows recovery for the first attempts and flags only the first one', () => {
11
+ const attempts: Record<string, number> = {}
12
+
13
+ const first = trackOrphanRecovery(attempts, 'task-1')
14
+ assert.deepEqual(first, { action: 'recover', attempt: 1, firstAttempt: true })
15
+
16
+ const second = trackOrphanRecovery(attempts, 'task-1')
17
+ assert.deepEqual(second, { action: 'recover', attempt: 2, firstAttempt: false })
18
+
19
+ const third = trackOrphanRecovery(attempts, 'task-1')
20
+ assert.deepEqual(third, { action: 'recover', attempt: 3, firstAttempt: false })
21
+ })
22
+
23
+ test('dead-letters once the attempt cap is exceeded', () => {
24
+ const attempts: Record<string, number> = { 'task-1': MAX_ORPHAN_RECOVERY_ATTEMPTS }
25
+
26
+ const decision = trackOrphanRecovery(attempts, 'task-1')
27
+ assert.deepEqual(decision, { action: 'dead_letter', attempt: MAX_ORPHAN_RECOVERY_ATTEMPTS + 1 })
28
+ })
29
+
30
+ test('tracks tasks independently', () => {
31
+ const attempts: Record<string, number> = {}
32
+ trackOrphanRecovery(attempts, 'task-1')
33
+ trackOrphanRecovery(attempts, 'task-1')
34
+ const other = trackOrphanRecovery(attempts, 'task-2')
35
+ assert.equal(other.action, 'recover')
36
+ assert.equal(other.attempt, 1)
37
+ })
38
+
39
+ test('prune drops counters for tasks no longer orphaned', () => {
40
+ const attempts: Record<string, number> = { 'task-1': 2, 'task-2': 1 }
41
+ pruneOrphanRecovery(attempts, new Set(['task-2']))
42
+ assert.deepEqual(attempts, { 'task-2': 1 })
43
+ })
44
+
45
+ test('honors a custom max', () => {
46
+ const attempts: Record<string, number> = {}
47
+ assert.equal(trackOrphanRecovery(attempts, 'task-1', 1).action, 'recover')
48
+ assert.equal(trackOrphanRecovery(attempts, 'task-1', 1).action, 'dead_letter')
49
+ })
@@ -0,0 +1,32 @@
1
+ export const MAX_ORPHAN_RECOVERY_ATTEMPTS = 3
2
+
3
+ export type OrphanRecoveryDecision =
4
+ | { action: 'recover'; attempt: number; firstAttempt: boolean }
5
+ | { action: 'dead_letter'; attempt: number }
6
+
7
+ /**
8
+ * Tracks how many times an orphaned queued task has been re-queued by the
9
+ * startup/daemon recovery scan. Recovery is allowed a bounded number of
10
+ * attempts; after that the task should be dead-lettered with one terminal
11
+ * reason instead of looping through recovery forever.
12
+ */
13
+ export function trackOrphanRecovery(
14
+ attempts: Record<string, number>,
15
+ taskId: string,
16
+ max: number = MAX_ORPHAN_RECOVERY_ATTEMPTS,
17
+ ): OrphanRecoveryDecision {
18
+ const attempt = (attempts[taskId] || 0) + 1
19
+ attempts[taskId] = attempt
20
+ if (attempt > max) return { action: 'dead_letter', attempt }
21
+ return { action: 'recover', attempt, firstAttempt: attempt === 1 }
22
+ }
23
+
24
+ /** Drops counters for tasks that are no longer orphaned so a future orphan starts fresh. */
25
+ export function pruneOrphanRecovery(
26
+ attempts: Record<string, number>,
27
+ stillOrphanedIds: ReadonlySet<string>,
28
+ ): void {
29
+ for (const taskId of Object.keys(attempts)) {
30
+ if (!stillOrphanedIds.has(taskId)) delete attempts[taskId]
31
+ }
32
+ }
@@ -0,0 +1,73 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ preflightProviderCredential,
6
+ type ProviderCredentialPreflightDeps,
7
+ } from './scheduled-run-preflight'
8
+
9
+ function makeDeps(overrides: Partial<ProviderCredentialPreflightDeps> = {}): ProviderCredentialPreflightDeps {
10
+ return {
11
+ getProvider: () => ({ requiresApiKey: true }),
12
+ resolveProviderCredentialId: (input) => input.credentialId || null,
13
+ resolveCredentialSecret: () => null,
14
+ ...overrides,
15
+ }
16
+ }
17
+
18
+ test('passes when the provider does not require an API key', () => {
19
+ const result = preflightProviderCredential(
20
+ { provider: 'ollama' },
21
+ makeDeps({ getProvider: () => ({ requiresApiKey: false }) }),
22
+ )
23
+ assert.deepEqual(result, { ok: true })
24
+ })
25
+
26
+ test('passes when the provider is unknown', () => {
27
+ const result = preflightProviderCredential(
28
+ { provider: 'mystery' },
29
+ makeDeps({ getProvider: () => null }),
30
+ )
31
+ assert.deepEqual(result, { ok: true })
32
+ })
33
+
34
+ test('passes when no provider is set', () => {
35
+ assert.deepEqual(preflightProviderCredential({ provider: '' }, makeDeps()), { ok: true })
36
+ })
37
+
38
+ test('passes when the resolved credential has a secret', () => {
39
+ const result = preflightProviderCredential(
40
+ { provider: 'openai', credentialId: 'cred-1' },
41
+ makeDeps({ resolveCredentialSecret: (id) => (id === 'cred-1' ? 'sk-test' : null) }),
42
+ )
43
+ assert.deepEqual(result, { ok: true })
44
+ })
45
+
46
+ test('passes when a fallback credential rescues a dead primary', () => {
47
+ const result = preflightProviderCredential(
48
+ { provider: 'openai', credentialId: 'cred-dead', fallbackCredentialIds: ['cred-live'] },
49
+ makeDeps({ resolveCredentialSecret: (id) => (id === 'cred-live' ? 'sk-test' : null) }),
50
+ )
51
+ assert.deepEqual(result, { ok: true })
52
+ })
53
+
54
+ test('passes when auto-matching finds another credential for the provider', () => {
55
+ const result = preflightProviderCredential(
56
+ { provider: 'openai', credentialId: 'cred-dead' },
57
+ makeDeps({
58
+ resolveProviderCredentialId: (input) => (input.credentialId ? input.credentialId : 'cred-auto'),
59
+ resolveCredentialSecret: (id) => (id === 'cred-auto' ? 'sk-test' : null),
60
+ }),
61
+ )
62
+ assert.deepEqual(result, { ok: true })
63
+ })
64
+
65
+ test('fails with an actionable error naming the provider when nothing resolves', () => {
66
+ const result = preflightProviderCredential({ provider: 'openai', credentialId: 'cred-dead' }, makeDeps())
67
+ assert.equal(result.ok, false)
68
+ if (!result.ok) {
69
+ assert.match(result.error, /Provider authentication preflight failed/)
70
+ assert.match(result.error, /"openai"/)
71
+ assert.match(result.error, /Settings/)
72
+ }
73
+ })