@swarmclawai/swarmclaw 1.9.23 → 1.9.25

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,14 @@ clawhub install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
153
153
 
154
- ## v1.9.23 Highlights
154
+ ## v1.9.25 Highlights
155
155
 
156
- Schedule reliability is now more deterministic for recurring autonomous work, especially after restarts or stale stored timing state.
156
+ Gateway lifecycle controls and Slack peer-agent collaboration are now safer for multi-agent operations.
157
157
 
158
- - **Cron drift repair.** Active schedules repair stale future cron slots before they skip the nearest run.
159
- - **Stable stagger.** Staggered schedules keep a deterministic per-schedule offset.
160
- - **Mission continuity.** Schedule-created board tasks keep a persistent mission link across recurring runs.
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
+ - **CLI lifecycle access.** `swarmclaw gateways activate`, `drain`, `cordon`, and `restart` now reach the same lifecycle control endpoint as the provider UI.
161
+ - **Slack peer messages.** Peer bot messages now reach the existing connector policy gates while self-loop messages are still blocked.
161
162
 
162
163
  ## Hosted Deploys
163
164
 
@@ -409,6 +410,16 @@ Operational docs: https://swarmclaw.ai/docs/observability
409
410
 
410
411
  ## Releases
411
412
 
413
+ ### v1.9.25 Highlights
414
+
415
+ 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.
416
+
417
+ - **Gateway lifecycle controls.** Providers can activate, drain, cordon, and request restart for saved OpenClaw gateway profiles.
418
+ - **Routing guardrails.** OpenClaw route selection skips draining and cordoned profiles, including default, preferred, and pinned gateway paths.
419
+ - **Operations Pulse awareness.** Cordoned and draining gateways now appear as operator attention items before they surprise a handoff or release check.
420
+ - **CLI lifecycle access.** `swarmclaw gateways activate`, `drain`, `cordon`, and `restart` now post the matching lifecycle action for automation and release scripts.
421
+ - **Slack peer collaboration.** Slack peer-bot messages are no longer dropped before group policy, mention, and self-loop protections run.
422
+
412
423
  ### v1.9.23 Highlights
413
424
 
414
425
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.23",
3
+ "version": "1.9.25",
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",
@@ -39,7 +39,7 @@
39
39
  "node": ">=22.6.0"
40
40
  },
41
41
  "bin": {
42
- "swarmclaw": "./bin/swarmclaw.js"
42
+ "swarmclaw": "bin/swarmclaw.js"
43
43
  },
