cli-swarm 7.0.36 → 7.0.38

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.
@@ -0,0 +1,163 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { coordinatorError, identifier, relativePath } from './swarm-coordinator-fs.mjs'
3
+ import { findTask, requireString } from './swarm-coordinator-model.mjs'
4
+
5
+ export const ROUTING_SCHEMA = 'swarm.task-routing/1.0'
6
+ export const REQUEST_SCHEMA = 'swarm.task-request/1.0'
7
+ export const TASKS_SCHEMA = 'swarm.task-message-result/1.0'
8
+ export const MAX_MESSAGE_LENGTH = 100_000
9
+ export const MAX_MESSAGE_ITEMS = 50
10
+ export const TERMINAL_REQUESTS = new Set(['completed', 'rejected'])
11
+ const MATCH_SIGNAL_MINIMUM = 2
12
+ const IDENTITY_FIELDS = ['taskId', 'agentId', 'chainId']
13
+
14
+ export function routingError(code, message) { coordinatorError('SWARM_ROUTING_' + code, message) }
15
+ export function digest(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex') }
16
+ export function exactObject(value, keys, label) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)
18
+ || keys.some(key => !Object.hasOwn(value, key)) || Object.keys(value).some(key => !keys.includes(key))) {
19
+ routingError('INPUT_INVALID', label + ' has missing or unsupported fields')
20
+ }
21
+ return value
22
+ }
23
+ export function strings(value, label) {
24
+ if (!Array.isArray(value) || value.length > MAX_MESSAGE_ITEMS) routingError('INPUT_INVALID', label + ' must be a bounded array')
25
+ const values = value.map(item => requireString(item, label))
26
+ if (new Set(values).size !== values.length) routingError('INPUT_INVALID', label + ' contains duplicates')
27
+ return values
28
+ }
29
+ export function identity(task) { return Object.fromEntries(IDENTITY_FIELDS.map(key => [key, task[key]])) }
30
+ export function describedTask(state, input) {
31
+ const task = findTask(state, input)
32
+ if (!task.routing || task.routing.schemaVersion !== ROUTING_SCHEMA) {
33
+ routingError('TASK_UNDESCRIBED', 'register the task goal and host identity before routing messages')
34
+ }
35
+ return task
36
+ }
37
+ export function requests(state) { return state.messages.filter(message => message.schemaVersion === REQUEST_SCHEMA) }
38
+ export function visibleTo(source, task) {
39
+ return task.routing && task.routing.schemaVersion === ROUTING_SCHEMA
40
+ && task.routing.ownerId === source.routing.ownerId && task.routing.projectId === source.routing.projectId
41
+ }
42
+ export function requestedTask(state, source, taskId) {
43
+ const task = state.tasks.find(entry => entry.taskId === identifier(taskId, 'targetTaskId'))
44
+ if (!task || !visibleTo(source, task)) routingError('TARGET_UNAVAILABLE', 'target task is outside this registered collaboration scope')
45
+ return task
46
+ }
47
+ export function accessRequest(state, task, requestId) {
48
+ const request = requests(state).find(entry => entry.requestId === identifier(requestId, 'requestId'))
49
+ if (!request || request.ownerId !== task.routing.ownerId || request.projectId !== task.routing.projectId
50
+ || ![request.sourceTaskId, request.targetTaskId, request.handoffTaskId].includes(task.taskId)) {
51
+ routingError('REQUEST_UNAVAILABLE', 'request is not available to this task')
52
+ }
53
+ return request
54
+ }
55
+ export function sourceRequest(state, input) {
56
+ const task = describedTask(state, input), request = accessRequest(state, task, input.requestId)
57
+ if (request.sourceTaskId !== task.taskId) routingError('SOURCE_REQUIRED', 'only the source task can coordinate this delivery')
58
+ return { task, request }
59
+ }
60
+ export function targetRequest(state, input) {
61
+ const task = describedTask(state, input), request = accessRequest(state, task, input.requestId)
62
+ if (request.targetTaskId !== task.taskId) routingError('TARGET_REQUIRED', 'only the assigned task can accept or complete this request')
63
+ return { task, request }
64
+ }
65
+ export function event(request, type, details) {
66
+ request.history.push({ type, details, recordedAt: new Date().toISOString() })
67
+ }
68
+ export function receipt(request) {
69
+ return request.receiptId === null ? null : {
70
+ requestId: request.requestId, receiptId: request.receiptId, targetTaskId: request.targetTaskId,
71
+ }
72
+ }
73
+ export function requestView(request) {
74
+ return { requestId: request.requestId, messageId: request.messageId, itemId: request.itemId,
75
+ text: request.text, sourceTaskId: request.sourceTaskId, targetTaskId: request.targetTaskId,
76
+ handoffTaskId: request.handoffTaskId, status: request.status, reason: request.reason,
77
+ candidates: request.candidates, receipt: receipt(request), deliveryAttempt: request.deliveryAttempt,
78
+ resultSummary: request.resultSummary }
79
+ }
80
+ export function deliveryView(state, request) {
81
+ const target = state.tasks.find(task => task.taskId === request.targetTaskId)
82
+ if (!target || !target.routing) routingError('TARGET_UNAVAILABLE', 'delivery target registration is missing')
83
+ return { requestId: request.requestId, messageId: request.messageId, itemId: request.itemId,
84
+ text: request.text, targetTaskId: target.taskId, targetAgentId: target.agentId, targetChainId: target.chainId,
85
+ targetHostId: target.routing.hostId, targetThreadId: target.routing.threadId, status: request.status }
86
+ }
87
+ export function initialRouting(input) {
88
+ const keys = ['ownerId', 'projectId', 'hostId', 'threadId', 'goal', 'keywords', 'requirements']
89
+ const description = Object.fromEntries(keys.map(key => [key, input[key]]))
90
+ for (const key of ['ownerId', 'projectId', 'hostId', 'threadId']) identifier(description[key], key)
91
+ description.goal = requireString(description.goal, 'goal')
92
+ description.keywords = strings(description.keywords, 'keywords')
93
+ if (!Array.isArray(description.requirements) || !description.requirements.length
94
+ || description.requirements.length > MAX_MESSAGE_ITEMS) routingError('INPUT_INVALID', 'original requirements are required')
95
+ description.requirements = description.requirements.map(requirement => {
96
+ exactObject(requirement, ['id', 'text'], 'requirement')
97
+ return { id: identifier(requirement.id, 'requirementId'), text: requireString(requirement.text, 'requirement text') }
98
+ })
99
+ if (new Set(description.requirements.map(item => item.id)).size !== description.requirements.length) {
100
+ routingError('INPUT_INVALID', 'original requirement IDs must be unique')
101
+ }
102
+ return { schemaVersion: ROUTING_SCHEMA, ...description, descriptionDigest: digest(description), revision: 1,
103
+ completedRequirementIds: [], nextAction: description.goal, checkpointAt: new Date().toISOString(), handoff: null }
104
+ }
105
+ export function normalizeMessage(input) {
106
+ identifier(input.messageId, 'messageId')
107
+ if (input.origin !== 'user') routingError('USER_EVENT_REQUIRED', 'documents and tool outputs cannot issue task-routing instructions')
108
+ const text = requireString(input.text, 'message text')
109
+ if (text.length > MAX_MESSAGE_LENGTH || !Array.isArray(input.items) || !input.items.length
110
+ || input.items.length > MAX_MESSAGE_ITEMS) routingError('INPUT_INVALID', 'message or item count exceeds the protocol limit')
111
+ let coveredUntil = 0
112
+ const items = input.items.map(item => {
113
+ exactObject(item, ['itemId', 'text', 'explicitTaskId', 'forceCurrent', 'targetPaths'], 'message item')
114
+ identifier(item.itemId, 'itemId')
115
+ const content = requireString(item.text, 'item text')
116
+ const start = text.indexOf(content, coveredUntil)
117
+ if (start < 0) routingError('CONTENT_MISMATCH', 'items must quote the original message in order without overlap')
118
+ if (/\S/u.test(text.slice(coveredUntil, start))) routingError('CONTENT_INCOMPLETE', 'extracted items must cover every non-whitespace part of the original message')
119
+ coveredUntil = start + content.length
120
+ if (typeof item.forceCurrent !== 'boolean') routingError('INPUT_INVALID', 'forceCurrent must be an explicit boolean')
121
+ if (item.explicitTaskId !== null) identifier(item.explicitTaskId, 'explicitTaskId')
122
+ return { ...item, text: content, targetPaths: strings(item.targetPaths, 'targetPaths').map(path => relativePath(path)) }
123
+ })
124
+ if (/\S/u.test(text.slice(coveredUntil))) routingError('CONTENT_INCOMPLETE', 'extracted items omit part of the original user message')
125
+ if (new Set(items.map(item => item.itemId)).size !== items.length) routingError('INPUT_INVALID', 'item IDs must be unique')
126
+ return { messageId: input.messageId, origin: input.origin, text, items }
127
+ }
128
+ function overlap(left, right) { return left === right || left.startsWith(right + '/') || right.startsWith(left + '/') }
129
+ function containsKeyword(text, keyword) {
130
+ const normalized = text.toLocaleLowerCase(), term = keyword.toLocaleLowerCase()
131
+ if (/[\u3400-\u9fff]/u.test(term)) return normalized.includes(term)
132
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
133
+ return new RegExp('(?:^|[^\\p{L}\\p{N}_])' + escaped + '(?:$|[^\\p{L}\\p{N}_])', 'u').test(normalized)
134
+ }
135
+ export function selectOwner(state, source, item) {
136
+ if (item.explicitTaskId !== null) {
137
+ const explicit = requestedTask(state, source, item.explicitTaskId)
138
+ return { target: explicit, reason: 'explicit-task', candidates: [explicit.taskId] }
139
+ }
140
+ const candidates = state.tasks.filter(task => visibleTo(source, task)
141
+ && !['completed', 'failed', 'reclaimed'].includes(task.status)).map(task => {
142
+ const keywords = task.routing.keywords.filter(keyword => containsKeyword(item.text, keyword))
143
+ const paths = item.targetPaths.filter(path => task.taskScope.some(scope => overlap(path, scope)))
144
+ return { task, keywords, paths, signals: keywords.length + (paths.length ? 1 : 0) }
145
+ }).filter(candidate => candidate.keywords.length > 0 && candidate.signals >= MATCH_SIGNAL_MINIMUM)
146
+ if (candidates.length !== 1) return { target: null, reason: candidates.length ? 'ambiguous-ownership' : 'ownership-unresolved',
147
+ candidates: candidates.map(candidate => candidate.task.taskId) }
148
+ return { target: candidates[0].task, reason: 'unique-goal-and-scope-evidence', candidates: [candidates[0].task.taskId] }
149
+ }
150
+ export function completionPending(state, task) {
151
+ const original = task.routing.requirements.filter(item => !task.routing.completedRequirementIds.includes(item.id))
152
+ const pending = requests(state).filter(request => !TERMINAL_REQUESTS.has(request.status)
153
+ && (request.targetTaskId === task.taskId || (request.sourceTaskId === task.taskId && request.receiptId === null)))
154
+ return { original, pending }
155
+ }
156
+ export function assertTaskRequestsCompleted(state, task) {
157
+ if (!task.routing) return
158
+ if (task.routing.schemaVersion !== ROUTING_SCHEMA) routingError('STATE_INVALID', 'unknown task routing schema')
159
+ const remaining = completionPending(state, task)
160
+ if (remaining.original.length || remaining.pending.length || task.routing.handoff !== null) {
161
+ routingError('TASK_INCOMPLETE', 'original requirements or routed requests remain unfinished')
162
+ }
163
+ }
@@ -0,0 +1,24 @@
1
+ const string = { type: 'string', minLength: 1 }
2
+ const strings = { type: 'array', items: string }
3
+ const object = (required, properties) => ({ type: 'object', additionalProperties: false, required, properties })
4
+ const identity = { taskId: string, agentId: string, chainId: string }
5
+ const fields = (properties) => object(Object.keys(properties), properties)
6
+ const request = { ...identity, requestId: string }
7
+ export const TASK_ROUTING_SCHEMAS = Object.freeze({
8
+ 'task-describe': fields({ ...identity, ownerId: string, projectId: string, hostId: string, threadId: string,
9
+ goal: string, keywords: strings, requirements: { type: 'array', items: fields({ id: string, text: string }) } }),
10
+ 'task-checkpoint': fields({ ...identity, expectedRevision: { type: 'integer', minimum: 1 },
11
+ completedRequirementIds: strings, nextAction: string }),
12
+ 'task-resume': fields(identity),
13
+ 'message-route': fields({ ...identity, messageId: string, origin: { const: 'user' }, text: string,
14
+ items: { type: 'array', items: fields({ itemId: string, text: string, explicitTaskId: { type: ['string', 'null'] },
15
+ forceCurrent: { type: 'boolean' }, targetPaths: strings }) } }),
16
+ 'message-status': fields(request),
17
+ 'message-delivery-start': fields(request),
18
+ 'message-delivery-report': fields({ ...request, errorCode: string, errorMessage: string }),
19
+ 'message-accept': fields(request),
20
+ 'message-complete': fields({ ...request, resultSummary: string }),
21
+ 'message-resolve': fields({ ...request, targetTaskId: string, userMessageId: string, userText: string }),
22
+ 'handoff-resume': fields(request),
23
+ 'handoff-release': fields({ ...request, checkpointSummary: string }),
24
+ })
@@ -0,0 +1,175 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { withCoordinationState, withCoordinationReadLock } from './swarm-coordinator-fs.mjs'
3
+ import { findTask, requireString } from './swarm-coordinator-model.mjs'
4
+ import { pendingDecision, hasActiveWait } from './swarm-coordinator-waits.mjs'
5
+ import { TASK_HANDOFF_HANDLERS } from './swarm-task-handoff.mjs'
6
+ import { TASKS_SCHEMA, REQUEST_SCHEMA, TERMINAL_REQUESTS, routingError, digest, strings, identity,
7
+ describedTask, requests, requestedTask, accessRequest, sourceRequest, targetRequest, event,
8
+ receipt, requestView, deliveryView, initialRouting, normalizeMessage, selectOwner, completionPending } from './swarm-task-routing-model.mjs'
9
+
10
+ function changed(state, output, type, taskId, requestId = null) {
11
+ return { state, output: { schemaVersion: TASKS_SCHEMA, ...output }, audit: [{ event: type, taskId, requestId }] }
12
+ }
13
+ export function resumeView(state, task) {
14
+ const routing = task.routing
15
+ const relevant = requests(state)
16
+ const remaining = completionPending(state, task)
17
+ return { ...identity(task), schemaVersion: TASKS_SCHEMA, goal: routing.goal,
18
+ goalDigest: routing.descriptionDigest, goalRevision: routing.revision,
19
+ remainingRequirements: remaining.original, nextAction: routing.nextAction,
20
+ checkpointAt: routing.checkpointAt, status: task.status,
21
+ canContinue: task.status === 'active' && routing.handoff === null
22
+ && !pendingDecision(state, task) && !hasActiveWait(state, task),
23
+ completionAllowed: !remaining.original.length && !remaining.pending.length && routing.handoff === null,
24
+ inbox: relevant.filter(item => item.targetTaskId === task.taskId && !TERMINAL_REQUESTS.has(item.status)).map(requestView),
25
+ outbox: relevant.filter(item => item.sourceTaskId === task.taskId && !TERMINAL_REQUESTS.has(item.status)).map(requestView),
26
+ handoffs: relevant.filter(item => item.handoffTaskId === task.taskId && !TERMINAL_REQUESTS.has(item.status)).map(requestView) }
27
+ }
28
+ async function describeTask(root, input) {
29
+ return withCoordinationState(root, state => {
30
+ const task = findTask(state, input), routing = initialRouting(input)
31
+ if (task.routing) {
32
+ describedTask(state, input)
33
+ if (task.routing.descriptionDigest !== routing.descriptionDigest) {
34
+ routingError('GOAL_IMMUTABLE', 'a new message cannot replace this task goal, host identity or original requirements')
35
+ }
36
+ } else {
37
+ if (['completed', 'failed', 'reclaimed'].includes(task.status)) routingError('TASK_TERMINAL', 'a terminal task cannot register new work')
38
+ task.routing = routing
39
+ }
40
+ return changed(state, { task: resumeView(state, task) }, 'task-described', task.taskId)
41
+ })
42
+ }
43
+ async function checkpointTask(root, input) {
44
+ return withCoordinationState(root, state => {
45
+ const task = describedTask(state, input), routing = task.routing
46
+ if (!Number.isSafeInteger(input.expectedRevision) || routing.revision !== input.expectedRevision) {
47
+ routingError('REVISION_CONFLICT', 'reload the current checkpoint before updating it')
48
+ }
49
+ if (task.status !== 'active' || routing.handoff !== null || pendingDecision(state, task) || hasActiveWait(state, task)) {
50
+ routingError('TASK_SUSPENDED', 'a suspended task cannot advance its checkpoint')
51
+ }
52
+ const completed = strings(input.completedRequirementIds, 'completedRequirementIds')
53
+ if (completed.some(id => !routing.requirements.some(item => item.id === id))
54
+ || routing.completedRequirementIds.some(id => !completed.includes(id))) {
55
+ routingError('CHECKPOINT_INVALID', 'completed requirements must belong to the original goal and cannot be silently discarded')
56
+ }
57
+ routing.completedRequirementIds = completed
58
+ routing.nextAction = requireString(input.nextAction, 'nextAction')
59
+ routing.checkpointAt = new Date().toISOString()
60
+ routing.revision += 1
61
+ return changed(state, { task: resumeView(state, task) }, 'task-checkpoint', task.taskId)
62
+ })
63
+ }
64
+ async function resumeTask(root, input) {
65
+ return withCoordinationReadLock(root, state => resumeView(state, describedTask(state, input)))
66
+ }
67
+ function newRequest(state, source, message, item) {
68
+ const selected = selectOwner(state, source, item)
69
+ const target = item.forceCurrent ? source : selected.target
70
+ const handoff = item.forceCurrent && selected.target && selected.target.taskId !== source.taskId ? selected.target : null
71
+ const unavailable = target && ['completed', 'failed', 'reclaimed'].includes(target.status)
72
+ const requestId = 'request-' + digest([source.taskId, message.messageId, item.itemId])
73
+ const request = { schemaVersion: REQUEST_SCHEMA, requestId, messageId: message.messageId, itemId: item.itemId,
74
+ messageDigest: digest(message), sourceTaskId: source.taskId, sourceAgentId: source.agentId, sourceChainId: source.chainId,
75
+ ownerId: source.routing.ownerId, projectId: source.routing.projectId, text: item.text, targetPaths: item.targetPaths,
76
+ targetTaskId: target ? target.taskId : null, handoffTaskId: handoff ? handoff.taskId : null,
77
+ forceCurrent: item.forceCurrent, status: unavailable || !target ? 'pending-routing' : handoff ? 'pending-handoff' : 'pending-delivery',
78
+ reason: unavailable ? 'target-task-terminal' : item.forceCurrent ? 'explicit-current-task' : selected.reason,
79
+ candidates: selected.candidates, receiptId: null, deliveryAttempt: null, resultSummary: null,
80
+ createdAt: new Date().toISOString(), acceptedAt: null, completedAt: null, history: [] }
81
+ event(request, 'message-recorded', { source: identity(source), selectedTaskId: request.targetTaskId, reason: request.reason })
82
+ return request
83
+ }
84
+ async function routeMessage(root, input) {
85
+ return withCoordinationState(root, state => {
86
+ const source = describedTask(state, input), message = normalizeMessage(input)
87
+ if (['completed', 'failed', 'reclaimed'].includes(source.status)) routingError('TASK_TERMINAL', 'resume or register a follow-up before submitting new work')
88
+ const existing = requests(state).filter(item => item.sourceTaskId === source.taskId && item.messageId === message.messageId)
89
+ if (existing.length && (existing.length !== message.items.length || existing.some(item => item.messageDigest !== digest(message)))) {
90
+ routingError('MESSAGE_CONFLICT', 'this message ID already has different content or routing instructions; use explicit resolution')
91
+ }
92
+ const recorded = existing.length ? existing : message.items.map(item => newRequest(state, source, message, item))
93
+ if (!existing.length) state.messages.push(...recorded)
94
+ const deliveries = recorded.filter(item => item.status === 'pending-delivery').map(item => deliveryView(state, item))
95
+ return changed(state, { messageId: message.messageId, source: identity(source), replayed: existing.length > 0,
96
+ requests: recorded.map(requestView), deliveries, continuation: resumeView(state, source) }, 'message-routed', source.taskId)
97
+ })
98
+ }
99
+ async function messageStatus(root, input) {
100
+ return withCoordinationReadLock(root, state => {
101
+ const task = describedTask(state, input), request = accessRequest(state, task, input.requestId)
102
+ return { schemaVersion: TASKS_SCHEMA, request: requestView(request), receipt: receipt(request) }
103
+ })
104
+ }
105
+ async function startDelivery(root, input) {
106
+ return withCoordinationState(root, state => {
107
+ const { task, request } = sourceRequest(state, input)
108
+ let claimed = false
109
+ if (request.status === 'pending-delivery' && request.deliveryAttempt === null) {
110
+ request.deliveryAttempt = { attemptId: 'delivery-' + randomUUID(), startedAt: new Date().toISOString(),
111
+ status: 'started', errorCode: null, errorMessage: null }
112
+ event(request, 'delivery-started', { attemptId: request.deliveryAttempt.attemptId })
113
+ claimed = true
114
+ }
115
+ return changed(state, { claimed, attemptId: request.deliveryAttempt === null ? null : request.deliveryAttempt.attemptId,
116
+ request: requestView(request) }, 'delivery-claim', task.taskId, request.requestId)
117
+ })
118
+ }
119
+ async function reportDelivery(root, input) {
120
+ return withCoordinationState(root, state => {
121
+ const { task, request } = sourceRequest(state, input)
122
+ const errorCode = requireString(input.errorCode, 'errorCode'), errorMessage = requireString(input.errorMessage, 'errorMessage')
123
+ if (request.deliveryAttempt === null) routingError('DELIVERY_NOT_STARTED', 'cannot report an unclaimed delivery')
124
+ request.deliveryAttempt.status = request.receiptId === null ? 'uncertain' : 'received'
125
+ request.deliveryAttempt.errorCode = errorCode
126
+ request.deliveryAttempt.errorMessage = errorMessage
127
+ event(request, 'delivery-reported', { attemptId: request.deliveryAttempt.attemptId, errorCode, errorMessage })
128
+ return changed(state, { request: requestView(request), receipt: receipt(request) }, 'delivery-reported', task.taskId, request.requestId)
129
+ })
130
+ }
131
+ function checkTargetScope(task, request) {
132
+ if (!request.targetPaths.every(path => task.taskScope.some(scope => path === scope || path.startsWith(scope + '/')))) {
133
+ routingError('SCOPE_REQUIRED', 'the target must approve an updated task scope before accepting these paths')
134
+ }
135
+ }
136
+ async function acceptMessage(root, input) {
137
+ return withCoordinationState(root, state => {
138
+ const { task, request } = targetRequest(state, input)
139
+ if (request.receiptId !== null) return changed(state, { ...receipt(request), status: request.status }, 'message-accept-replayed', task.taskId, request.requestId)
140
+ if (request.status !== 'pending-delivery') routingError('DELIVERY_NOT_READY', 'resolve ownership and complete any handoff before accepting')
141
+ if (!resumeView(state, task).canContinue) routingError('TASK_SUSPENDED', 'target task is not ready to accept work')
142
+ checkTargetScope(task, request)
143
+ request.receiptId = 'receipt-' + randomUUID()
144
+ request.acceptedAt = new Date().toISOString()
145
+ request.status = 'accepted'
146
+ if (request.deliveryAttempt !== null) request.deliveryAttempt.status = 'received'
147
+ event(request, 'message-accepted', { ...identity(task), receiptId: request.receiptId })
148
+ return changed(state, { ...receipt(request), status: 'accepted' }, 'message-accepted', task.taskId, request.requestId)
149
+ })
150
+ }
151
+ async function resolveMessage(root, input) {
152
+ return withCoordinationState(root, state => {
153
+ const { task, request } = sourceRequest(state, input)
154
+ const target = requestedTask(state, task, input.targetTaskId)
155
+ const userMessageId = requireString(input.userMessageId, 'userMessageId'), userText = requireString(input.userText, 'userText')
156
+ const previous = request.history.find(entry => entry.type === 'user-resolution' && entry.details.userMessageId === userMessageId)
157
+ if (previous) {
158
+ if (previous.details.targetTaskId !== target.taskId || previous.details.userText !== userText) routingError('MESSAGE_CONFLICT', 'resolution ID was already used with different content')
159
+ return changed(state, { request: requestView(request) }, 'message-resolution-replayed', task.taskId, request.requestId)
160
+ }
161
+ if (request.status !== 'pending-routing' || request.receiptId !== null || request.deliveryAttempt !== null) routingError('ALREADY_ROUTED', 'an active delivery cannot be silently redirected')
162
+ if (['completed', 'failed', 'reclaimed'].includes(target.status)) routingError('TASK_TERMINAL', 'select an active follow-up task')
163
+ request.targetTaskId = target.taskId
164
+ request.status = 'pending-delivery'
165
+ request.reason = 'explicit-user-resolution'
166
+ event(request, 'user-resolution', { userMessageId, userText, targetTaskId: target.taskId })
167
+ return changed(state, { request: requestView(request), delivery: deliveryView(state, request) }, 'message-resolved', task.taskId, request.requestId)
168
+ })
169
+ }
170
+ export const TASK_ROUTING_HANDLERS = Object.freeze({
171
+ 'task-describe': describeTask, 'task-checkpoint': checkpointTask, 'task-resume': resumeTask,
172
+ 'message-route': routeMessage, 'message-status': messageStatus, 'message-delivery-start': startDelivery,
173
+ 'message-delivery-report': reportDelivery, 'message-accept': acceptMessage, 'message-resolve': resolveMessage,
174
+ ...TASK_HANDOFF_HANDLERS,
175
+ })