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.
@@ -1,15 +1,19 @@
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,
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 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')
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(), resolvedAt: null }
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
- promoted.push(await grantLock(state, root, queued.request, now))
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
- return promoted
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 = 'waiting'
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 nowMs = Date.parse(input.now)
413
- if (!Number.isFinite(nowMs)) coordinatorError('SWARM_COORD_NOW_INVALID', 'now must be an ISO date-time')
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