@swarmclawai/swarmclaw 1.9.22 → 1.9.24

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,13 +151,13 @@ clawhub install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
153
153
 
154
- ## v1.9.17 Highlights
154
+ ## v1.9.24 Highlights
155
155
 
156
- Agent configuration history is now visible in the agent editor, so operators can review recent saved versions and restore prior settings without leaving the agent workflow.
156
+ Gateway lifecycle controls and Slack peer-agent collaboration are now safer for multi-agent operations.
157
157
 
158
- - **Agent sheet history.** Advanced agent settings list recent saved versions with timestamp, actor, and provider/model snapshot.
159
- - **One-click restore.** Restoring a prior version uses the existing config-version restore API, refreshes agent state, and closes the sheet to avoid stale form data.
160
- - **Regression coverage.** New tests cover config-version list/restore routes and UI summary formatting.
158
+ - **Gateway lifecycle controls.** Providers can activate, drain, cordon, or request restart for saved OpenClaw gateways.
159
+ - **Routing guardrails.** New OpenClaw work skips draining or cordoned gateway profiles, and Operations Pulse flags unavailable gateways.
160
+ - **Slack peer messages.** Peer bot messages now reach the existing connector policy gates while self-loop messages are still blocked.
161
161
 
162
162
  ## Hosted Deploys
163
163
 
@@ -409,6 +409,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
409
409
 
410
410
  ## Releases
411
411
 
412
+ ### v1.9.24 Highlights
413
+
414
+ Gateway lifecycle release: saved OpenClaw gateways now have explicit operator lifecycle controls, automatic routing avoids gateways that should not receive new work, and Slack peer-agent messages flow through the existing connector policy gates.
415
+
416
+ - **Gateway lifecycle controls.** Providers can activate, drain, cordon, and request restart for saved OpenClaw gateway profiles.
417
+ - **Routing guardrails.** OpenClaw route selection skips draining and cordoned profiles, including default, preferred, and pinned gateway paths.
418
+ - **Operations Pulse awareness.** Cordoned and draining gateways now appear as operator attention items before they surprise a handoff or release check.
419
+ - **Slack peer collaboration.** Slack peer-bot messages are no longer dropped before group policy, mention, and self-loop protections run.
420
+
421
+ ### v1.9.23 Highlights
422
+
423
+ Schedule reliability release: recurring work now repairs stale timing state before it can skip the nearest run, and scheduled board tasks keep mission context across repeat launches.
424
+
425
+ - **Cron drift repair.** Active cron schedules repair missing or invalid `nextRunAt` values and stale future cron slots before the scheduler decides whether work is due.
426
+ - **Tick-time advancement.** Cron and interval schedules now advance from the scheduler tick time instead of the process wall clock, making restart and catch-up behavior deterministic.
427
+ - **Stable stagger.** Schedule stagger offsets are deterministic per schedule, avoiding thundering-herd launches without moving a saved next-run target on every recompute.
428
+ - **Mission continuity.** Schedule-created board tasks attach to a persistent mission link, so recurring runs share the same operational context.
429
+
412
430
  ### v1.9.22 Highlights
413
431
 
414
432
  Research tools release: agents now get direct `web_extract` and `web_crawl` tools alongside `web_search`, `web_fetch`, and the unified `web` tool.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.22",
