@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 +24 -0
- package/package.json +2 -2
- package/src/lib/server/autonomy/supervisor-reflection.test.ts +10 -1
- package/src/lib/server/connectors/outbox.ts +22 -2
- package/src/lib/server/runtime/queue/core.ts +160 -18
- package/src/lib/server/runtime/queue/orphan-recovery.test.ts +49 -0
- package/src/lib/server/runtime/queue/orphan-recovery.ts +32 -0
- package/src/lib/server/runtime/scheduled-run-preflight.test.ts +73 -0
- package/src/lib/server/runtime/scheduled-run-preflight.ts +83 -0
- package/src/lib/server/schedules/schedule-lifecycle.test.ts +44 -0
- package/src/lib/server/schedules/schedule-lifecycle.ts +27 -0
- package/src/lib/server/storage-normalization.ts +13 -0
- package/src/lib/server/tasks/task-followups.test.ts +124 -41
- package/src/lib/server/tasks/task-followups.ts +28 -3
- package/src/lib/server/tasks/task-lifecycle.test.ts +25 -0
- package/src/lib/server/tasks/task-lifecycle.ts +6 -0
- package/src/lib/server/tasks/task-result.test.ts +25 -1
- package/src/lib/server/tasks/task-result.ts +22 -0
- package/src/lib/server/workspace-paths.test.ts +72 -0
- package/src/lib/server/workspace-paths.ts +60 -0
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|