cli-swarm 7.0.33 → 7.0.35
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/package.json +2 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/autocoord.md +33 -7
- package/skill/skill.json +1 -1
- package/swarm-coordinator-model.mjs +23 -2
- package/swarm-coordinator-waits.mjs +285 -0
- package/swarm-coordinator.mjs +181 -235
- package/swarm-runtime.mjs +83 -87
package/swarm-coordinator.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
|
|
1
|
+
import { createHash, randomUUID, verify } from 'node:crypto'
|
|
2
|
+
import { lstat, readFile } from 'node:fs/promises'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { LEASE_SCHEMA, coordinatorError, identifier, leasePayload, readCoordinationState,
|
|
5
|
+
withCoordinationReadLock, withCoordinationState, writeSignedLease } from './swarm-coordinator-fs.mjs'
|
|
6
|
+
import { findTask, locksConflict, normalizeLockRequest, normalizeTaskCard, requireString,
|
|
6
7
|
scanTaskConflicts, validateInputShape } from './swarm-coordinator-model.mjs'
|
|
8
|
+
import { addMessage, eventRecord, routeEvent, createDecision, applyCycles, applyTimeouts,
|
|
9
|
+
assertTaskRunnable, pendingDecision, hasActiveWait, dependencyWait, publishEvent, taskStatus,
|
|
10
|
+
resolveHuman, cancelWait, waitForEvent } from './swarm-coordinator-waits.mjs'
|
|
7
11
|
|
|
8
12
|
const LOCAL_SCHEMA = 'swarm.coordinator-local/1.0'
|
|
9
13
|
const OPERATIONS = Object.freeze([
|
|
10
14
|
'capabilities', 'register-task', 'conflict-scan', 'lock-acquire', 'lock-renew',
|
|
11
|
-
'lock-release', 'baseline-handshake', 'dependency-wait', 'event-publish',
|
|
12
|
-
'task-status', 'tick', 'resolve-human', 'status',
|
|
15
|
+
'lock-release', 'lock-queue-status', 'baseline-handshake', 'dependency-wait', 'event-publish',
|
|
16
|
+
'task-status', 'tick', 'resolve-human', 'wait-for-event', 'wait-cancel', 'status',
|
|
13
17
|
])
|
|
14
18
|
|
|
15
19
|
const objectSchema = (required, properties) => ({
|
|
@@ -22,15 +26,18 @@ const TASK_CARD_SCHEMA = objectSchema(
|
|
|
22
26
|
['taskId', 'agentId', 'chainId', 'taskScope', 'plannedActions', 'deployTarget', 'eta', 'baselineHash', 'archConstraints'],
|
|
23
27
|
{ taskId: string, agentId: string, chainId: string, taskScope: stringArray,
|
|
24
28
|
plannedActions: stringArray, deployTarget: nullableString, eta: string,
|
|
25
|
-
baselineHash: string, archConstraints: stringArray },
|
|
29
|
+
baselineHash: string, archConstraints: stringArray, supersedesTaskId: string },
|
|
26
30
|
)
|
|
27
31
|
const LOCK_SCHEMA = objectSchema(
|
|
28
32
|
['taskId', 'agentId', 'chainId', 'lockType', 'resource', 'paths', 'ttlSeconds', 'queueOnConflict', 'baselineHandshakeId'],
|
|
29
33
|
{ taskId: string, agentId: string, chainId: string,
|
|
30
34
|
lockType: { enum: ['file', 'build', 'deploy'] }, resource: nullableString, paths: stringArray,
|
|
31
35
|
ttlSeconds: { type: 'integer', minimum: 1, maximum: 3600 }, queueOnConflict: { type: 'boolean' },
|
|
32
|
-
baselineHandshakeId: nullableString
|
|
36
|
+
baselineHandshakeId: nullableString,
|
|
37
|
+
queueTimeoutMs: { type: 'integer', minimum: 1, description: 'Explicit maximum time in the queue; required when queueOnConflict=true.' } },
|
|
33
38
|
)
|
|
39
|
+
LOCK_SCHEMA.allOf = [{ if: { properties: { queueOnConflict: { const: true } } },
|
|
40
|
+
then: { required: ['queueTimeoutMs'] } }]
|
|
34
41
|
const WAIT_SCHEMA = objectSchema(
|
|
35
42
|
['taskId', 'chainId', 'waiter', 'waitFor', 'event', 'purpose', 'expectedWithinMs', 'onEvent', 'onTimeout', 'refetchPaths'],
|
|
36
43
|
{ taskId: string, chainId: string, waiter: string, waitFor: string, event: string, purpose: string,
|
|
@@ -43,6 +50,8 @@ const OPERATION_SCHEMAS = Object.freeze({
|
|
|
43
50
|
'register-task': TASK_CARD_SCHEMA,
|
|
44
51
|
'conflict-scan': objectSchema(['taskId'], { taskId: string }),
|
|
45
52
|
'lock-acquire': LOCK_SCHEMA,
|
|
53
|
+
'lock-queue-status': objectSchema(['queueId', 'taskId', 'agentId', 'chainId'],
|
|
54
|
+
{ queueId: string, taskId: string, agentId: string, chainId: string }),
|
|
46
55
|
'lock-renew': objectSchema(['lockId', 'taskId', 'agentId', 'chainId', 'ttlSeconds'],
|
|
47
56
|
{ lockId: string, taskId: string, agentId: string, chainId: string,
|
|
48
57
|
ttlSeconds: { type: 'integer', minimum: 1, maximum: 3600 } }),
|
|
@@ -56,90 +65,33 @@ const OPERATION_SCHEMAS = Object.freeze({
|
|
|
56
65
|
'task-status': objectSchema(['taskId', 'agentId', 'chainId', 'status'],
|
|
57
66
|
{ taskId: string, agentId: string, chainId: string,
|
|
58
67
|
status: { enum: ['active', 'waiting', 'completed', 'failed', 'reclaimed'] } }),
|
|
59
|
-
tick: objectSchema(['now'], { now: { type: 'string', format: 'date-time'
|
|
68
|
+
tick: objectSchema(['now'], { now: { type: 'string', format: 'date-time',
|
|
69
|
+
description: 'Caller observation only; deadlines and signed leases use the coordinator clock.' } }),
|
|
60
70
|
'resolve-human': objectSchema(['decisionId', 'answer', 'actorId'],
|
|
61
71
|
{ decisionId: string, answer: string, actorId: string }),
|
|
72
|
+
'wait-for-event': objectSchema(['taskId', 'agentId', 'chainId', 'waitId'],
|
|
73
|
+
{ taskId: string, agentId: string, chainId: string, waitId: string }),
|
|
74
|
+
'wait-cancel': objectSchema(['taskId', 'agentId', 'chainId', 'waitId', 'reason'],
|
|
75
|
+
{ taskId: string, agentId: string, chainId: string, waitId: string, reason: string }),
|
|
62
76
|
status: objectSchema([], {}),
|
|
63
77
|
})
|
|
64
78
|
|
|
65
79
|
function sha256(value) { return createHash('sha256').update(value).digest('hex') }
|
|
66
80
|
|
|
67
|
-
function
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
function validateReplacement(state, card) {
|
|
82
|
+
if (card.supersedesTaskId === null) return
|
|
83
|
+
const previous = state.tasks.find((task) => task.taskId === card.supersedesTaskId)
|
|
84
|
+
const pending = previous && state.decisions.some((decision) => decision.status === 'pending'
|
|
85
|
+
&& decision.agents.includes(previous.agentId))
|
|
86
|
+
const covered = previous && previous.taskScope.every((path) => card.taskScope.some((scope) => (
|
|
87
|
+
path === scope || path.startsWith(scope + '/')
|
|
88
|
+
)))
|
|
89
|
+
if (!previous || previous.chainId !== card.chainId || !['failed', 'reclaimed'].includes(previous.status)
|
|
90
|
+
|| pending || !covered || previous.deployTarget !== card.deployTarget
|
|
91
|
+
|| previous.archConstraints.some((constraint) => !card.archConstraints.includes(constraint))
|
|
92
|
+
|| state.tasks.some((task) => task.supersedesTaskId === previous.taskId)) {
|
|
93
|
+
coordinatorError('SWARM_COORD_REPLACEMENT_INVALID', 'replacement requires one failed/reclaimed task in this chain, preserved scope/constraints, and no pending decision')
|
|
74
94
|
}
|
|
75
|
-
return task
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function addMessage(state, type, from, to, payload, now) {
|
|
79
|
-
const message = coordinationMessage(type, from, to, payload, now)
|
|
80
|
-
state.messages.push(message)
|
|
81
|
-
return message
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function eventRecord(publisher, event, payload, now) {
|
|
85
|
-
return {
|
|
86
|
-
schemaVersion: 'swarm.coord-event/1.0', eventId: `event-${randomUUID()}`,
|
|
87
|
-
publisher, event, payload, publishedAt: now,
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function wakeWait(state, wait, resolution, event, now) {
|
|
92
|
-
wait.status = resolution
|
|
93
|
-
wait.resolvedAt = now
|
|
94
|
-
wait.resolution = event
|
|
95
|
-
const task = state.tasks.find((item) => item.taskId === wait.taskId)
|
|
96
|
-
if (task && task.status === 'waiting') {
|
|
97
|
-
task.status = 'active'
|
|
98
|
-
task.updatedAt = now
|
|
99
|
-
}
|
|
100
|
-
const wakePackage = {
|
|
101
|
-
schemaVersion: 'swarm.wake-package/1.0', waitId: wait.waitId, reason: resolution,
|
|
102
|
-
event, refetchPaths: wait.refetchPaths, createdAt: now,
|
|
103
|
-
}
|
|
104
|
-
addMessage(state, 'dependency-wait', 'coordinator', wait.waiter, wakePackage, now)
|
|
105
|
-
return wakePackage
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function routeEvent(state, event) {
|
|
109
|
-
return state.waits.filter((wait) => wait.status === 'active'
|
|
110
|
-
&& wait.waitFor === event.publisher && wait.event === event.event)
|
|
111
|
-
.map((wait) => wakeWait(state, wait, 'event-received', event, event.publishedAt))
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function confirmationRequest(decision) {
|
|
115
|
-
const options = decision.agents.map((agentId) => ({
|
|
116
|
-
id: `resume:${agentId}`, label: `先恢复 ${agentId}`, hint: '唤醒该智能体先解除依赖',
|
|
117
|
-
}))
|
|
118
|
-
options.push({ id: 'abort', label: '终止等待', hint: '终止相关等待并保持任务阻塞' })
|
|
119
|
-
return {
|
|
120
|
-
schemaVersion: 'confirm-protocol.skill.request/1.0',
|
|
121
|
-
requestId: `confirm-${decision.decisionId}`,
|
|
122
|
-
operation: 'interaction-request',
|
|
123
|
-
input: { interaction: {
|
|
124
|
-
schemaVersion: 'confirm.interaction/1.0', requestId: decision.decisionId,
|
|
125
|
-
type: 'choice', question: decision.question, options, default: null, timeout: null,
|
|
126
|
-
timeoutAction: 'wait', risk: 'high', riskDescription: decision.riskDescription,
|
|
127
|
-
rememberable: false, memoryKey: '',
|
|
128
|
-
callback: { operation: 'resolve-human', payload: { decisionId: decision.decisionId } },
|
|
129
|
-
} },
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function createDecision(state, kind, agents, waitIds, question, riskDescription, now) {
|
|
134
|
-
const decision = {
|
|
135
|
-
schemaVersion: 'swarm.coord-decision/1.0', decisionId: `decision-${randomUUID()}`,
|
|
136
|
-
kind, agents: [...new Set(agents)], waitIds: [...new Set(waitIds)], question,
|
|
137
|
-
riskDescription, status: 'pending', answer: null, actorId: null,
|
|
138
|
-
createdAt: now, resolvedAt: null,
|
|
139
|
-
}
|
|
140
|
-
state.decisions.push(decision)
|
|
141
|
-
addMessage(state, 'need-human', 'coordinator', 'human', { decisionId: decision.decisionId, kind, agents, waitIds }, now)
|
|
142
|
-
return { decision, status: 'blocked', confirmationRequired: true, confirmProtocolRequest: confirmationRequest(decision), nextStep: { operation: 'confirm-protocol', instruction: 'Invoke Confirm Protocol and wait for the human answer.' } }
|
|
143
95
|
}
|
|
144
96
|
|
|
145
97
|
async function registerTask(repositoryRoot, input) {
|
|
@@ -148,9 +100,11 @@ async function registerTask(repositoryRoot, input) {
|
|
|
148
100
|
if (state.tasks.some((task) => task.taskId === card.taskId)) {
|
|
149
101
|
coordinatorError('SWARM_COORD_TASK_EXISTS', `task ${card.taskId} is already registered`)
|
|
150
102
|
}
|
|
103
|
+
validateReplacement(state, card)
|
|
151
104
|
state.tasks.push(card)
|
|
152
105
|
return { state, output: { schemaVersion: LOCAL_SCHEMA, task: card },
|
|
153
|
-
audit: [{ event: 'range-declare', taskId: card.taskId, agentId: card.agentId, taskScope: card.taskScope
|
|
106
|
+
audit: [{ event: 'range-declare', taskId: card.taskId, agentId: card.agentId, taskScope: card.taskScope,
|
|
107
|
+
supersedesTaskId: card.supersedesTaskId }] }
|
|
154
108
|
})
|
|
155
109
|
}
|
|
156
110
|
|
|
@@ -205,6 +159,7 @@ async function acquireLock(repositoryRoot, input) {
|
|
|
205
159
|
return withCoordinationState(repositoryRoot, async (state, root) => {
|
|
206
160
|
const request = normalizeLockRequest(input)
|
|
207
161
|
const task = findTask(state, request)
|
|
162
|
+
assertTaskRunnable(state, task)
|
|
208
163
|
if (!validHandshake(task, request)) {
|
|
209
164
|
coordinatorError('SWARM_COORD_BASELINE_HANDSHAKE_REQUIRED', 'build and deploy locks require the current baseline handshake')
|
|
210
165
|
}
|
|
@@ -215,9 +170,11 @@ async function acquireLock(repositoryRoot, input) {
|
|
|
215
170
|
audit: [{ event: 'lock-granted', lockId: granted.lock.lockId, taskId: request.taskId }] }
|
|
216
171
|
}
|
|
217
172
|
if (!request.queueOnConflict) coordinatorError('SWARM_COORD_LOCK_DENIED', 'the requested lock conflicts with an active lock')
|
|
173
|
+
const enqueuedAt = new Date().toISOString()
|
|
218
174
|
const queued = { schemaVersion: 'swarm.coord-queue/1.0', queueId: `queue-${randomUUID()}`,
|
|
219
175
|
request, blockingLockIds: conflicts.map((lock) => lock.lockId), status: 'queued',
|
|
220
|
-
enqueuedAt: new Date().toISOString(),
|
|
176
|
+
enqueuedAt, deadlineAt: new Date(Date.parse(enqueuedAt) + request.queueTimeoutMs).toISOString(),
|
|
177
|
+
resolvedAt: null, grant: null, decisionId: null, confirmProtocolRequest: null }
|
|
221
178
|
state.queue.push(queued)
|
|
222
179
|
addMessage(state, 'lock-denied', 'coordinator', request.agentId,
|
|
223
180
|
{ queueId: queued.queueId, blockingLockIds: queued.blockingLockIds }, queued.enqueuedAt)
|
|
@@ -229,6 +186,7 @@ async function acquireLock(repositoryRoot, input) {
|
|
|
229
186
|
async function renewLock(repositoryRoot, input) {
|
|
230
187
|
return withCoordinationState(repositoryRoot, async (state, root) => {
|
|
231
188
|
const task = findTask(state, input)
|
|
189
|
+
assertTaskRunnable(state, task)
|
|
232
190
|
const lockId = identifier(input.lockId, 'lockId')
|
|
233
191
|
const lock = state.locks.find((item) => item.lockId === lockId)
|
|
234
192
|
if (!lock || lock.status !== 'active' || lock.taskId !== task.taskId) {
|
|
@@ -245,16 +203,122 @@ async function renewLock(repositoryRoot, input) {
|
|
|
245
203
|
})
|
|
246
204
|
}
|
|
247
205
|
|
|
206
|
+
function rejectQueuedLock(state, queued, reason, now) {
|
|
207
|
+
queued.status = reason === 'queue-timeout' ? 'timed-out' : 'rejected'
|
|
208
|
+
queued.reason = reason
|
|
209
|
+
queued.resolvedAt = now
|
|
210
|
+
addMessage(state, 'lock-denied', 'coordinator', queued.request.agentId,
|
|
211
|
+
{ queueId: queued.queueId, reason, request: queued.request, deadlineAt: queued.deadlineAt }, now)
|
|
212
|
+
if (reason !== 'queue-timeout') return
|
|
213
|
+
const result = createDecision(state, 'lock-queue-timeout', [queued.request.agentId], [],
|
|
214
|
+
'锁队列 ' + queued.queueId + ' 已于 ' + queued.deadlineAt + ' 超时;资源 '
|
|
215
|
+
+ queued.request.resource + ',路径 ' + queued.request.paths.join(', ') + '。请核查占锁方后决定恢复或终止任务。',
|
|
216
|
+
'原队列不会再次授锁。恢复后必须提交带新明确等待上限的申请,不能把超时当作已取得锁。', now)
|
|
217
|
+
queued.decisionId = result.decision.decisionId
|
|
218
|
+
queued.confirmProtocolRequest = result.confirmProtocolRequest
|
|
219
|
+
return result
|
|
220
|
+
}
|
|
221
|
+
|
|
248
222
|
async function promoteQueue(state, root, now) {
|
|
249
|
-
const promoted = []
|
|
223
|
+
const promoted = [], decisions = []
|
|
250
224
|
for (const queued of state.queue.filter((item) => item.status === 'queued')) {
|
|
225
|
+
if (!Number.isFinite(Date.parse(queued.deadlineAt))) {
|
|
226
|
+
rejectQueuedLock(state, queued, 'queue-deadline-missing-or-invalid', now)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (Date.parse(queued.deadlineAt) <= Date.parse(now)) {
|
|
230
|
+
decisions.push(rejectQueuedLock(state, queued, 'queue-timeout', now))
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const task = findTask(state, queued.request)
|
|
234
|
+
if (['completed', 'failed', 'reclaimed'].includes(task.status) || !validHandshake(task, queued.request)) {
|
|
235
|
+
rejectQueuedLock(state, queued, 'task-terminated-or-baseline-handshake-changed', now)
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (task.status !== 'active' || pendingDecision(state, task) || hasActiveWait(state, task)) continue
|
|
251
239
|
const conflicts = state.locks.filter((lock) => locksConflict(queued.request, lock))
|
|
252
240
|
if (conflicts.length) continue
|
|
253
241
|
queued.status = 'granted'
|
|
254
242
|
queued.resolvedAt = now
|
|
255
|
-
|
|
243
|
+
const granted = await grantLock(state, root, queued.request, now)
|
|
244
|
+
queued.grant = { lockId: granted.lock.lockId, leasePath: granted.leasePath, lease: granted.lease }
|
|
245
|
+
promoted.push(granted)
|
|
246
|
+
}
|
|
247
|
+
return { promoted, decisions }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function checkStoredQueueLease(root, grant) {
|
|
251
|
+
const lease = grant.lease
|
|
252
|
+
const expectedPath = '.coord/leases/' + identifier(lease.leaseId, 'leaseId') + '.json'
|
|
253
|
+
if (grant.leasePath !== expectedPath || lease.schemaVersion !== LEASE_SCHEMA
|
|
254
|
+
|| !Number.isFinite(Date.parse(lease.issuedAt)) || Date.parse(lease.issuedAt) > Date.now()) {
|
|
255
|
+
coordinatorError('SWARM_COORD_QUEUE_GRANT_INVALID', 'queued lease path, schema or issue time is invalid')
|
|
256
|
+
}
|
|
257
|
+
const leaseFile = resolve(root, expectedPath)
|
|
258
|
+
const publicFile = resolve(root, '.coord/authority/public.pem')
|
|
259
|
+
for (const file of [leaseFile, publicFile]) {
|
|
260
|
+
const status = await lstat(file)
|
|
261
|
+
if (!status.isFile() || status.isSymbolicLink()) {
|
|
262
|
+
coordinatorError('SWARM_COORD_QUEUE_GRANT_INVALID', 'queued lease and public key must be regular files')
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const storedLease = JSON.parse(await readFile(leaseFile, 'utf8'))
|
|
266
|
+
const publicKey = await readFile(publicFile, 'utf8')
|
|
267
|
+
if (JSON.stringify(storedLease) !== JSON.stringify(lease) || lease.authorityKeyId !== sha256(publicKey)
|
|
268
|
+
|| typeof lease.signature !== 'string'
|
|
269
|
+
|| !verify(null, Buffer.from(JSON.stringify(leasePayload(lease))), publicKey, Buffer.from(lease.signature, 'base64url'))) {
|
|
270
|
+
coordinatorError('SWARM_COORD_QUEUE_GRANT_INVALID', 'queued lease does not match the stored signed grant')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function checkedQueueGrant(state, queued, root) {
|
|
275
|
+
const grant = queued.grant
|
|
276
|
+
const lock = grant && state.locks.find((entry) => entry.lockId === grant.lockId)
|
|
277
|
+
const request = queued.request
|
|
278
|
+
const fields = ['taskId', 'chainId', 'agentId', 'lockType', 'resource']
|
|
279
|
+
if (!lock || fields.some((field) => lock[field] !== request[field])
|
|
280
|
+
|| JSON.stringify(lock.paths) !== JSON.stringify(request.paths)
|
|
281
|
+
|| lock.leasePath !== grant.leasePath || !grant.lease
|
|
282
|
+
|| ['lockId', 'leaseId', 'chainId', 'agentId', 'lockType', 'resource', 'issuedAt', 'expiresAt']
|
|
283
|
+
.some((field) => lock[field] !== grant.lease[field])
|
|
284
|
+
|| JSON.stringify(lock.paths) !== JSON.stringify(grant.lease.paths)) {
|
|
285
|
+
coordinatorError('SWARM_COORD_QUEUE_GRANT_INVALID', 'queued grant does not match its original request and current lease')
|
|
256
286
|
}
|
|
257
|
-
|
|
287
|
+
if (lock.status !== 'active' || !Number.isFinite(Date.parse(lock.expiresAt)) || Date.parse(lock.expiresAt) <= Date.now()) {
|
|
288
|
+
coordinatorError('SWARM_COORD_QUEUE_GRANT_EXPIRED', 'queued grant is no longer an active unexpired lock')
|
|
289
|
+
}
|
|
290
|
+
await checkStoredQueueLease(root, grant)
|
|
291
|
+
return { lock, leasePath: grant.leasePath, lease: grant.lease }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function lockQueueStatus(repositoryRoot, input) {
|
|
295
|
+
return withCoordinationReadLock(repositoryRoot, async (state, root) => {
|
|
296
|
+
const task = findTask(state, input)
|
|
297
|
+
const queueId = identifier(input.queueId, 'queueId')
|
|
298
|
+
const queued = state.queue.find((entry) => entry.queueId === queueId)
|
|
299
|
+
if (!queued || ['taskId', 'agentId', 'chainId'].some((field) => queued.request[field] !== input[field])) {
|
|
300
|
+
coordinatorError('SWARM_COORD_QUEUE_NOT_FOUND', 'queue does not belong to the requested task, agent and chain')
|
|
301
|
+
}
|
|
302
|
+
if (['completed', 'failed', 'reclaimed'].includes(task.status) || ['rejected', 'timed-out'].includes(queued.status)) {
|
|
303
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', queued, reason: 'queue-no-longer-runnable',
|
|
304
|
+
confirmationRequired: queued.status === 'timed-out',
|
|
305
|
+
decisionId: queued.decisionId, confirmProtocolRequest: queued.confirmProtocolRequest,
|
|
306
|
+
requiredAction: 'Resolve any human decision and submit a new explicit request; this queue cannot grant a lock.' }
|
|
307
|
+
}
|
|
308
|
+
if (queued.status === 'queued') {
|
|
309
|
+
if (!Number.isFinite(Date.parse(queued.deadlineAt)) || Date.parse(queued.deadlineAt) <= Date.now()) {
|
|
310
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', queued, reason: 'queue-deadline-expired-or-missing',
|
|
311
|
+
requiredAction: 'Run tick to record the terminal queue outcome; do not repeat lock-acquire.' }
|
|
312
|
+
}
|
|
313
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'queued', queued }
|
|
314
|
+
}
|
|
315
|
+
if (queued.status !== 'granted') coordinatorError('SWARM_COORD_QUEUE_STATE_INVALID', 'unknown queued lock state')
|
|
316
|
+
assertTaskRunnable(state, task)
|
|
317
|
+
if (!validHandshake(task, queued.request)) {
|
|
318
|
+
coordinatorError('SWARM_COORD_BASELINE_HANDSHAKE_REQUIRED', 'queued grant requires the original current baseline handshake')
|
|
319
|
+
}
|
|
320
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'granted', queueId, ...await checkedQueueGrant(state, queued, root) }
|
|
321
|
+
})
|
|
258
322
|
}
|
|
259
323
|
|
|
260
324
|
async function releaseLock(repositoryRoot, input) {
|
|
@@ -271,9 +335,9 @@ async function releaseLock(repositoryRoot, input) {
|
|
|
271
335
|
const event = eventRecord(lock.agentId, 'lock-released', { lockId, resource: lock.resource }, now)
|
|
272
336
|
state.events.push(event)
|
|
273
337
|
const wakePackages = routeEvent(state, event)
|
|
274
|
-
const promoted = await promoteQueue(state, root, now)
|
|
275
|
-
return { state, output: { schemaVersion: LOCAL_SCHEMA, released: lock, promoted, wakePackages },
|
|
276
|
-
audit: [{ event: 'lock-released', lockId, taskId: task.taskId }] }
|
|
338
|
+
const { promoted, decisions } = await promoteQueue(state, root, now)
|
|
339
|
+
return { state, output: { schemaVersion: LOCAL_SCHEMA, released: lock, promoted, wakePackages, decisions },
|
|
340
|
+
audit: [{ event: 'lock-released', lockId, taskId: task.taskId, queueTimeouts: decisions.length }] }
|
|
277
341
|
})
|
|
278
342
|
}
|
|
279
343
|
|
|
@@ -288,7 +352,12 @@ async function baselineHandshake(repositoryRoot, input) {
|
|
|
288
352
|
task.baselineHandshake = matched ? {
|
|
289
353
|
handshakeId: `handshake-${randomUUID()}`, baselineHash: observed, checkedAt: now,
|
|
290
354
|
} : null
|
|
291
|
-
if (!matched) task.status = '
|
|
355
|
+
if (!matched) { task.status = 'blocked'; task.blockedReason = 'baseline-mismatch' }
|
|
356
|
+
else if (!hasActiveWait(state, task) && !pendingDecision(state, task)
|
|
357
|
+
&& task.status === 'blocked' && task.blockedReason === 'baseline-mismatch') {
|
|
358
|
+
task.status = 'active'
|
|
359
|
+
task.blockedReason = null
|
|
360
|
+
}
|
|
292
361
|
const message = addMessage(state, 'baseline-handshake', 'coordinator', task.agentId, {
|
|
293
362
|
matched, expectedBaselineHash: task.baselineHash, observedBaselineHash: observed,
|
|
294
363
|
refetchPaths: input.refetchPaths,
|
|
@@ -300,162 +369,36 @@ async function baselineHandshake(repositoryRoot, input) {
|
|
|
300
369
|
})
|
|
301
370
|
}
|
|
302
371
|
|
|
303
|
-
function applyCycles(state, now) {
|
|
304
|
-
const cycles = detectWaitCycles(state.waits)
|
|
305
|
-
const requests = []
|
|
306
|
-
for (const cycle of cycles) {
|
|
307
|
-
const agents = cycle.slice(0, -1)
|
|
308
|
-
const waits = state.waits.filter((wait) => wait.status === 'active'
|
|
309
|
-
&& agents.includes(wait.waiter) && agents.includes(wait.waitFor))
|
|
310
|
-
for (const wait of waits) wakeWait(state, wait, 'deadlock-interrupted', { cycle }, now)
|
|
311
|
-
requests.push(createDecision(state, 'dependency-cycle', agents, waits.map((wait) => wait.waitId),
|
|
312
|
-
`检测到依赖等待成环:${agents.join(' → ')}。请选择先恢复的智能体。`,
|
|
313
|
-
'依赖成环会导致全部相关任务无限等待,必须由真人决定执行顺序。', now))
|
|
314
|
-
}
|
|
315
|
-
return requests
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function dependencyWait(repositoryRoot, input) {
|
|
319
|
-
return withCoordinationState(repositoryRoot, async (state) => {
|
|
320
|
-
const task = findTask(state, { taskId: input.taskId, agentId: input.waiter, chainId: input.chainId })
|
|
321
|
-
const wait = normalizeWait(input)
|
|
322
|
-
if (!state.tasks.some((item) => item.agentId === wait.waitFor)) {
|
|
323
|
-
coordinatorError('SWARM_COORD_WAIT_TARGET_UNKNOWN', 'waitFor must identify a registered agent')
|
|
324
|
-
}
|
|
325
|
-
task.status = 'waiting'
|
|
326
|
-
task.updatedAt = wait.startedAt
|
|
327
|
-
state.waits.push(wait)
|
|
328
|
-
addMessage(state, 'dependency-wait', wait.waiter, wait.waitFor, {
|
|
329
|
-
waitId: wait.waitId, event: wait.event, deadlineAt: wait.deadlineAt, purpose: wait.purpose,
|
|
330
|
-
}, wait.startedAt)
|
|
331
|
-
const decisions = applyCycles(state, wait.startedAt)
|
|
332
|
-
return { state, output: { schemaVersion: LOCAL_SCHEMA, wait, suspended: wait.status === 'active', decisions },
|
|
333
|
-
audit: [{ event: 'dependency-wait', waitId: wait.waitId, waiter: wait.waiter, waitFor: wait.waitFor }] }
|
|
334
|
-
})
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async function publishEvent(repositoryRoot, input) {
|
|
338
|
-
return withCoordinationState(repositoryRoot, async (state) => {
|
|
339
|
-
const publisher = identifier(input.publisher, 'publisher')
|
|
340
|
-
const eventName = identifier(input.event, 'event')
|
|
341
|
-
if (!input.payload || typeof input.payload !== 'object' || Array.isArray(input.payload)) {
|
|
342
|
-
coordinatorError('SWARM_COORD_EVENT_PAYLOAD_INVALID', 'payload must be an object')
|
|
343
|
-
}
|
|
344
|
-
const event = eventRecord(publisher, eventName, input.payload, new Date().toISOString())
|
|
345
|
-
state.events.push(event)
|
|
346
|
-
const wakePackages = routeEvent(state, event)
|
|
347
|
-
return { state, output: { schemaVersion: LOCAL_SCHEMA, event, wakePackages },
|
|
348
|
-
audit: [{ event: 'event-published', eventId: event.eventId, publisher, eventName }] }
|
|
349
|
-
})
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function notifyDeadTask(state, task, now) {
|
|
353
|
-
return state.waits.filter((wait) => wait.status === 'active' && wait.waitFor === task.agentId)
|
|
354
|
-
.map((wait) => wakeWait(state, wait, 'dependency-terminated', {
|
|
355
|
-
event: 'task-terminated', publisher: task.agentId, payload: { taskId: task.taskId, status: task.status },
|
|
356
|
-
publishedAt: now,
|
|
357
|
-
}, now))
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function taskStatus(repositoryRoot, input) {
|
|
361
|
-
return withCoordinationState(repositoryRoot, async (state) => {
|
|
362
|
-
const task = findTask(state, input)
|
|
363
|
-
const status = requireTaskStatus(input.status)
|
|
364
|
-
const now = new Date().toISOString()
|
|
365
|
-
task.status = status
|
|
366
|
-
task.updatedAt = now
|
|
367
|
-
const undeclaredWait = status === 'waiting' && !state.waits.some((wait) => (
|
|
368
|
-
wait.status === 'active' && wait.taskId === task.taskId
|
|
369
|
-
))
|
|
370
|
-
let declarationMessage = null
|
|
371
|
-
if (undeclaredWait) declarationMessage = addMessage(state, 'dependency-wait', 'coordinator', task.agentId,
|
|
372
|
-
{ status: 'declaration-required', taskId: task.taskId }, now)
|
|
373
|
-
const wakePackages = status === 'failed' || status === 'reclaimed'
|
|
374
|
-
? notifyDeadTask(state, task, now) : []
|
|
375
|
-
return { state, output: { schemaVersion: LOCAL_SCHEMA, task, undeclaredWait,
|
|
376
|
-
declarationMessage, wakePackages }, audit: [{ event: 'task-status', taskId: task.taskId, status }] }
|
|
377
|
-
})
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function updateTimeoutCount(state, waiter) {
|
|
381
|
-
let entry = state.timeoutCounts.find((item) => item.waiter === waiter)
|
|
382
|
-
if (!entry) {
|
|
383
|
-
entry = { waiter, count: 0 }
|
|
384
|
-
state.timeoutCounts.push(entry)
|
|
385
|
-
}
|
|
386
|
-
entry.count += 1
|
|
387
|
-
return entry.count
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function applyTimeouts(state, now) {
|
|
391
|
-
const timedOut = state.waits.filter((wait) => wait.status === 'active'
|
|
392
|
-
&& Date.parse(wait.deadlineAt) <= Date.parse(now))
|
|
393
|
-
const decisions = []
|
|
394
|
-
const wakePackages = []
|
|
395
|
-
for (const wait of timedOut) {
|
|
396
|
-
const count = updateTimeoutCount(state, wait.waiter)
|
|
397
|
-
if (wait.onTimeout === 'escalate-need-human' || count >= 2) {
|
|
398
|
-
wakePackages.push(wakeWait(state, wait, 'timeout-interrupted', { timeoutCount: count }, now))
|
|
399
|
-
decisions.push(createDecision(state, 'dependency-timeout', [wait.waiter, wait.waitFor], [wait.waitId],
|
|
400
|
-
`${wait.waiter} 等待 ${wait.waitFor} 的 ${wait.event} 已超时,请选择后续动作。`,
|
|
401
|
-
'依赖事件未在声明期限内到达,继续静默等待可能导致任务停滞。', now))
|
|
402
|
-
} else {
|
|
403
|
-
const resolution = wait.onTimeout === 'abandon-wait' ? 'timeout-abandoned' : 'timeout-continued'
|
|
404
|
-
wakePackages.push(wakeWait(state, wait, resolution, { timeoutCount: count }, now))
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
return { timedOut, decisions, wakePackages }
|
|
408
|
-
}
|
|
409
|
-
|
|
410
372
|
async function tick(repositoryRoot, input) {
|
|
411
373
|
return withCoordinationState(repositoryRoot, async (state, root) => {
|
|
412
|
-
const
|
|
413
|
-
if (!Number.isFinite(
|
|
374
|
+
const observedMs = Date.parse(input.now)
|
|
375
|
+
if (typeof input.now !== 'string' || !Number.isFinite(observedMs)) {
|
|
376
|
+
coordinatorError('SWARM_COORD_NOW_INVALID', 'now must be an ISO date-time observation')
|
|
377
|
+
}
|
|
378
|
+
const observedAt = new Date(observedMs).toISOString()
|
|
379
|
+
const nowMs = Date.now()
|
|
414
380
|
const now = new Date(nowMs).toISOString()
|
|
415
381
|
const expiredLocks = state.locks.filter((lock) => lock.status === 'active'
|
|
416
382
|
&& Date.parse(lock.expiresAt) <= nowMs)
|
|
383
|
+
const lockWakePackages = []
|
|
417
384
|
for (const lock of expiredLocks) {
|
|
418
385
|
lock.status = 'expired'
|
|
419
386
|
lock.releasedAt = now
|
|
387
|
+
const event = eventRecord(lock.agentId, 'lock-released', { lockId: lock.lockId, resource: lock.resource, reason: 'expired' }, now)
|
|
388
|
+
state.events.push(event)
|
|
389
|
+
lockWakePackages.push(...routeEvent(state, event))
|
|
420
390
|
}
|
|
421
|
-
const promoted = await promoteQueue(state, root, now)
|
|
391
|
+
const { promoted, decisions } = await promoteQueue(state, root, now)
|
|
422
392
|
const timeouts = applyTimeouts(state, now)
|
|
423
393
|
const cycles = applyCycles(state, now)
|
|
424
|
-
return { state, output: { schemaVersion: LOCAL_SCHEMA, expiredLocks, promoted,
|
|
425
|
-
timedOutWaits: timeouts.timedOut, wakePackages: timeouts.wakePackages,
|
|
426
|
-
decisions: [...timeouts.decisions, ...cycles] },
|
|
427
|
-
audit: [{ event: 'coordination-tick', expiredLocks: expiredLocks.length,
|
|
394
|
+
return { state, output: { schemaVersion: LOCAL_SCHEMA, observedAt, scannedAt: now, expiredLocks, promoted,
|
|
395
|
+
timedOutWaits: timeouts.timedOut, wakePackages: [...lockWakePackages, ...timeouts.wakePackages],
|
|
396
|
+
decisions: [...decisions, ...timeouts.decisions, ...cycles] },
|
|
397
|
+
audit: [{ event: 'coordination-tick', observedAt, scannedAt: now, queueTimeouts: decisions.length, expiredLocks: expiredLocks.length,
|
|
428
398
|
timedOutWaits: timeouts.timedOut.length, deadlocks: cycles.length }] }
|
|
429
399
|
})
|
|
430
400
|
}
|
|
431
401
|
|
|
432
|
-
async function resolveHuman(repositoryRoot, input) {
|
|
433
|
-
return withCoordinationState(repositoryRoot, async (state) => {
|
|
434
|
-
const decisionId = identifier(input.decisionId, 'decisionId')
|
|
435
|
-
const decision = state.decisions.find((item) => item.decisionId === decisionId)
|
|
436
|
-
if (!decision || decision.status !== 'pending') {
|
|
437
|
-
coordinatorError('SWARM_COORD_DECISION_NOT_PENDING', 'decision is not pending')
|
|
438
|
-
}
|
|
439
|
-
const answer = requireString(input.answer, 'answer')
|
|
440
|
-
const allowed = new Set([...decision.agents.map((agent) => `resume:${agent}`), 'abort'])
|
|
441
|
-
if (!allowed.has(answer)) coordinatorError('SWARM_COORD_DECISION_ANSWER_INVALID', 'answer is not a declared option')
|
|
442
|
-
const now = new Date().toISOString()
|
|
443
|
-
decision.status = 'resolved'
|
|
444
|
-
decision.answer = answer
|
|
445
|
-
decision.actorId = identifier(input.actorId, 'actorId')
|
|
446
|
-
decision.resolvedAt = now
|
|
447
|
-
if (answer.startsWith('resume:')) {
|
|
448
|
-
const agent = answer.slice('resume:'.length)
|
|
449
|
-
for (const task of state.tasks.filter((item) => item.agentId === agent && item.status === 'waiting')) {
|
|
450
|
-
task.status = 'active'
|
|
451
|
-
task.updatedAt = now
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
return { state, output: { schemaVersion: LOCAL_SCHEMA, decision },
|
|
455
|
-
audit: [{ event: 'decision-resolved', decisionId, answer, actorId: decision.actorId }] }
|
|
456
|
-
})
|
|
457
|
-
}
|
|
458
|
-
|
|
459
402
|
async function coordinatorStatus(repositoryRoot) {
|
|
460
403
|
const state = await readCoordinationState(repositoryRoot)
|
|
461
404
|
return { schemaVersion: LOCAL_SCHEMA, state }
|
|
@@ -465,6 +408,7 @@ const HANDLERS = Object.freeze({
|
|
|
465
408
|
'register-task': registerTask,
|
|
466
409
|
'conflict-scan': conflictScan,
|
|
467
410
|
'lock-acquire': acquireLock,
|
|
411
|
+
'lock-queue-status': lockQueueStatus,
|
|
468
412
|
'lock-renew': renewLock,
|
|
469
413
|
'lock-release': releaseLock,
|
|
470
414
|
'baseline-handshake': baselineHandshake,
|
|
@@ -473,6 +417,8 @@ const HANDLERS = Object.freeze({
|
|
|
473
417
|
'task-status': taskStatus,
|
|
474
418
|
tick,
|
|
475
419
|
'resolve-human': resolveHuman,
|
|
420
|
+
'wait-for-event': (root, input) => waitForEvent(root, input, tick),
|
|
421
|
+
'wait-cancel': cancelWait,
|
|
476
422
|
status: coordinatorStatus,
|
|
477
423
|
})
|
|
478
424
|
|