3
+ "version": "1.9.24",
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/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/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/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/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/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-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/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/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/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/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/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/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/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/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",
@@ -0,0 +1,22 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ import { controlGatewayProfile } from '@/lib/server/gateways/gateway-profile-service'
4
+ import { notFound } from '@/lib/server/collection-helpers'
5
+ import { GatewayControlSchema, formatZodError } from '@/lib/validation/schemas'
6
+
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params
11
+ const raw = await req.json().catch(() => null)
12
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
13
+ return NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 })
14
+ }
15
+
16
+ const parsed = GatewayControlSchema.safeParse(raw)
17
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
18
+
19
+ const result = controlGatewayProfile(id, parsed.data)
20
+ if (!result) return notFound()
21
+ return NextResponse.json(result)
22
+ }
@@ -0,0 +1,86 @@
1
+ import assert from 'node:assert/strict'
2
+ import { afterEach, test } from 'node:test'
3
+
4
+ import { POST } from '@/app/api/gateways/[id]/control/route'
5
+ import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
6
+
7
+ const originalGateways = loadGatewayProfiles()
8
+
9
+ afterEach(() => {
10
+ saveGatewayProfiles(originalGateways)
11
+ })
12
+
13
+ function saveTestGateway(id = 'gateway-control-test') {
14
+ saveGatewayProfiles({
15
+ ...loadGatewayProfiles(),
16
+ [id]: {
17
+ id,
18
+ name: 'Gateway Control Test',
19
+ provider: 'openclaw',
20
+ endpoint: 'http://127.0.0.1:18789/v1',
21
+ wsUrl: 'ws://127.0.0.1:18789',
22
+ credentialId: null,
23
+ status: 'healthy',
24
+ lifecycleState: 'active',
25
+ notes: null,
26
+ tags: [],
27
+ lastError: null,
28
+ lastCheckedAt: null,
29
+ lastModelCount: null,
30
+ discoveredHost: null,
31
+ discoveredPort: null,
32
+ deployment: null,
33
+ stats: null,
34
+ isDefault: false,
35
+ createdAt: 1,
36
+ updatedAt: 1,
37
+ },
38
+ })
39
+ }
40
+
41
+ test('gateway control route drains a gateway profile', async () => {
42
+ saveTestGateway()
43
+
44
+ const response = await POST(new Request('http://local/api/gateways/gateway-control-test/control', {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({ action: 'drain', reason: 'maintenance' }),
48
+ }), { params: Promise.resolve({ id: 'gateway-control-test' }) })
49
+
50
+ assert.equal(response.status, 200)
51
+ const payload = await response.json()
52
+ assert.equal(payload.lifecycleState, 'draining')
53
+ assert.equal(payload.lastControlAction, 'drain')
54
+ assert.equal(payload.lastControlReason, 'maintenance')
55
+ assert.equal(payload.controlRequest, null)
56
+ })
57
+
58
+ test('gateway control route records restart requests without changing lifecycle', async () => {
59
+ saveTestGateway()
60
+
61
+ const response = await POST(new Request('http://local/api/gateways/gateway-control-test/control', {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify({ action: 'restart' }),
65
+ }), { params: Promise.resolve({ id: 'gateway-control-test' }) })
66
+
67
+ assert.equal(response.status, 200)
68
+ const payload = await response.json()
69
+ assert.equal(payload.lifecycleState, 'active')
70
+ assert.equal(payload.lastControlAction, 'restart')
71
+ assert.equal(payload.controlRequest?.action, 'restart')
72
+ assert.equal(payload.controlRequest?.source, 'swarmclaw')
73
+ assert.equal(typeof payload.controlRequest?.requestedAt, 'number')
74
+ })
75
+
76
+ test('gateway control route validates control actions', async () => {
77
+ saveTestGateway()
78
+
79
+ const response = await POST(new Request('http://local/api/gateways/gateway-control-test/control', {
80
+ method: 'POST',
81
+ headers: { 'content-type': 'application/json' },
82
+ body: JSON.stringify({ action: 'pause' }),
83
+ }), { params: Promise.resolve({ id: 'gateway-control-test' }) })
84
+
85
+ assert.equal(response.status, 400)
86
+ })
@@ -13,6 +13,7 @@ import {
13
13
  import { useCredentialsQuery, useCreateCredentialMutation } from '@/features/credentials/queries'
14
14
  import {
15
15
  useCloneGatewayProfileMutation,
16
+ useGatewayControlMutation,
16
17
  useDeleteGatewayProfileMutation,
17
18
  useGatewayFleetTopologyQuery,
18
19
  useGatewayHealthCheckMutation,
@@ -22,7 +23,7 @@ import {
22
23
  useVerifyOpenClawDeployMutation,
23
24
  } from '@/features/gateways/queries'
24
25
  import { useExternalAgentsQuery, useExternalAgentRuntimeMutation } from '@/features/external-agents/queries'
25
- import type { GatewayProfile } from '@/types'
26
+ import type { GatewayControlAction, GatewayLifecycleState, GatewayProfile } from '@/types'
26
27
  import { dedup } from '@/lib/shared-utils'
27
28
  import { PageLoader } from '@/components/ui/page-loader'
28
29
  import { StatusDot } from '@/components/ui/status-dot'
@@ -45,6 +46,18 @@ function formatRuntimeTimestamp(value: number | null | undefined): string {
45
46
  }).format(value)
46
47
  }
