@swarmclawai/swarmclaw 1.8.11 → 1.8.13
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 +18 -2
- package/package.json +3 -3
- package/src/app/api/gateways/[id]/topology/route.ts +13 -0
- package/src/app/api/gateways/fleet/route.ts +9 -0
- package/src/app/api/gateways/topology-route.test.ts +37 -0
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/gateways/gateway-sheet.tsx +43 -0
- package/src/components/operations/operations-pulse-panel.tsx +6 -3
- package/src/components/providers/provider-list.tsx +99 -2
- package/src/features/gateways/queries.ts +34 -47
- package/src/lib/server/gateways/gateway-profile-service.ts +5 -0
- package/src/lib/server/gateways/gateway-topology.test.ts +151 -0
- package/src/lib/server/gateways/gateway-topology.ts +366 -0
- package/src/lib/server/operations/operation-pulse.test.ts +57 -1
- package/src/lib/server/operations/operation-pulse.ts +83 -0
- package/src/lib/server/runtime/queue/core.ts +11 -2
- package/src/lib/server/runtime/queue-retry-policy.test.ts +98 -0
- package/src/lib/server/session-tools/context.ts +2 -0
- package/src/lib/server/session-tools/execute.test.ts +93 -1
- package/src/lib/server/session-tools/execute.ts +4 -3
- package/src/lib/server/session-tools/index.ts +2 -1
- package/src/lib/server/tasks/task-validation.test.ts +33 -0
- package/src/lib/server/tasks/task-validation.ts +2 -1
- package/src/types/misc.ts +58 -0
- package/src/types/operations.ts +2 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { listPendingApprovals } from '@/lib/server/approvals'
|
|
2
2
|
import { getConnectorReadiness } from '@/lib/connectors/connector-readiness'
|
|
3
3
|
import { loadConnectors } from '@/lib/server/connectors/connector-repository'
|
|
4
|
+
import { listOpenClawGatewayProfiles } from '@/lib/server/gateways/gateway-profile-service'
|
|
4
5
|
import { listMissions } from '@/lib/server/missions/mission-repository'
|
|
5
6
|
import { listUnifiedRuns } from '@/lib/server/runs/unified-run-queries'
|
|
6
7
|
import type {
|
|
7
8
|
ApprovalRequest,
|
|
8
9
|
Connector,
|
|
10
|
+
GatewayProfile,
|
|
9
11
|
Mission,
|
|
10
12
|
OperationPulse,
|
|
11
13
|
OperationPulseAction,
|
|
@@ -26,6 +28,7 @@ const SEVERITY_RANK: Record<OperationPulseSeverity, number> = {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const ACTIVE_MISSION_STATUSES = new Set<Mission['status']>(['running', 'paused'])
|
|
31
|
+
const GATEWAY_TOPOLOGY_STALE_MS = 30 * 60_000
|
|
29
32
|
|
|
30
33
|
export function normalizeOperationPulseRange(value: string | null | undefined): OperationPulseRange {
|
|
31
34
|
return value === '7d' ? '7d' : '24h'
|
|
@@ -68,6 +71,67 @@ function addAction(actions: OperationPulseAction[], action: OperationPulseAction
|
|
|
68
71
|
actions.push(action)
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
function gatewayPendingPairings(gateway: GatewayProfile): number {
|
|
75
|
+
return (gateway.stats?.pendingNodePairings || 0) + (gateway.stats?.pendingDevicePairings || 0)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
|
|
79
|
+
severity: OperationPulseSeverity
|
|
80
|
+
summary: string
|
|
81
|
+
evidence: string[]
|
|
82
|
+
} | null {
|
|
83
|
+
const pendingPairings = gatewayPendingPairings(gateway)
|
|
84
|
+
const errorCount = gateway.stats?.lastTopologyErrorCount || 0
|
|
85
|
+
const checkedAt = gateway.stats?.lastTopologyCheckedAt || gateway.lastCheckedAt || null
|
|
86
|
+
const staleTopology = !checkedAt || now - checkedAt > GATEWAY_TOPOLOGY_STALE_MS
|
|
87
|
+
const evidence = [
|
|
88
|
+
`status:${gateway.status}`,
|
|
89
|
+
`${gateway.stats?.connectedNodeCount || 0}/${gateway.stats?.nodeCount || 0} nodes`,
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
if (gateway.status === 'offline') {
|
|
93
|
+
return {
|
|
94
|
+
severity: 'high',
|
|
95
|
+
summary: `${gateway.name} is offline${gateway.lastError ? `: ${gateway.lastError}` : '.'}`,
|
|
96
|
+
evidence,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (gateway.status === 'degraded') {
|
|
101
|
+
return {
|
|
102
|
+
severity: 'high',
|
|
103
|
+
summary: `${gateway.name} is degraded${gateway.lastError ? `: ${gateway.lastError}` : '.'}`,
|
|
104
|
+
evidence,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (errorCount > 0) {
|
|
109
|
+
return {
|
|
110
|
+
severity: 'medium',
|
|
111
|
+
summary: `${gateway.name} topology refresh reported ${errorCount} error${errorCount === 1 ? '' : 's'}.`,
|
|
112
|
+
evidence: [...evidence, gateway.stats?.lastTopologyError || 'topology error'].filter(Boolean),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (pendingPairings > 0) {
|
|
117
|
+
return {
|
|
118
|
+
severity: 'medium',
|
|
119
|
+
summary: `${gateway.name} has ${pendingPairings} pending OpenClaw pairing request${pendingPairings === 1 ? '' : 's'}.`,
|
|
120
|
+
evidence: [...evidence, `${pendingPairings} pending pairings`],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (staleTopology) {
|
|
125
|
+
return {
|
|
126
|
+
severity: 'medium',
|
|
127
|
+
summary: `${gateway.name} topology has not been refreshed in the last 30 minutes.`,
|
|
128
|
+
evidence,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
71
135
|
function sortActions(actions: OperationPulseAction[]): OperationPulseAction[] {
|
|
72
136
|
return [...actions]
|
|
73
137
|
.sort((left, right) => {
|
|
@@ -85,6 +149,7 @@ export function buildOperationPulse(input: {
|
|
|
85
149
|
runs: SessionRunRecord[]
|
|
86
150
|
approvals: ApprovalRequest[]
|
|
87
151
|
connectors: Connector[]
|
|
152
|
+
gateways?: GatewayProfile[]
|
|
88
153
|
}): OperationPulse {
|
|
89
154
|
const windowStart = input.now - RANGE_MS[input.range]
|
|
90
155
|
const windowRuns = input.runs.filter((run) => run.status === 'running' || run.status === 'queued' || isWithinWindow(runActivityAt(run), windowStart))
|
|
@@ -94,6 +159,9 @@ export function buildOperationPulse(input: {
|
|
|
94
159
|
const pendingApprovals = input.approvals.filter((approval) => approval.status === 'pending')
|
|
95
160
|
const connectorReadiness = input.connectors.map((connector) => ({ connector, readiness: getConnectorReadiness(connector) }))
|
|
96
161
|
const connectorAttention = connectorReadiness.filter((item) => item.readiness.state !== 'healthy')
|
|
162
|
+
const gatewayAttention = (input.gateways || [])
|
|
163
|
+
.map((gateway) => ({ gateway, reason: gatewayAttentionReason(gateway, input.now) }))
|
|
164
|
+
.filter((item): item is { gateway: GatewayProfile; reason: NonNullable<ReturnType<typeof gatewayAttentionReason>> } => Boolean(item.reason))
|
|
97
165
|
const budgetWarnings = input.missions
|
|
98
166
|
.map((mission) => ({ mission, pressure: budgetPressure(mission, input.now) }))
|
|
99
167
|
.filter((item) => item.pressure)
|
|
@@ -141,6 +209,19 @@ export function buildOperationPulse(input: {
|
|
|
141
209
|
})
|
|
142
210
|
}
|
|
143
211
|
|
|
212
|
+
for (const item of gatewayAttention.slice(0, 5)) {
|
|
213
|
+
addAction(actions, {
|
|
214
|
+
id: `gateway:${item.gateway.id}`,
|
|
215
|
+
kind: 'gateway',
|
|
216
|
+
severity: item.reason.severity,
|
|
217
|
+
title: 'Review OpenClaw gateway',
|
|
218
|
+
summary: item.reason.summary,
|
|
219
|
+
href: '/providers',
|
|
220
|
+
evidence: item.reason.evidence,
|
|
221
|
+
createdAt: item.gateway.stats?.lastTopologyCheckedAt || item.gateway.lastCheckedAt || item.gateway.updatedAt || item.gateway.createdAt,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
144
225
|
for (const item of budgetWarnings.slice(0, 5)) {
|
|
145
226
|
if (!item.pressure) continue
|
|
146
227
|
addAction(actions, {
|
|
@@ -178,6 +259,7 @@ export function buildOperationPulse(input: {
|
|
|
178
259
|
failedRuns: failedRuns.length,
|
|
179
260
|
pendingApprovals: pendingApprovals.length,
|
|
180
261
|
connectorAttention: connectorAttention.length,
|
|
262
|
+
gatewayAttention: gatewayAttention.length,
|
|
181
263
|
budgetWarnings: budgetWarnings.length,
|
|
182
264
|
},
|
|
183
265
|
actions: sortActions(actions),
|
|
@@ -193,5 +275,6 @@ export function getOperationPulse(range: OperationPulseRange): OperationPulse {
|
|
|
193
275
|
runs: listUnifiedRuns({ limit: 500 }),
|
|
194
276
|
approvals: listPendingApprovals(),
|
|
195
277
|
connectors: Object.values(loadConnectors()),
|
|
278
|
+
gateways: listOpenClawGatewayProfiles(),
|
|
196
279
|
})
|
|
197
280
|
}
|
|
@@ -124,6 +124,14 @@ function applyTaskPolicyDefaults(task: BoardTask): void {
|
|
|
124
124
|
if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
function formatRetryError(reason: string): string {
|
|
128
|
+
return `Retry scheduled after failure: ${reason}`.slice(0, 500)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isRepeatedRetryFailure(previousError: unknown, reason: string): boolean {
|
|
132
|
+
return typeof previousError === 'string' && previousError === formatRetryError(reason)
|
|
133
|
+
}
|
|
134
|
+
|
|
127
135
|
export interface TaskResumeState {
|
|
128
136
|
claudeSessionId: string | null
|
|
129
137
|
codexThreadId: string | null
|
|
@@ -971,9 +979,10 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
|
|
|
971
979
|
}
|
|
972
980
|
applyTaskPolicyDefaults(task)
|
|
973
981
|
const now = Date.now()
|
|
982
|
+
const repeatedRetryFailure = isRepeatedRetryFailure(task.error, reason)
|
|
974
983
|
task.attempts = (task.attempts || 0) + 1
|
|
975
984
|
|
|
976
|
-
if ((task.attempts || 0) < (task.maxAttempts || 1)) {
|
|
985
|
+
if ((task.attempts || 0) < (task.maxAttempts || 1) && !repeatedRetryFailure) {
|
|
977
986
|
const delayMs = jitteredBackoff((task.retryBackoffSec || 30) * 1000, Math.max(0, (task.attempts || 1) - 1), 6 * 3600_000)
|
|
978
987
|
task.status = 'queued'
|
|
979
988
|
task.retryScheduledAt = now + delayMs
|
|
@@ -982,7 +991,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
|
|
|
982
991
|
// recovery loop burns CPU re-queueing a task that can never run.
|
|
983
992
|
task.checkoutRunId = null
|
|
984
993
|
task.updatedAt = now
|
|
985
|
-
task.error =
|
|
994
|
+
task.error = formatRetryError(reason)
|
|
986
995
|
if (!task.comments) task.comments = []
|
|
987
996
|
task.comments.push({
|
|
988
997
|
id: genId(),
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
|
|
9
|
+
|
|
10
|
+
function runWithTempDataDir<T extends Record<string, unknown>>(script: string): T {
|
|
11
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-queue-retry-policy-'))
|
|
12
|
+
try {
|
|
13
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
env: {
|
|
16
|
+
...process.env,
|
|
17
|
+
DATA_DIR: path.join(tempDir, 'data'),
|
|
18
|
+
WORKSPACE_DIR: path.join(tempDir, 'workspace'),
|
|
19
|
+
SWARMCLAW_BUILD_MODE: '1',
|
|
20
|
+
},
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
timeout: 15000,
|
|
23
|
+
})
|
|
24
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
25
|
+
const lines = (result.stdout || '')
|
|
26
|
+
.trim()
|
|
27
|
+
.split('\n')
|
|
28
|
+
.map((line) => line.trim())
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
31
|
+
return JSON.parse(jsonLine || '{}') as T
|
|
32
|
+
} finally {
|
|
33
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('recoverStalledRunningTasks dead-letters repeated retry failures instead of repeating identical work', () => {
|
|
38
|
+
const output = runWithTempDataDir<{
|
|
39
|
+
result: { recovered: number; deadLettered: number }
|
|
40
|
+
status: string | null
|
|
41
|
+
attempts: number | null
|
|
42
|
+
queued: string[]
|
|
43
|
+
retryScheduledAt: number | null
|
|
44
|
+
deadLetteredAt: number | null
|
|
45
|
+
error: string | null
|
|
46
|
+
}>(`
|
|
47
|
+
const storageMod = await import('@/lib/server/storage')
|
|
48
|
+
const queueMod = await import('@/lib/server/runtime/queue')
|
|
49
|
+
const storage = storageMod.default || storageMod
|
|
50
|
+
const queue = queueMod.default || queueMod
|
|
51
|
+
|
|
52
|
+
const now = Date.now()
|
|
53
|
+
const reason = 'Detected stalled run after 5m without progress'
|
|
54
|
+
storage.saveSettings({
|
|
55
|
+
...storage.loadSettings(),
|
|
56
|
+
taskStallTimeoutMin: 5,
|
|
57
|
+
taskRetryBackoffSec: 30,
|
|
58
|
+
})
|
|
59
|
+
storage.saveTasks({
|
|
60
|
+
repeat: {
|
|
61
|
+
id: 'repeat',
|
|
62
|
+
title: 'Repeated structural failure',
|
|
63
|
+
description: 'A task that already retried the same failure reason',
|
|
64
|
+
status: 'running',
|
|
65
|
+
agentId: 'agent-a',
|
|
66
|
+
startedAt: now - 600_000,
|
|
67
|
+
updatedAt: now - 600_000,
|
|
68
|
+
createdAt: now - 700_000,
|
|
69
|
+
maxAttempts: 3,
|
|
70
|
+
attempts: 1,
|
|
71
|
+
checkoutRunId: 'repeat-run-id',
|
|
72
|
+
error: 'Retry scheduled after failure: ' + reason,
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
storage.saveQueue([])
|
|
76
|
+
|
|
77
|
+
const result = queue.recoverStalledRunningTasks()
|
|
78
|
+
const task = storage.loadTasks().repeat
|
|
79
|
+
console.log(JSON.stringify({
|
|
80
|
+
result,
|
|
81
|
+
status: task?.status ?? null,
|
|
82
|
+
attempts: task?.attempts ?? null,
|
|
83
|
+
queued: storage.loadQueue(),
|
|
84
|
+
retryScheduledAt: task?.retryScheduledAt ?? null,
|
|
85
|
+
deadLetteredAt: task?.deadLetteredAt ?? null,
|
|
86
|
+
error: task?.error ?? null,
|
|
87
|
+
}))
|
|
88
|
+
`)
|
|
89
|
+
|
|
90
|
+
assert.equal(output.result.recovered, 0)
|
|
91
|
+
assert.equal(output.result.deadLettered, 1)
|
|
92
|
+
assert.equal(output.status, 'failed')
|
|
93
|
+
assert.equal(output.attempts, 2)
|
|
94
|
+
assert.deepEqual(output.queued, [])
|
|
95
|
+
assert.equal(output.retryScheduledAt, null)
|
|
96
|
+
assert.equal(typeof output.deadLetteredAt, 'number')
|
|
97
|
+
assert.match(output.error || '', /Dead-lettered after 2\/3 attempts/)
|
|
98
|
+
})
|
|
@@ -94,6 +94,8 @@ export interface ToolBuildContext {
|
|
|
94
94
|
fileAccessPolicy?: { allowedPaths?: string[]; blockedPaths?: string[] } | null
|
|
95
95
|
/** Agent's sandbox config — passed to shell for session-scoped container execution */
|
|
96
96
|
sandboxConfig?: NonNullable<Agent['sandboxConfig']> | null
|
|
97
|
+
/** Loaded agent record for tool builders that need per-agent runtime settings */
|
|
98
|
+
agentRecord?: Agent | null
|
|
97
99
|
/** Agent's filesystem scope — 'machine' allows file access outside the workspace */
|
|
98
100
|
filesystemScope?: 'workspace' | 'machine'
|
|
99
101
|
}
|
|
@@ -1,7 +1,40 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { describe, it, test } from 'node:test'
|
|
3
7
|
import { redactSecrets, buildCredentialEnv } from './credential-env'
|
|
4
8
|
|
|
9
|
+
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
|
|
10
|
+
|
|
11
|
+
function runWithTempDataDir<T extends Record<string, unknown>>(script: string): T {
|
|
12
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-execute-tool-'))
|
|
13
|
+
try {
|
|
14
|
+
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
|
|
15
|
+
cwd: repoRoot,
|
|
16
|
+
env: {
|
|
17
|
+
...process.env,
|
|
18
|
+
DATA_DIR: path.join(tempDir, 'data'),
|
|
19
|
+
WORKSPACE_DIR: path.join(tempDir, 'workspace'),
|
|
20
|
+
SWARMCLAW_BUILD_MODE: '1',
|
|
21
|
+
},
|
|
22
|
+
encoding: 'utf-8',
|
|
23
|
+
timeout: 15000,
|
|
24
|
+
})
|
|
25
|
+
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
26
|
+
const lines = (result.stdout || '')
|
|
27
|
+
.trim()
|
|
28
|
+
.split('\n')
|
|
29
|
+
.map((line) => line.trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
32
|
+
return JSON.parse(jsonLine || '{}') as T
|
|
33
|
+
} finally {
|
|
34
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
5
38
|
describe('credential-env', () => {
|
|
6
39
|
describe('redactSecrets', () => {
|
|
7
40
|
it('redacts secret values from text', () => {
|
|
@@ -56,3 +89,62 @@ describe('credential-env', () => {
|
|
|
56
89
|
})
|
|
57
90
|
})
|
|
58
91
|
})
|
|
92
|
+
|
|
93
|
+
test('buildExecuteTools uses the current agent executeConfig backend', () => {
|
|
94
|
+
const output = runWithTempDataDir<{
|
|
95
|
+
hostOutput: string
|
|
96
|
+
sandboxOutput: string
|
|
97
|
+
}>(`
|
|
98
|
+
const storageMod = await import('@/lib/server/storage')
|
|
99
|
+
const executeModImport = await import('./src/lib/server/session-tools/execute.ts')
|
|
100
|
+
const executeMod = executeModImport.default || executeModImport
|
|
101
|
+
const storage = storageMod.default || storageMod
|
|
102
|
+
const now = Date.now()
|
|
103
|
+
|
|
104
|
+
storage.saveAgents({
|
|
105
|
+
'agent-host': {
|
|
106
|
+
id: 'agent-host',
|
|
107
|
+
name: 'Host Agent',
|
|
108
|
+
provider: 'openai',
|
|
109
|
+
model: 'gpt-test',
|
|
110
|
+
executeConfig: { backend: 'host', network: { enabled: true }, timeout: 30 },
|
|
111
|
+
createdAt: now,
|
|
112
|
+
updatedAt: now,
|
|
113
|
+
},
|
|
114
|
+
'agent-sandbox': {
|
|
115
|
+
id: 'agent-sandbox',
|
|
116
|
+
name: 'Sandbox Agent',
|
|
117
|
+
provider: 'openai',
|
|
118
|
+
model: 'gpt-test',
|
|
119
|
+
createdAt: now,
|
|
120
|
+
updatedAt: now,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const makeContext = (agentId) => ({
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
ctx: { agentId, sessionId: 'session-' + agentId },
|
|
127
|
+
hasExtension: (name) => name === 'execute',
|
|
128
|
+
hasTool: (name) => name === 'execute',
|
|
129
|
+
cleanupFns: [],
|
|
130
|
+
commandTimeoutMs: 30000,
|
|
131
|
+
claudeTimeoutMs: 30000,
|
|
132
|
+
cliProcessTimeoutMs: 30000,
|
|
133
|
+
persistDelegateResumeId: () => {},
|
|
134
|
+
readStoredDelegateResumeId: () => null,
|
|
135
|
+
resolveCurrentSession: () => ({ id: 'session-' + agentId, agentId }),
|
|
136
|
+
activeExtensions: ['execute'],
|
|
137
|
+
agentRecord: storage.loadAgent(agentId),
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const hostTool = executeMod.buildExecuteTools(makeContext('agent-host'))[0]
|
|
141
|
+
const sandboxTool = executeMod.buildExecuteTools(makeContext('agent-sandbox'))[0]
|
|
142
|
+
const hostOutput = await hostTool.invoke({ code: 'printf host-ok', persistent: true })
|
|
143
|
+
const sandboxOutput = await sandboxTool.invoke({ code: 'printf sandbox-no', persistent: true })
|
|
144
|
+
|
|
145
|
+
console.log(JSON.stringify({ hostOutput, sandboxOutput }))
|
|
146
|
+
`)
|
|
147
|
+
|
|
148
|
+
assert.equal(output.hostOutput, 'host-ok')
|
|
149
|
+
assert.match(output.sandboxOutput, /requires `executeConfig\.backend = "host"`/)
|
|
150
|
+
})
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
normalizeAgentExecuteConfig,
|
|
27
27
|
type AgentExecuteConfig,
|
|
28
28
|
} from '@/lib/agent-execute-defaults'
|
|
29
|
+
import { loadAgent } from '../storage'
|
|
29
30
|
|
|
30
31
|
const TAG = 'execute'
|
|
31
32
|
|
|
@@ -310,9 +311,9 @@ registerNativeCapability('execute', ExecuteExtension)
|
|
|
310
311
|
export function buildExecuteTools(bctx: ToolBuildContext) {
|
|
311
312
|
if (!bctx.hasExtension('execute')) return []
|
|
312
313
|
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
314
|
+
const agentId = typeof bctx.ctx?.agentId === 'string' ? bctx.ctx.agentId.trim() : ''
|
|
315
|
+
const agent = (bctx.agentRecord as (Agent & { executeConfig?: ExecuteConfig }) | null | undefined)
|
|
316
|
+
?? (agentId ? (loadAgent(agentId) as (Agent & { executeConfig?: ExecuteConfig }) | null) : null)
|
|
316
317
|
const executeConfig = normalizeAgentExecuteConfig(agent?.executeConfig)
|
|
317
318
|
|
|
318
319
|
return [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
|
-
import type { Session } from '@/types'
|
|
3
|
+
import type { Agent, Session } from '@/types'
|
|
4
4
|
import { dedup, errorMessage } from '@/lib/shared-utils'
|
|
5
5
|
import { loadSettings, loadSession, loadAgent, loadMcpServers, patchAgent, patchSession } from '../storage'
|
|
6
6
|
import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
|
|
@@ -221,6 +221,7 @@ export async function buildSessionTools(cwd: string, enabledExtensions: string[]
|
|
|
221
221
|
activeExtensions,
|
|
222
222
|
fileAccessPolicy: effectiveFileAccessPolicy,
|
|
223
223
|
sandboxConfig: agentRecord?.sandboxConfig ?? null,
|
|
224
|
+
agentRecord: agentRecord as Agent | null,
|
|
224
225
|
filesystemScope,
|
|
225
226
|
}
|
|
226
227
|
|
|
@@ -49,6 +49,39 @@ test('validateTaskCompletion still enforces stricter minimum for implementation
|
|
|
49
49
|
assert.ok(validation.reasons.some((reason) => reason.includes('Result summary is too short')))
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
+
test('validateTaskCompletion does not auto-apply implementation quality gates to scheduled tasks', () => {
|
|
53
|
+
const validation = validateTaskCompletion({
|
|
54
|
+
title: '[Sched] Daily wiki hygiene (run #1)',
|
|
55
|
+
description: 'Run scripts/wiki-hygiene.mjs and post the digest to Slack.',
|
|
56
|
+
result: 'Ran node scripts/wiki-hygiene.mjs and posted the digest to Slack with exit code 0.',
|
|
57
|
+
sourceType: 'schedule',
|
|
58
|
+
sourceScheduleId: 'schedule-wiki',
|
|
59
|
+
error: null,
|
|
60
|
+
} as Partial<BoardTask>)
|
|
61
|
+
|
|
62
|
+
assert.equal(validation.ok, true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('validateTaskCompletion still enforces explicit quality gates on scheduled tasks', () => {
|
|
66
|
+
const validation = validateTaskCompletion({
|
|
67
|
+
title: '[Sched] Daily wiki hygiene (run #1)',
|
|
68
|
+
description: 'Run scripts/wiki-hygiene.mjs and post the digest to Slack.',
|
|
69
|
+
result: 'Ran node scripts/wiki-hygiene.mjs and posted the digest to Slack with exit code 0.',
|
|
70
|
+
sourceType: 'schedule',
|
|
71
|
+
sourceScheduleId: 'schedule-wiki',
|
|
72
|
+
qualityGate: {
|
|
73
|
+
enabled: true,
|
|
74
|
+
minResultChars: 20,
|
|
75
|
+
minEvidenceItems: 2,
|
|
76
|
+
requireVerification: true,
|
|
77
|
+
},
|
|
78
|
+
error: null,
|
|
79
|
+
} as Partial<BoardTask>)
|
|
80
|
+
|
|
81
|
+
assert.equal(validation.ok, false)
|
|
82
|
+
assert.ok(validation.reasons.some((reason) => reason.includes('verification evidence is required')))
|
|
83
|
+
})
|
|
84
|
+
|
|
52
85
|
test('validateTaskCompletion fails implementation task with unfinished next-step language', () => {
|
|
53
86
|
const validation = validateTaskCompletion({
|
|
54
87
|
title: 'Build weather dashboard',
|
|
@@ -85,7 +85,8 @@ export function validateTaskCompletion(
|
|
|
85
85
|
const report = options.report || null
|
|
86
86
|
const hasExplicitQualityGate = !!task.qualityGate && typeof task.qualityGate === 'object'
|
|
87
87
|
const qualityGate = normalizeTaskQualityGate(task.qualityGate || null, options.settings || null)
|
|
88
|
-
const
|
|
88
|
+
const scheduleTask = task.sourceType === 'schedule' || typeof task.sourceScheduleId === 'string'
|
|
89
|
+
const implementationTask = !scheduleTask && (IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description))
|
|
89
90
|
|
|
90
91
|
if (error) reasons.push('Task has a non-empty error field.')
|
|
91
92
|
if (/^untitled task$/i.test(title) && !description) {
|
package/src/types/misc.ts
CHANGED
|
@@ -670,6 +670,11 @@ export interface OpenClawGatewayStats {
|
|
|
670
670
|
pairedDeviceCount?: number
|
|
671
671
|
pendingDevicePairings?: number
|
|
672
672
|
externalRuntimeCount?: number
|
|
673
|
+
sessionCount?: number
|
|
674
|
+
presenceCount?: number
|
|
675
|
+
lastTopologyCheckedAt?: number
|
|
676
|
+
lastTopologyErrorCount?: number
|
|
677
|
+
lastTopologyError?: string | null
|
|
673
678
|
}
|
|
674
679
|
|
|
675
680
|
export interface OpenClawDeploymentConfig {
|
|
@@ -773,6 +778,59 @@ export interface OpenClawPairingSnapshot {
|
|
|
773
778
|
paired?: OpenClawPairedDevice[]
|
|
774
779
|
}
|
|
775
780
|
|
|
781
|
+
export interface OpenClawGatewayRpcError {
|
|
782
|
+
method: string
|
|
783
|
+
message: string
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export interface OpenClawGatewaySession {
|
|
787
|
+
id: string
|
|
788
|
+
key?: string | null
|
|
789
|
+
title?: string | null
|
|
790
|
+
channel?: string | null
|
|
791
|
+
sender?: string | null
|
|
792
|
+
updatedAt?: number | null
|
|
793
|
+
status?: string | null
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export interface OpenClawGatewayPresenceEntry {
|
|
797
|
+
id: string
|
|
798
|
+
label?: string | null
|
|
799
|
+
mode?: string | null
|
|
800
|
+
deviceId?: string | null
|
|
801
|
+
host?: string | null
|
|
802
|
+
status?: string | null
|
|
803
|
+
updatedAt?: number | null
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
export interface OpenClawGatewayTopologyStats extends OpenClawGatewayStats {
|
|
807
|
+
pendingPairingCount: number
|
|
808
|
+
hasErrors: boolean
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export interface OpenClawGatewayTopology {
|
|
812
|
+
profile: GatewayProfile
|
|
813
|
+
connected: boolean
|
|
814
|
+
refreshedAt: number
|
|
815
|
+
stats: OpenClawGatewayTopologyStats
|
|
816
|
+
nodes: OpenClawNode[]
|
|
817
|
+
nodePairings: OpenClawNodePairRequest[]
|
|
818
|
+
devicePairings: OpenClawDevicePairRequest[]
|
|
819
|
+
pairedDevices: OpenClawPairedDevice[]
|
|
820
|
+
sessions: OpenClawGatewaySession[]
|
|
821
|
+
presence: OpenClawGatewayPresenceEntry[]
|
|
822
|
+
errors: OpenClawGatewayRpcError[]
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export interface OpenClawGatewayFleetTopology {
|
|
826
|
+
generatedAt: number
|
|
827
|
+
gateways: OpenClawGatewayTopology[]
|
|
828
|
+
totals: OpenClawGatewayTopologyStats & {
|
|
829
|
+
gatewayCount: number
|
|
830
|
+
connectedGatewayCount: number
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
776
834
|
// --- ClawHub ---
|
|
777
835
|
|
|
778
836
|
export interface ClawHubSkill {
|
package/src/types/operations.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type OperationPulseActionKind =
|
|
|
7
7
|
| 'run'
|
|
8
8
|
| 'approval'
|
|
9
9
|
| 'connector'
|
|
10
|
+
| 'gateway'
|
|
10
11
|
| 'budget'
|
|
11
12
|
| 'quality'
|
|
12
13
|
|
|
@@ -16,6 +17,7 @@ export interface OperationPulseKpis {
|
|
|
16
17
|
failedRuns: number
|
|
17
18
|
pendingApprovals: number
|
|
18
19
|
connectorAttention: number
|
|
20
|
+
gatewayAttention: number
|
|
19
21
|
budgetWarnings: number
|
|
20
22
|
}
|
|
21
23
|
|