cli-swarm 7.0.18 → 7.0.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 +16 -1
- package/broker.mjs +301 -0
- package/cli.mjs +54 -12
- package/installer.mjs +57 -154
- package/package.json +8 -3
- package/skill/SKILL.md +25 -18
- package/skill/references/autocoord.md +33 -0
- package/skill/skill.json +2 -2
- package/swarm-coordinator-fs.mjs +265 -0
- package/swarm-coordinator-model.mjs +272 -0
- package/swarm-coordinator.mjs +500 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { coordinatorError, identifier, readCoordinationState,
|
|
3
|
+
withCoordinationState, writeSignedLease } from './swarm-coordinator-fs.mjs'
|
|
4
|
+
import { coordinationMessage, detectWaitCycles, locksConflict, normalizeLockRequest,
|
|
5
|
+
normalizeTaskCard, normalizeWait, requireString, requireTaskStatus,
|
|
6
|
+
scanTaskConflicts, validateInputShape } from './swarm-coordinator-model.mjs'
|
|
7
|
+
|
|
8
|
+
const LOCAL_SCHEMA = 'swarm.coordinator-local/1.0'
|
|
9
|
+
const OPERATIONS = Object.freeze([
|
|
10
|
+
'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',
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
const objectSchema = (required, properties) => ({
|
|
16
|
+
type: 'object', additionalProperties: false, required, properties,
|
|
17
|
+
})
|
|
18
|
+
const string = { type: 'string', minLength: 1 }
|
|
19
|
+
const stringArray = { type: 'array', items: string }
|
|
20
|
+
const nullableString = { type: ['string', 'null'] }
|
|
21
|
+
const TASK_CARD_SCHEMA = objectSchema(
|
|
22
|
+
['taskId', 'agentId', 'chainId', 'taskScope', 'plannedActions', 'deployTarget', 'eta', 'baselineHash', 'archConstraints'],
|
|
23
|
+
{ taskId: string, agentId: string, chainId: string, taskScope: stringArray,
|
|
24
|
+
plannedActions: stringArray, deployTarget: nullableString, eta: string,
|
|
25
|
+
baselineHash: string, archConstraints: stringArray },
|
|
26
|
+
)
|
|
27
|
+
const LOCK_SCHEMA = objectSchema(
|
|
28
|
+
['taskId', 'agentId', 'chainId', 'lockType', 'resource', 'paths', 'ttlSeconds', 'queueOnConflict', 'baselineHandshakeId'],
|
|
29
|
+
{ taskId: string, agentId: string, chainId: string,
|
|
30
|
+
lockType: { enum: ['file', 'build', 'deploy'] }, resource: nullableString, paths: stringArray,
|
|
31
|
+
ttlSeconds: { type: 'integer', minimum: 1, maximum: 3600 }, queueOnConflict: { type: 'boolean' },
|
|
32
|
+
baselineHandshakeId: nullableString },
|
|
33
|
+
)
|
|
34
|
+
const WAIT_SCHEMA = objectSchema(
|
|
35
|
+
['taskId', 'chainId', 'waiter', 'waitFor', 'event', 'purpose', 'expectedWithinMs', 'onEvent', 'onTimeout', 'refetchPaths'],
|
|
36
|
+
{ taskId: string, chainId: string, waiter: string, waitFor: string, event: string, purpose: string,
|
|
37
|
+
expectedWithinMs: { type: 'integer', minimum: 1 }, onEvent: { const: 'wake-with-package' },
|
|
38
|
+
onTimeout: { enum: ['escalate-need-human', 'abandon-wait', 'continue-after-timeout'] },
|
|
39
|
+
refetchPaths: stringArray },
|
|
40
|
+
)
|
|
41
|
+
const OPERATION_SCHEMAS = Object.freeze({
|
|
42
|
+
capabilities: objectSchema([], {}),
|
|
43
|
+
'register-task': TASK_CARD_SCHEMA,
|
|
44
|
+
'conflict-scan': objectSchema(['taskId'], { taskId: string }),
|
|
45
|
+
'lock-acquire': LOCK_SCHEMA,
|
|
46
|
+
'lock-renew': objectSchema(['lockId', 'taskId', 'agentId', 'chainId', 'ttlSeconds'],
|
|
47
|
+
{ lockId: string, taskId: string, agentId: string, chainId: string,
|
|
48
|
+
ttlSeconds: { type: 'integer', minimum: 1, maximum: 3600 } }),
|
|
49
|
+
'lock-release': objectSchema(['lockId', 'taskId', 'agentId', 'chainId'],
|
|
50
|
+
{ lockId: string, taskId: string, agentId: string, chainId: string }),
|
|
51
|
+
'baseline-handshake': objectSchema(['taskId', 'agentId', 'chainId', 'observedBaselineHash', 'refetchPaths'],
|
|
52
|
+
{ taskId: string, agentId: string, chainId: string, observedBaselineHash: string, refetchPaths: stringArray }),
|
|
53
|
+
'dependency-wait': WAIT_SCHEMA,
|
|
54
|
+
'event-publish': objectSchema(['publisher', 'event', 'payload'],
|
|
55
|
+
{ publisher: string, event: string, payload: { type: 'object' } }),
|
|
56
|
+
'task-status': objectSchema(['taskId', 'agentId', 'chainId', 'status'],
|
|
57
|
+
{ taskId: string, agentId: string, chainId: string,
|
|
58
|
+
status: { enum: ['active', 'waiting', 'completed', 'failed', 'reclaimed'] } }),
|
|
59
|
+
tick: objectSchema(['now'], { now: { type: 'string', format: 'date-time' } }),
|
|
60
|
+
'resolve-human': objectSchema(['decisionId', 'answer', 'actorId'],
|
|
61
|
+
{ decisionId: string, answer: string, actorId: string }),
|
|
62
|
+
status: objectSchema([], {}),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function sha256(value) { return createHash('sha256').update(value).digest('hex') }
|
|
66
|
+
|
|
67
|
+
function findTask(state, input) {
|
|
68
|
+
const taskId = identifier(input.taskId, 'taskId')
|
|
69
|
+
const task = state.tasks.find((item) => item.taskId === taskId)
|
|
70
|
+
if (!task) coordinatorError('SWARM_COORD_TASK_NOT_FOUND', `task ${taskId} is not registered`)
|
|
71
|
+
if (task.agentId !== identifier(input.agentId, 'agentId')
|
|
72
|
+
|| task.chainId !== identifier(input.chainId, 'chainId')) {
|
|
73
|
+
coordinatorError('SWARM_COORD_TASK_AUTHORITY_DENIED', 'task ownership does not match')
|
|
74
|
+
}
|
|
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, confirmProtocolRequest: confirmationRequest(decision) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function registerTask(repositoryRoot, input) {
|
|
146
|
+
return withCoordinationState(repositoryRoot, async (state) => {
|
|
147
|
+
const card = normalizeTaskCard(input)
|
|
148
|
+
if (state.tasks.some((task) => task.taskId === card.taskId)) {
|
|
149
|
+
coordinatorError('SWARM_COORD_TASK_EXISTS', `task ${card.taskId} is already registered`)
|
|
150
|
+
}
|
|
151
|
+
state.tasks.push(card)
|
|
152
|
+
return { state, output: { schemaVersion: LOCAL_SCHEMA, task: card },
|
|
153
|
+
audit: [{ event: 'range-declare', taskId: card.taskId, agentId: card.agentId, taskScope: card.taskScope }] }
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function conflictScan(repositoryRoot, input) {
|
|
158
|
+
return withCoordinationState(repositoryRoot, async (state) => {
|
|
159
|
+
const taskId = identifier(input.taskId, 'taskId')
|
|
160
|
+
const task = state.tasks.find((item) => item.taskId === taskId)
|
|
161
|
+
if (!task) coordinatorError('SWARM_COORD_TASK_NOT_FOUND', `task ${taskId} is not registered`)
|
|
162
|
+
const conflicts = scanTaskConflicts(task, state.tasks)
|
|
163
|
+
const messages = conflicts.filter((conflict) => conflict.type !== 'requirement')
|
|
164
|
+
.map((conflict) => addMessage(state, 'conflict-alert', 'coordinator', task.agentId, conflict))
|
|
165
|
+
const decisions = conflicts.filter((conflict) => conflict.type === 'requirement').map((conflict) => {
|
|
166
|
+
const other = state.tasks.find((item) => item.taskId === conflict.taskId)
|
|
167
|
+
if (!other) coordinatorError('SWARM_COORD_TASK_NOT_FOUND', `task ${conflict.taskId} is not registered`)
|
|
168
|
+
task.status = 'waiting'
|
|
169
|
+
other.status = 'waiting'
|
|
170
|
+
return createDecision(state, 'requirement-conflict', [task.agentId, other.agentId], [],
|
|
171
|
+
`任务 ${task.taskId} 与 ${conflict.taskId} 的需求声明冲突,请选择先恢复的智能体。`,
|
|
172
|
+
'矛盾需求同时执行会产生不可预测的覆盖,必须由真人裁决。', new Date().toISOString())
|
|
173
|
+
})
|
|
174
|
+
return { state, output: { schemaVersion: LOCAL_SCHEMA, taskId, conflicts, messages,
|
|
175
|
+
decisions, zeroConflict: conflicts.length === 0 }, audit: [{ event: 'conflict-scan', taskId, conflictCount: conflicts.length }] }
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function grantLock(state, root, request, now) {
|
|
180
|
+
const issuedAt = now
|
|
181
|
+
const lockId = `lock-${randomUUID()}`
|
|
182
|
+
const leaseId = `lease-${randomUUID()}`
|
|
183
|
+
const expiresAt = new Date(Date.parse(now) + request.ttlSeconds * 1_000).toISOString()
|
|
184
|
+
const lease = await writeSignedLease(root, {
|
|
185
|
+
leaseId, lockId, chainId: request.chainId, agentId: request.agentId,
|
|
186
|
+
lockType: request.lockType, resource: request.resource, paths: request.paths,
|
|
187
|
+
issuedAt, expiresAt, nonce: randomUUID(),
|
|
188
|
+
}, sha256)
|
|
189
|
+
const lock = { schemaVersion: 'swarm.coord-lock/1.0', lockId, leaseId,
|
|
190
|
+
leasePath: lease.relativeLeasePath, taskId: request.taskId, chainId: request.chainId,
|
|
191
|
+
agentId: request.agentId, lockType: request.lockType, resource: request.resource,
|
|
192
|
+
paths: request.paths, status: 'active', issuedAt, expiresAt, releasedAt: null }
|
|
193
|
+
state.locks.push(lock)
|
|
194
|
+
addMessage(state, 'lock-granted', 'coordinator', request.agentId, { lockId, leaseId, expiresAt }, now)
|
|
195
|
+
return { lock, leasePath: lease.relativeLeasePath, lease: lease.signedLease }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validHandshake(task, request) {
|
|
199
|
+
if (request.lockType === 'file') return request.baselineHandshakeId === null
|
|
200
|
+
return task.baselineHandshake !== null
|
|
201
|
+
&& request.baselineHandshakeId === task.baselineHandshake.handshakeId
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function acquireLock(repositoryRoot, input) {
|
|
205
|
+
return withCoordinationState(repositoryRoot, async (state, root) => {
|
|
206
|
+
const request = normalizeLockRequest(input)
|
|
207
|
+
const task = findTask(state, request)
|
|
208
|
+
if (!validHandshake(task, request)) {
|
|
209
|
+
coordinatorError('SWARM_COORD_BASELINE_HANDSHAKE_REQUIRED', 'build and deploy locks require the current baseline handshake')
|
|
210
|
+
}
|
|
211
|
+
const conflicts = state.locks.filter((lock) => locksConflict(request, lock))
|
|
212
|
+
if (!conflicts.length) {
|
|
213
|
+
const granted = await grantLock(state, root, request, new Date().toISOString())
|
|
214
|
+
return { state, output: { schemaVersion: LOCAL_SCHEMA, status: 'granted', ...granted },
|
|
215
|
+
audit: [{ event: 'lock-granted', lockId: granted.lock.lockId, taskId: request.taskId }] }
|
|
216
|
+
}
|
|
217
|
+
if (!request.queueOnConflict) coordinatorError('SWARM_COORD_LOCK_DENIED', 'the requested lock conflicts with an active lock')
|
|
218
|
+
const queued = { schemaVersion: 'swarm.coord-queue/1.0', queueId: `queue-${randomUUID()}`,
|
|
219
|
+
request, blockingLockIds: conflicts.map((lock) => lock.lockId), status: 'queued',
|
|
220
|
+
enqueuedAt: new Date().toISOString(), resolvedAt: null }
|
|
221
|
+
state.queue.push(queued)
|
|
222
|
+
addMessage(state, 'lock-denied', 'coordinator', request.agentId,
|
|
223
|
+
{ queueId: queued.queueId, blockingLockIds: queued.blockingLockIds }, queued.enqueuedAt)
|
|
224
|
+
return { state, output: { schemaVersion: LOCAL_SCHEMA, status: 'queued', queued },
|
|
225
|
+
audit: [{ event: 'lock-queued', queueId: queued.queueId, taskId: request.taskId }] }
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function renewLock(repositoryRoot, input) {
|
|
230
|
+
return withCoordinationState(repositoryRoot, async (state, root) => {
|
|
231
|
+
const task = findTask(state, input)
|
|
232
|
+
const lockId = identifier(input.lockId, 'lockId')
|
|
233
|
+
const lock = state.locks.find((item) => item.lockId === lockId)
|
|
234
|
+
if (!lock || lock.status !== 'active' || lock.taskId !== task.taskId) {
|
|
235
|
+
coordinatorError('SWARM_COORD_LOCK_NOT_ACTIVE', 'lock is not active for this task')
|
|
236
|
+
}
|
|
237
|
+
if (!Number.isInteger(input.ttlSeconds) || input.ttlSeconds < 1 || input.ttlSeconds > 3_600) {
|
|
238
|
+
coordinatorError('SWARM_COORD_LOCK_TTL_INVALID', 'ttlSeconds must be 1..3600')
|
|
239
|
+
}
|
|
240
|
+
lock.status = 'renewed'
|
|
241
|
+
const request = { ...lock, ttlSeconds: input.ttlSeconds }
|
|
242
|
+
const granted = await grantLock(state, root, request, new Date().toISOString())
|
|
243
|
+
return { state, output: { schemaVersion: LOCAL_SCHEMA, status: 'renewed', ...granted },
|
|
244
|
+
audit: [{ event: 'lock-renewed', previousLockId: lockId, lockId: granted.lock.lockId }] }
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function promoteQueue(state, root, now) {
|
|
249
|
+
const promoted = []
|
|
250
|
+
for (const queued of state.queue.filter((item) => item.status === 'queued')) {
|
|
251
|
+
const conflicts = state.locks.filter((lock) => locksConflict(queued.request, lock))
|
|
252
|
+
if (conflicts.length) continue
|
|
253
|
+
queued.status = 'granted'
|
|
254
|
+
queued.resolvedAt = now
|
|
255
|
+
promoted.push(await grantLock(state, root, queued.request, now))
|
|
256
|
+
}
|
|
257
|
+
return promoted
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function releaseLock(repositoryRoot, input) {
|
|
261
|
+
return withCoordinationState(repositoryRoot, async (state, root) => {
|
|
262
|
+
const task = findTask(state, input)
|
|
263
|
+
const lockId = identifier(input.lockId, 'lockId')
|
|
264
|
+
const lock = state.locks.find((item) => item.lockId === lockId)
|
|
265
|
+
if (!lock || lock.status !== 'active' || lock.taskId !== task.taskId) {
|
|
266
|
+
coordinatorError('SWARM_COORD_LOCK_NOT_ACTIVE', 'lock is not active for this task')
|
|
267
|
+
}
|
|
268
|
+
const now = new Date().toISOString()
|
|
269
|
+
lock.status = 'released'
|
|
270
|
+
lock.releasedAt = now
|
|
271
|
+
const event = eventRecord(lock.agentId, 'lock-released', { lockId, resource: lock.resource }, now)
|
|
272
|
+
state.events.push(event)
|
|
273
|
+
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 }] }
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function baselineHandshake(repositoryRoot, input) {
|
|
281
|
+
return withCoordinationState(repositoryRoot, async (state) => {
|
|
282
|
+
const task = findTask(state, input)
|
|
283
|
+
const observed = requireString(input.observedBaselineHash, 'observedBaselineHash')
|
|
284
|
+
if (!/^[0-9a-f]{64}$/.test(observed)) coordinatorError('SWARM_COORD_BASELINE_INVALID', 'observedBaselineHash must be SHA-256')
|
|
285
|
+
if (!Array.isArray(input.refetchPaths)) coordinatorError('SWARM_COORD_REFETCH_REQUIRED', 'refetchPaths must be an array')
|
|
286
|
+
const now = new Date().toISOString()
|
|
287
|
+
const matched = observed === task.baselineHash
|
|
288
|
+
task.baselineHandshake = matched ? {
|
|
289
|
+
handshakeId: `handshake-${randomUUID()}`, baselineHash: observed, checkedAt: now,
|
|
290
|
+
} : null
|
|
291
|
+
if (!matched) task.status = 'waiting'
|
|
292
|
+
const message = addMessage(state, 'baseline-handshake', 'coordinator', task.agentId, {
|
|
293
|
+
matched, expectedBaselineHash: task.baselineHash, observedBaselineHash: observed,
|
|
294
|
+
refetchPaths: input.refetchPaths,
|
|
295
|
+
}, now)
|
|
296
|
+
return { state, output: { schemaVersion: LOCAL_SCHEMA, allowed: matched,
|
|
297
|
+
handshake: task.baselineHandshake, message,
|
|
298
|
+
requiredAction: matched ? null : 'refetch-baseline' },
|
|
299
|
+
audit: [{ event: 'baseline-handshake', taskId: task.taskId, matched }] }
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
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
|
+
async function tick(repositoryRoot, input) {
|
|
411
|
+
return withCoordinationState(repositoryRoot, async (state, root) => {
|
|
412
|
+
const nowMs = Date.parse(input.now)
|
|
413
|
+
if (!Number.isFinite(nowMs)) coordinatorError('SWARM_COORD_NOW_INVALID', 'now must be an ISO date-time')
|
|
414
|
+
const now = new Date(nowMs).toISOString()
|
|
415
|
+
const expiredLocks = state.locks.filter((lock) => lock.status === 'active'
|
|
416
|
+
&& Date.parse(lock.expiresAt) <= nowMs)
|
|
417
|
+
for (const lock of expiredLocks) {
|
|
418
|
+
lock.status = 'expired'
|
|
419
|
+
lock.releasedAt = now
|
|
420
|
+
}
|
|
421
|
+
const promoted = await promoteQueue(state, root, now)
|
|
422
|
+
const timeouts = applyTimeouts(state, now)
|
|
423
|
+
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,
|
|
428
|
+
timedOutWaits: timeouts.timedOut.length, deadlocks: cycles.length }] }
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
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
|
+
async function coordinatorStatus(repositoryRoot) {
|
|
460
|
+
const state = await readCoordinationState(repositoryRoot)
|
|
461
|
+
return { schemaVersion: LOCAL_SCHEMA, state }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const HANDLERS = Object.freeze({
|
|
465
|
+
'register-task': registerTask,
|
|
466
|
+
'conflict-scan': conflictScan,
|
|
467
|
+
'lock-acquire': acquireLock,
|
|
468
|
+
'lock-renew': renewLock,
|
|
469
|
+
'lock-release': releaseLock,
|
|
470
|
+
'baseline-handshake': baselineHandshake,
|
|
471
|
+
'dependency-wait': dependencyWait,
|
|
472
|
+
'event-publish': publishEvent,
|
|
473
|
+
'task-status': taskStatus,
|
|
474
|
+
tick,
|
|
475
|
+
'resolve-human': resolveHuman,
|
|
476
|
+
status: coordinatorStatus,
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
async function executeCoordinatorOperation(operation, repositoryRoot, input) {
|
|
480
|
+
if (operation === 'capabilities') return {
|
|
481
|
+
schemaVersion: LOCAL_SCHEMA, operations: OPERATIONS, operationSchemas: OPERATION_SCHEMAS,
|
|
482
|
+
roles: ['board', 'dispatcher', 'ops', 'security-guard', 'coordinator'],
|
|
483
|
+
stateBoundary: '.coord persistent ledger; conversation context is never authoritative',
|
|
484
|
+
writeBoundary: 'Aimlock guarded-write verifies the signed active coordination lease',
|
|
485
|
+
}
|
|
486
|
+
const handler = HANDLERS[operation]
|
|
487
|
+
if (!handler) coordinatorError('SWARM_COORD_OPERATION_UNSUPPORTED', `unsupported operation: ${operation}`)
|
|
488
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
489
|
+
coordinatorError('SWARM_COORD_INPUT_INVALID', 'input must be an object')
|
|
490
|
+
}
|
|
491
|
+
validateInputShape(input, OPERATION_SCHEMAS[operation])
|
|
492
|
+
return handler(repositoryRoot, input)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export {
|
|
496
|
+
LOCAL_SCHEMA,
|
|
497
|
+
OPERATION_SCHEMAS,
|
|
498
|
+
OPERATIONS,
|
|
499
|
+
executeCoordinatorOperation,
|
|
500
|
+
}
|