47
48
 
49
+ function gatewayLifecycleLabel(value: GatewayLifecycleState | null | undefined): string {
50
+ if (value === 'draining') return 'Draining'
51
+ if (value === 'cordoned') return 'Cordoned'
52
+ return 'Active'
53
+ }
54
+
55
+ function gatewayLifecycleBadgeClass(value: GatewayLifecycleState | null | undefined): string {
56
+ if (value === 'draining') return 'border-amber-400/20 bg-amber-400/[0.08] text-amber-200'
57
+ if (value === 'cordoned') return 'border-rose-400/20 bg-rose-400/[0.08] text-rose-200'
58
+ return 'border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-200'
59
+ }
60
+
48
61
  export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
49
62
  const setProviderSheetOpen = useAppStore((s) => s.setProviderSheetOpen)
50
63
  const setEditingProviderId = useAppStore((s) => s.setEditingProviderId)
@@ -63,6 +76,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
63
76
  const saveGatewayMutation = useSaveGatewayProfileMutation()
64
77
  const deleteGatewayMutation = useDeleteGatewayProfileMutation()
65
78
  const healthCheckGatewayMutation = useGatewayHealthCheckMutation()
79
+ const gatewayControlMutation = useGatewayControlMutation()
66
80
  const refreshGatewayTopologyMutation = useRefreshGatewayTopologyMutation()
67
81
  const verifyDeployMutation = useVerifyOpenClawDeployMutation()
68
82
  const cloneGatewayMutation = useCloneGatewayProfileMutation()
@@ -211,6 +225,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
211
225
  credentialId: gateway.credentialId || null,
212
226
  notes: gateway.notes || null,
213
227
  tags: gateway.tags || [],
228
+ lifecycleState: 'active',
214
229
  deployment: gateway.deployment || null,
215
230
  stats: gateway.stats || null,
216
231
  isDefault: false,
@@ -221,6 +236,27 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
221
236
  }
222
237
  }
223
238
 