44
44
  "files": [
45
45
  "bin/",
@@ -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-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/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
+ })
package/src/cli/index.js CHANGED
@@ -278,6 +278,23 @@ const COMMAND_GROUPS = [
278
278
  cmd('create', 'POST', '/gateways', 'Create a gateway profile', { expectsJsonBody: true }),
279
279
  cmd('update', 'PUT', '/gateways/:id', 'Update a gateway profile', { expectsJsonBody: true }),
280
280
  cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
281
+ cmd('control', 'POST', '/gateways/:id/control', 'Run a gateway lifecycle control action', { expectsJsonBody: true }),
282
+ cmd('activate', 'POST', '/gateways/:id/control', 'Return a gateway to active routing', {
283
+ expectsJsonBody: true,
284
+ defaultBody: { action: 'activate' },
285
+ }),
286
+ cmd('drain', 'POST', '/gateways/:id/control', 'Drain a gateway from new automatic work', {
287
+ expectsJsonBody: true,
288
+ defaultBody: { action: 'drain' },
289
+ }),
290
+ cmd('cordon', 'POST', '/gateways/:id/control', 'Cordon a gateway from automatic work', {
291
+ expectsJsonBody: true,
292
+ defaultBody: { action: 'cordon' },
293
+ }),
294
+ cmd('restart', 'POST', '/gateways/:id/control', 'Request a gateway restart', {
295
+ expectsJsonBody: true,
296
+ defaultBody: { action: 'restart' },
297
+ }),
281
298
  cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
282
299
  cmd('topology', 'GET', '/gateways/:id/topology', 'Refresh and return one gateway topology snapshot'),
283
300
  cmd('environments', 'GET', '/gateways/:id/environments', 'List OpenClaw gateway execution environments'),
@@ -225,6 +225,36 @@ test('tasks execution-policy-decision posts policy decisions', async () => {
225
225
  assert.equal(stderr.toString(), '')
226
226
  })
227
227
 
228
+ test('gateways drain command posts a lifecycle control action', async () => {
229
+ const stdout = makeWritable()
230
+ const stderr = makeWritable()
231
+ const calls = []
232
+
233
+ const fetchImpl = async (url, init) => {
234
+ calls.push({ url: String(url), init })
235
+ return jsonResponse({ ok: true })
236
+ }
237
+
238
+ const exitCode = await runCli(
239
+ ['gateways', 'drain', 'gateway-1', '--json'],
240
+ {
241
+ fetchImpl,
242
+ stdout,
243
+ stderr,
244
+ env: {
245
+ SWARMCLAW_API_KEY: 'test-key',
246
+ },
247
+ cwd: process.cwd(),
248
+ }
249
+ )
250
+
251
+ assert.equal(exitCode, 0)
252
+ assert.equal(calls.length, 1)
253
+ assert.match(calls[0].url, /\/api\/gateways\/gateway-1\/control$/)
254
+ assert.equal(calls[0].init.method, 'POST')
255
+ assert.deepEqual(JSON.parse(calls[0].init.body), { action: 'drain' })
256
+ })
257
+
228
258
  test('openclaw deploy bundle command merges action with provided JSON body', async () => {
229
259
  const stdout = makeWritable()
230
260
  const stderr = makeWritable()
package/src/cli/spec.js CHANGED
@@ -219,6 +219,11 @@ const COMMAND_GROUPS = {
219
219
  create: { description: 'Create a gateway profile', method: 'POST', path: '/gateways' },
220
220
  update: { description: 'Update a gateway profile', method: 'PUT', path: '/gateways/:id', params: ['id'] },
221
221
  delete: { description: 'Delete a gateway profile', method: 'DELETE', path: '/gateways/:id', params: ['id'] },
222
+ control: { description: 'Run a gateway lifecycle control action', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
223
+ activate: { description: 'Return a gateway to active routing', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
224
+ drain: { description: 'Drain a gateway from new automatic work', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
225
+ cordon: { description: 'Cordon a gateway from automatic work', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
226
+ restart: { description: 'Request a gateway restart', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
222
227
  health: { description: 'Run a gateway health check', method: 'GET', path: '/gateways/:id/health', params: ['id'] },
223
228
  topology: { description: 'Refresh and return one gateway topology snapshot', method: 'GET', path: '/gateways/:id/topology', params: ['id'] },
224
229
  environments: { description: 'List OpenClaw gateway execution environments', method: 'GET', path: '/gateways/:id/environments', params: ['id'] },
@@ -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>
@@ -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
@@ -27,6 +27,29 @@ function normalizeNullableNumber(value: unknown): number | null {
27
27
  return typeof value === 'number' && Number.isFinite(value) ? value : null
28
28
  }
29
29
 
30
+ function normalizeLifecycleState(value: unknown): NonNullable<GatewayProfile['lifecycleState']> {
31
+ return value === 'draining' || value === 'cordoned' ? value : 'active'
32
+ }
33
+
34
+ function normalizeControlAction(value: unknown): GatewayProfile['lastControlAction'] {
35
+ return value === 'activate' || value === 'drain' || value === 'cordon' || value === 'restart'
36
+ ? value
37
+ : null
38
+ }
39
+
40
+ function normalizeControlRequest(value: unknown): GatewayProfile['controlRequest'] {
41
+ if (!value || typeof value !== 'object') return null
42
+ const request = value as Record<string, unknown>
43
+ const requestedAt = normalizeNullableNumber(request.requestedAt)
44
+ if (request.action !== 'restart' || !requestedAt) return null
45
+ return {
46
+ action: 'restart',
47
+ requestedAt,
48
+ source: 'swarmclaw',
49
+ reason: normalizeText(request.reason),
50
+ }
51
+ }
52
+
30
53
  function normalizeDeployment(value: unknown): OpenClawDeploymentConfig | null {
31
54
  if (!value || typeof value !== 'object') return null
32
55
  const deployment = value as Record<string, unknown>
@@ -106,6 +129,11 @@ export function createGatewayProfile(input: Record<string, unknown>): GatewayPro
106
129
  wsUrl: normalizeText(input.wsUrl),
107
130
  credentialId: normalizeText(input.credentialId),
108
131
  status: typeof input.status === 'string' && input.status.trim() ? input.status as GatewayProfile['status'] : 'unknown',
132
+ lifecycleState: normalizeLifecycleState(input.lifecycleState),
133
+ lastControlAction: normalizeControlAction(input.lastControlAction),
134
+ lastControlActionAt: normalizeNullableNumber(input.lastControlActionAt),
135
+ lastControlReason: normalizeText(input.lastControlReason),
136
+ controlRequest: normalizeControlRequest(input.controlRequest),
109
137
  notes: typeof input.notes === 'string' ? input.notes : null,
110
138
  tags: normalizeTags(input.tags),
111
139
  lastError: null,
@@ -149,6 +177,11 @@ export function updateGatewayProfile(id: string, input: Record<string, unknown>)
149
177
  : 'unknown'
150
178
  gateway.status = nextStatus
151
179
  }
180
+ if (input.lifecycleState !== undefined) gateway.lifecycleState = normalizeLifecycleState(input.lifecycleState)
181
+ if (input.lastControlAction !== undefined) gateway.lastControlAction = normalizeControlAction(input.lastControlAction)
182
+ if (input.lastControlActionAt !== undefined) gateway.lastControlActionAt = normalizeNullableNumber(input.lastControlActionAt)
183
+ if (input.lastControlReason !== undefined) gateway.lastControlReason = normalizeText(input.lastControlReason)
184
+ if (input.controlRequest !== undefined) gateway.controlRequest = normalizeControlRequest(input.controlRequest)
152
185
  if (input.notes !== undefined) gateway.notes = typeof input.notes === 'string' ? input.notes : null
153
186
  if (input.tags !== undefined) gateway.tags = normalizeTags(input.tags)
154
187
  if (input.lastError !== undefined) gateway.lastError = typeof input.lastError === 'string' ? input.lastError : null
@@ -167,6 +200,44 @@ export function updateGatewayProfile(id: string, input: Record<string, unknown>)
167
200
  return gateway
168
201
  }
169
202
 
203
+ export function controlGatewayProfile(
204
+ id: string,
205
+ input: { action: NonNullable<GatewayProfile['lastControlAction']>; reason?: string | null },
206
+ now = Date.now(),
207
+ ): GatewayProfile | null {
208
+ const gateways = loadGatewayProfiles()
209
+ const gateway = gateways[id]
210
+ if (!gateway) return null
211
+
212
+ const action = normalizeControlAction(input.action)
213
+ if (!action) return null
214
+
215
+ gateway.lifecycleState = action === 'drain'
216
+ ? 'draining'
217
+ : action === 'cordon'
218
+ ? 'cordoned'
219
+ : action === 'activate'
220
+ ? 'active'
221
+ : normalizeLifecycleState(gateway.lifecycleState)
222
+ gateway.lastControlAction = action
223
+ gateway.lastControlActionAt = now
224
+ gateway.lastControlReason = normalizeText(input.reason)
225
+ gateway.controlRequest = action === 'restart'
226
+ ? {
227
+ action: 'restart',
228
+ requestedAt: now,
229
+ source: 'swarmclaw',
230
+ reason: normalizeText(input.reason),
231
+ }
232
+ : null
233
+ gateway.updatedAt = now
234
+
235
+ gateways[id] = gateway
236
+ saveGatewayProfiles(gateways)
237
+ notify('gateways')
238
+ return gateway
239
+ }
240
+
170
241
  export function deleteGatewayProfileAndDetachAgents(id: string): boolean {
171
242
  const gateways = loadGatewayProfiles()
172
243
  const deleted = gateways[id]
@@ -88,6 +88,11 @@ function gateway(overrides: Partial<GatewayProfile>): GatewayProfile {
88
88
  wsUrl: overrides.wsUrl ?? null,
89
89
  credentialId: overrides.credentialId ?? null,
90
90
  status: overrides.status || 'healthy',
91
+ lifecycleState: overrides.lifecycleState || 'active',
92
+ lastControlAction: overrides.lastControlAction ?? null,
93
+ lastControlActionAt: overrides.lastControlActionAt ?? null,
94
+ lastControlReason: overrides.lastControlReason ?? null,
95
+ controlRequest: overrides.controlRequest ?? null,
91
96
  notes: overrides.notes ?? null,
92
97
  tags: overrides.tags || [],
93
98
  lastError: overrides.lastError ?? null,
@@ -190,4 +195,33 @@ describe('operation pulse', () => {
190
195
  assert.ok((pulse.actions[0]?.summary || '').includes('no available OpenClaw execution environments'))
191
196
  assert.equal(pulse.actions[0]?.evidence.includes('0/2 environments'), true)
192
197
  })
198
+
199
+ it('surfaces gateways that are unavailable for automatic new work', () => {
200
+ const pulse = buildOperationPulse({
201
+ range: '24h',
202
+ now,
203
+ missions: [],
204
+ runs: [],
205
+ approvals: [],
206
+ connectors: [],
207
+ gateways: [
208
+ gateway({
209
+ lifecycleState: 'cordoned',
210
+ stats: {
211
+ nodeCount: 1,
212
+ connectedNodeCount: 1,
213
+ environmentCount: 1,
214
+ availableEnvironmentCount: 1,
215
+ lastTopologyCheckedAt: now - 1000,
216
+ },
217
+ }),
218
+ ],
219
+ })
220
+
221
+ assert.equal(pulse.kpis.gatewayAttention, 1)
222
+ assert.equal(pulse.actions[0]?.kind, 'gateway')
223
+ assert.equal(pulse.actions[0]?.severity, 'medium')
224
+ assert.ok((pulse.actions[0]?.summary || '').includes('cordoned from automatic new work'))
225
+ assert.equal(pulse.actions[0]?.evidence.includes('lifecycle:cordoned'), true)
226
+ })
193
227
  })
@@ -84,8 +84,10 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
84
84
  const errorCount = gateway.stats?.lastTopologyErrorCount || 0
85
85
  const checkedAt = gateway.stats?.lastTopologyCheckedAt || gateway.lastCheckedAt || null
86
86
  const staleTopology = !checkedAt || now - checkedAt > GATEWAY_TOPOLOGY_STALE_MS
87
+ const lifecycleState = gateway.lifecycleState || 'active'
87
88
  const evidence = [
88
89
  `status:${gateway.status}`,
90
+ `lifecycle:${lifecycleState}`,
89
91
  `${gateway.stats?.connectedNodeCount || 0}/${gateway.stats?.nodeCount || 0} nodes`,
90
92
  `${gateway.stats?.availableEnvironmentCount || 0}/${gateway.stats?.environmentCount || 0} environments`,
91
93
  ]
@@ -106,6 +108,22 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
106
108
  }
107
109
  }
108
110
 
111
+ if (lifecycleState === 'cordoned') {
112
+ return {
113
+ severity: 'medium',
114
+ summary: `${gateway.name} is cordoned from automatic new work.`,
115
+ evidence,
116
+ }
117
+ }
118
+
119
+ if (lifecycleState === 'draining') {
120
+ return {
121
+ severity: 'medium',
122
+ summary: `${gateway.name} is draining and will not receive automatic new work.`,
123
+ evidence,
124
+ }
125
+ }
126
+
109
127
  if (errorCount > 0) {
110
128
  return {
111
129
  severity: 'medium',
@@ -364,6 +364,11 @@ export const ExternalAgentUpdateSchema = z.object({
364
364
  }).nullable().optional(),
365
365
  })
366
366
 
367
+ export const GatewayControlSchema = z.object({
368
+ action: z.enum(['activate', 'drain', 'cordon', 'restart']),
369
+ reason: z.string().max(500).nullable().optional(),
370
+ }).strict()
371
+
367
372
  export const ChatroomCreateSchema = z.object({
368
373
  name: z.string().min(1, 'Chatroom name is required'),
369
374
  agentIds: z.array(z.string()).min(1, 'Select at least one agent').default([]),
package/src/types/misc.ts CHANGED
@@ -644,6 +644,8 @@ export interface McpServerConfig {
644
644
 
645
645
  export type GatewayProvider = 'openclaw'
646
646
  export type GatewayHealthState = 'unknown' | 'healthy' | 'degraded' | 'offline' | 'pending'
647
+ export type GatewayLifecycleState = 'active' | 'draining' | 'cordoned'
648
+ export type GatewayControlAction = 'activate' | 'drain' | 'cordon' | 'restart'
647
649
  export type OpenClawDeploymentMethod = 'local' | 'bundle' | 'ssh' | 'imported'
648
650
  export type OpenClawDeploymentProvider =
649
651
  | 'local'
@@ -714,6 +716,16 @@ export interface GatewayProfile {
714
716
  wsUrl?: string | null
715
717
  credentialId?: string | null
716
718
  status: GatewayHealthState
719
+ lifecycleState?: GatewayLifecycleState
720
+ lastControlAction?: GatewayControlAction | null
721
+ lastControlActionAt?: number | null
722
+ lastControlReason?: string | null
723
+ controlRequest?: {
724
+ action: 'restart'
725
+ requestedAt: number
726
+ source: 'swarmclaw'
727
+ reason?: string | null
728
+ } | null
717
729
  notes?: string | null
718
730
  tags?: string[]
719
731
  lastError?: string | null