239
+ const handleGatewayControl = async (
240
+ e: React.MouseEvent,
241
+ gatewayId: string,
242
+ action: GatewayControlAction,
243
+ ) => {
244
+ e.stopPropagation()
245
+ try {
246
+ await gatewayControlMutation.mutateAsync({ id: gatewayId, action })
247
+ const actionLabel = action === 'activate'
248
+ ? 'Gateway activated'
249
+ : action === 'drain'
250
+ ? 'Gateway draining'
251
+ : action === 'cordon'
252
+ ? 'Gateway cordoned'
253
+ : 'Restart requested'
254
+ toast.success(actionLabel)
255
+ } catch (err: unknown) {
256
+ toast.error(err instanceof Error ? err.message : 'Gateway control failed')
257
+ }
258
+ }
259
+
224
260
  const handleRuntimeAction = async (
225
261
  e: React.MouseEvent,
226
262
  runtimeId: string,
@@ -537,6 +573,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
537
573
  const deployment = gateway.deployment || null
538
574
  const topology = topologyByGatewayId.get(gateway.id) || null
539
575
  const stats = topology?.stats || gateway.stats || null
576
+ const lifecycleState = gateway.lifecycleState || 'active'
540
577
  const topologyErrors = topology?.errors || []
541
578
  const pendingPairings = (stats?.pendingNodePairings || 0) + (stats?.pendingDevicePairings || 0)
542
579
  const topologyErrorCount = topologyErrors.length || stats?.lastTopologyErrorCount || 0
@@ -569,6 +606,9 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
569
606
  {gateway.isDefault && (
570
607
  <span className="text-[10px] font-700 px-2 py-0.5 rounded-[5px] bg-accent-bright/10 text-accent-bright uppercase tracking-wider">Default</span>
571
608
  )}
609
+ <span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] border uppercase tracking-wider ${gatewayLifecycleBadgeClass(lifecycleState)}`}>
610
+ {gatewayLifecycleLabel(lifecycleState)}
611
+ </span>
572
612
  <StatusDot
573
613
  status={
574
614
  gateway.status === 'healthy'
@@ -666,7 +706,40 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
666
706
  </div>
667
707
  )}
668
708
  {!inSidebar && (
669
- <div className="mt-3 flex items-center gap-2">
709
+ <div className="mt-3 flex flex-wrap items-center gap-2">
710
+ {lifecycleState !== 'active' ? (
711
+ <button
712
+ onClick={(e) => void handleGatewayControl(e, gateway.id, 'activate')}
713
+ disabled={gatewayControlMutation.isPending}
714
+ className="px-2.5 py-1.5 rounded-[8px] border border-emerald-400/20 bg-emerald-400/[0.06] text-[11px] font-700 text-emerald-200 hover:bg-emerald-400/[0.1] cursor-pointer transition-all disabled:opacity-40"
715
+ >
716
+ Activate
717
+ </button>
718
+ ) : (
719
+ <>
720
+ <button
721
+ onClick={(e) => void handleGatewayControl(e, gateway.id, 'drain')}
722
+ disabled={gatewayControlMutation.isPending}
723
+ className="px-2.5 py-1.5 rounded-[8px] border border-amber-400/20 bg-amber-400/[0.06] text-[11px] font-700 text-amber-200 hover:bg-amber-400/[0.1] cursor-pointer transition-all disabled:opacity-40"
724
+ >
725
+ Drain
726
+ </button>
727
+ <button
728
+ onClick={(e) => void handleGatewayControl(e, gateway.id, 'cordon')}
729
+ disabled={gatewayControlMutation.isPending}
730
+ className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all disabled:opacity-40"
731
+ >
732
+ Cordon
733
+ </button>
734
+ </>
735
+ )}
736
+ <button
737
+ onClick={(e) => void handleGatewayControl(e, gateway.id, 'restart')}
738
+ disabled={gatewayControlMutation.isPending}
739
+ className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all disabled:opacity-40"
740
+ >
741
+ Restart
742
+ </button>
670
743
  <button onClick={(e) => void handleHealthCheckGateway(e, gateway.id)} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
671
744
  Health
672
745
  </button>
@@ -162,6 +162,8 @@ function historyActionLabel(action: ScheduleHistoryEntry['action']): string {
162
162
  return 'Skipped'
163
163
  case 'failed':
164
164
  return 'Failed'
165
+ case 'repaired':
166
+ return 'Repaired'
165
167
  default:
166
168
  return action
167
169
  }
@@ -171,6 +173,7 @@ function historyActionBadge(action: ScheduleHistoryEntry['action']): string {
171
173
  if (action === 'created' || action === 'restored' || action === 'run_started') return badgeClass('completed')
172
174
  if (action === 'failed') return badgeClass('failed')
173
175
  if (action === 'skipped' || action === 'archived') return badgeClass('paused')
176
+ if (action === 'repaired') return badgeClass('running')
174
177
  return badgeClass('running')
175
178
  }
176
179
 
@@ -38,6 +38,12 @@ interface SaveGatewayProfileInput {
38
38
  payload: Record<string, unknown>
39
39
  }
40
40
 
41
+ interface GatewayControlInput {
42
+ id: string
43
+ action: 'activate' | 'drain' | 'cordon' | 'restart'
44
+ reason?: string | null
45
+ }
46
+
41
47
  interface VerifyOpenClawDeployInput {
42
48
  endpoint: string
43
49
  token?: string
@@ -130,6 +136,17 @@ export function useGatewayHealthCheckMutation() {
130
136
  })
131
137
  }
132
138
 
139
+ export function useGatewayControlMutation() {
140
+ const queryClient = useQueryClient()
141
+ return useMutation({
142
+ mutationFn: ({ id, action, reason }: GatewayControlInput) =>
143
+ api('POST', `/gateways/${id}/control`, { action, reason }),
144
+ onSuccess: async () => {
145
+ await invalidateGatewayQueries(queryClient)
146
+ },
147
+ })
148
+ }
149
+
133
150
  export function useGatewayFleetTopologyQuery(options: QueryOptions = {}) {
134
151
  return useQuery<OpenClawGatewayFleetTopology>({
135
152
  queryKey: gatewayQueryKeys.fleet(),
@@ -17,6 +17,11 @@ function makeGateway(overrides: Partial<GatewayProfile> = {}): GatewayProfile {
17
17
  wsUrl: 'wss://gateway.example.com',
18
18
  credentialId: 'cred-gateway',
19
19
  status: 'healthy',
20
+ lifecycleState: 'active',
21
+ lastControlAction: null,
22
+ lastControlActionAt: null,
23
+ lastControlReason: null,
24
+ controlRequest: null,
20
25
  tags: [],
21
26
  isDefault: true,
22
27
  createdAt: now,
@@ -62,6 +67,81 @@ test('resolveAgentRouteCandidatesWithProfiles applies the default OpenClaw gatew
62
67
  assert.equal(route.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://gateway.example.com/v1'))
63
68
  })
64
69
 
70
+ test('resolveAgentRouteCandidatesWithProfiles routes around draining default gateways', () => {
71
+ const gateways = [
72
+ makeGateway({
73
+ lifecycleState: 'draining',
74
+ }),
75
+ makeGateway({
76
+ id: 'gateway-secondary',
77
+ name: 'Gateway Secondary',
78
+ endpoint: 'https://secondary.example.com/v1',
79
+ wsUrl: 'wss://secondary.example.com',
80
+ credentialId: 'cred-secondary',
81
+ isDefault: false,
82
+ }),
83
+ ]
84
+
85
+ const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent(), gateways)
86
+ assert.ok(route)
87
+ assert.equal(route.gatewayProfileId, 'gateway-secondary')
88
+ assert.equal(route.credentialId, 'cred-secondary')
89
+ assert.equal(route.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://secondary.example.com/v1'))
90
+ })
91
+
92
+ test('resolveAgentRouteCandidatesWithProfiles ignores cordoned preferred gateways', () => {
93
+ const gateways = [
94
+ makeGateway({
95
+ id: 'gateway-specialized',
96
+ name: 'Specialized Gateway',
97
+ tags: ['browser'],
98
+ lifecycleState: 'cordoned',
99
+ }),
100
+ makeGateway({
101
+ id: 'gateway-active',
102
+ name: 'Active Gateway',
103
+ endpoint: 'https://active.example.com/v1',
104
+ wsUrl: 'wss://active.example.com',
105
+ credentialId: 'cred-active',
106
+ tags: [],
107
+ isDefault: false,
108
+ }),
109
+ ]
110
+
111
+ const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent(), gateways, undefined, undefined, {
112
+ preferredGatewayTags: ['browser'],
113
+ })
114
+ assert.ok(route)
115
+ assert.equal(route.gatewayProfileId, 'gateway-active')
116
+ })
117
+
118
+ test('resolveAgentRouteCandidatesWithProfiles drops OpenClaw routes when every gateway is cordoned', () => {
119
+ const routes = resolveAgentRouteCandidatesWithProfiles(makeAgent({
120
+ gatewayProfileId: 'gateway-default',
121
+ }), [
122
+ makeGateway({
123
+ lifecycleState: 'cordoned',
124
+ }),
125
+ ])
126
+
127
+ assert.equal(routes.length, 0)
128
+ })
129
+
130
+ test('resolveAgentRouteCandidatesWithProfiles keeps explicit direct OpenClaw routes while saved gateways are cordoned', () => {
131
+ const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent({
132
+ gatewayProfileId: null,
133
+ apiEndpoint: 'https://direct.example.com/v1',
134
+ }), [
135
+ makeGateway({
136
+ lifecycleState: 'cordoned',
137
+ }),
138
+ ])
139
+
140
+ assert.ok(route)
141
+ assert.equal(route.gatewayProfileId, null)
142
+ assert.equal(route.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://direct.example.com/v1'))
143
+ })
144
+
65
145
  test('resolveAgentRouteCandidatesWithProfiles respects routing strategy but deprioritizes cooling providers', () => {
66
146
  const gateways = [
67
147
  makeGateway({
@@ -65,6 +65,29 @@ function normalizeNullableNumber(value: unknown): number | null {
65
65
  return typeof value === 'number' && Number.isFinite(value) ? value : null
66
66
  }
67
67
 
68
+ function normalizeGatewayLifecycleState(value: unknown): NonNullable<GatewayProfile['lifecycleState']> {
69
+ return value === 'draining' || value === 'cordoned' ? value : 'active'
70
+ }
71
+
72
+ function normalizeGatewayControlAction(value: unknown): GatewayProfile['lastControlAction'] {
73
+ return value === 'activate' || value === 'drain' || value === 'cordon' || value === 'restart'
74
+ ? value
75
+ : null
76
+ }
77
+
78
+ function normalizeGatewayControlRequest(value: unknown): GatewayProfile['controlRequest'] {
79
+ if (!value || typeof value !== 'object') return null
80
+ const request = value as Record<string, unknown>
81
+ const requestedAt = normalizeNullableNumber(request.requestedAt)
82
+ if (request.action !== 'restart' || !requestedAt) return null
83
+ return {
84
+ action: 'restart',
85
+ requestedAt,
86
+ source: 'swarmclaw',
87
+ reason: normalizeText(request.reason),
88
+ }
89
+ }
90
+
68
91
  function normalizeGatewayDeployment(
69
92
  value: unknown,
70
93
  ): GatewayProfile['deployment'] {
@@ -109,6 +132,13 @@ function normalizeGatewayStats(value: unknown): GatewayProfile['stats'] {
109
132
  pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
110
133
  pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
111
134
  externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
135
+ sessionCount: normalizeNullableNumber(stats.sessionCount) ?? undefined,
136
+ presenceCount: normalizeNullableNumber(stats.presenceCount) ?? undefined,
137
+ environmentCount: normalizeNullableNumber(stats.environmentCount) ?? undefined,
138
+ availableEnvironmentCount: normalizeNullableNumber(stats.availableEnvironmentCount) ?? undefined,
139
+ lastTopologyCheckedAt: normalizeNullableNumber(stats.lastTopologyCheckedAt) ?? undefined,
140
+ lastTopologyErrorCount: normalizeNullableNumber(stats.lastTopologyErrorCount) ?? undefined,
141
+ lastTopologyError: normalizeText(stats.lastTopologyError),
112
142
  }
113
143
  }
114
144
 
@@ -139,6 +169,11 @@ function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
139
169
  wsUrl: typeof gateway.wsUrl === 'string' && gateway.wsUrl.trim() ? gateway.wsUrl.trim() : deriveOpenClawWsUrl(endpoint),
140
170
  credentialId: typeof gateway.credentialId === 'string' && gateway.credentialId.trim() ? gateway.credentialId.trim() : null,
141
171
  status: gateway.status === 'healthy' || gateway.status === 'degraded' || gateway.status === 'offline' || gateway.status === 'pending' ? gateway.status : 'unknown',
172
+ lifecycleState: normalizeGatewayLifecycleState(gateway.lifecycleState),
173
+ lastControlAction: normalizeGatewayControlAction(gateway.lastControlAction),
174
+ lastControlActionAt: normalizeNullableNumber(gateway.lastControlActionAt),
175
+ lastControlReason: normalizeText(gateway.lastControlReason),
176
+ controlRequest: normalizeGatewayControlRequest(gateway.controlRequest),
142
177
  notes: typeof gateway.notes === 'string' ? gateway.notes : null,
143
178
  tags: ensureStringArray(gateway.tags),
144
179
  lastError: typeof gateway.lastError === 'string' ? gateway.lastError : null,
@@ -163,6 +198,18 @@ function findGatewayProfile(
163
198
  return gatewayProfiles.find((profile) => profile.id === id) || null
164
199
  }
165
200
 
201
+ export function isGatewayAcceptingNewWork(gatewayProfile: GatewayProfile | null | undefined): boolean {
202
+ return normalizeGatewayLifecycleState(gatewayProfile?.lifecycleState) === 'active'
203
+ }
204
+
205
+ function findAcceptingGatewayProfile(
206
+ gatewayProfiles: GatewayProfile[],
207
+ profileId?: string | null,
208
+ ): GatewayProfile | null {
209
+ const gatewayProfile = findGatewayProfile(gatewayProfiles, profileId)
210
+ return isGatewayAcceptingNewWork(gatewayProfile) ? gatewayProfile : null
211
+ }
212
+
166
213
  export function getGatewayProfiles(provider: GatewayProfile['provider'] | null = null): GatewayProfile[] {
167
214
  const all = loadGatewayProfiles()
168
215
  return Object.entries(all)
@@ -180,13 +227,15 @@ export function getGatewayProfile(profileId?: string | null): GatewayProfile | n
180
227
  }
181
228
 
182
229
  function defaultGatewayProfile(gatewayProfiles: GatewayProfile[]): GatewayProfile | null {
183
- return gatewayProfiles.find((profile) => profile.isDefault) || gatewayProfiles[0] || null
230
+ const accepting = gatewayProfiles.filter(isGatewayAcceptingNewWork)
231
+ return accepting.find((profile) => profile.isDefault) || accepting[0] || null
184
232
  }
185
233
 
186
234
  function gatewayPreferenceScore(
187
235
  gatewayProfile: GatewayProfile,
188
236
  preferences?: GatewayRoutePreferences | null,
189
237
  ): number {
238
+ if (!isGatewayAcceptingNewWork(gatewayProfile)) return -1
190
239
  const normalized = normalizeRoutePreferences(preferences)
191
240
  const preferredTags = normalized.preferredGatewayTags || []
192
241
  const preferredUseCase = normalized.preferredGatewayUseCase || null
@@ -263,13 +312,15 @@ function buildRouteFromSeed(
263
312
  preferredGatewayTags: seed.preferredGatewayTags ?? routePreferences?.preferredGatewayTags,
264
313
  preferredGatewayUseCase: seed.preferredGatewayUseCase ?? routePreferences?.preferredGatewayUseCase,
265
314
  })
266
- let gatewayProfile = findGatewayProfile(gatewayProfiles, seed.gatewayProfileId ?? null)
315
+ let gatewayProfile = findAcceptingGatewayProfile(gatewayProfiles, seed.gatewayProfileId ?? null)
267
316
  if (!gatewayProfile && provider === 'openclaw') {
268
317
  gatewayProfile = pickPreferredGatewayProfile(gatewayProfiles, mergedPreferences)
269
- || findGatewayProfile(gatewayProfiles, agentGatewayProfileId ?? null)
318
+ || findAcceptingGatewayProfile(gatewayProfiles, agentGatewayProfileId ?? null)
270
319
  || defaultGatewayProfile(gatewayProfiles)
271
320
  }
272
- const gatewayProfileId = gatewayProfile?.id ?? seed.gatewayProfileId ?? agentGatewayProfileId ?? null
321
+ const hasExplicitDirectEndpoint = typeof seed.apiEndpoint === 'string' && seed.apiEndpoint.trim().length > 0
322
+ if (provider === 'openclaw' && gatewayProfiles.length > 0 && !gatewayProfile && !hasExplicitDirectEndpoint) return null
323
+ const gatewayProfileId = gatewayProfile?.id ?? null
273
324
 
274
325
  const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
275
326
  const model = (seed.model || '').trim() || (providerFromGateway === 'openclaw' ? DEFAULT_OPENCLAW_MODEL : '')
@@ -0,0 +1,30 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ import { shouldAcceptSlackMessageEvent } from './slack'
5
+
6
+ test('shouldAcceptSlackMessageEvent accepts peer bot text messages', () => {
7
+ assert.equal(shouldAcceptSlackMessageEvent({
8
+ type: 'message',
9
+ text: 'Marian, can you review this?',
10
+ user: 'U_PEER_AGENT',
11
+ bot_id: 'B_PEER_AGENT',
12
+ }, 'U_SELF_AGENT'), true)
13
+ })
14
+
15
+ test('shouldAcceptSlackMessageEvent still rejects self-loop messages', () => {
16
+ assert.equal(shouldAcceptSlackMessageEvent({
17
+ type: 'message',
18
+ text: 'Loopback',
19
+ user: 'U_SELF_AGENT',
20
+ bot_id: 'B_SELF_AGENT',
21
+ }, 'U_SELF_AGENT'), false)
22
+ })
23
+
24
+ test('shouldAcceptSlackMessageEvent rejects non-text message events', () => {
25
+ assert.equal(shouldAcceptSlackMessageEvent({
26
+ type: 'message',
27
+ subtype: 'channel_join',
28
+ user: 'U_PEER_AGENT',
29
+ }, 'U_SELF_AGENT'), false)
30
+ })
@@ -106,6 +106,12 @@ async function hydrateSlackThreadContext(params: {
106
106
  }
107
107
  }
108
108
 
109
+ export function shouldAcceptSlackMessageEvent(message: unknown, botUserId?: string): boolean {
110
+ if (!message || typeof message !== 'object' || !('text' in message)) return false
111
+ const msg = message as { user?: unknown }
112
+ return !(botUserId && typeof msg.user === 'string' && msg.user === botUserId)
113
+ }
114
+
109
115
  const slack: PlatformConnector = {
110
116
  async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
111
117
  const appToken = connector.config.appToken || ''
@@ -173,10 +179,9 @@ const slack: PlatformConnector = {
173
179
 
174
180
  // Handle messages
175
181
  app.message(async ({ message, say, client }) => {
176
- // Only handle user messages (not bot messages or own messages)
177
- if (!('text' in message) || ('bot_id' in message)) return
182
+ // Bot-to-bot collaboration still flows through the normal connector policy gates.
183
+ if (!shouldAcceptSlackMessageEvent(message, botUserId)) return
178
184
  const msg = message as any
179
- if (botUserId && msg.user === botUserId) return
180
185
 
181
186
  const channelId = msg.channel
182
187
  if (allowedChannels && !allowedChannels.includes(channelId)) return