cli-swarm 7.0.37 → 7.0.39

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,142 @@
1
+ import { evidenceState, readValidationSubject, validatorReceiptSubject } from "cli-validator/runtime";
2
+ import { text, isObject, validId, finding, ORG_PERMISSIONS, TEST_EVIDENCE_SCHEMA, SHA256_PATTERN } from "./swarm-task-contract.mjs";
3
+
4
+ const MIN_DISPATCH_LINES = 200;
5
+ const MIN_DISPATCH_FILES = 3;
6
+ const ACTIVE_TASK_STATES = new Set(["assigned", "claimed", "running"]);
7
+ function dispatchEligibility(tasks, task, facts) {
8
+ if (!isObject(facts) || !Number.isSafeInteger(facts.estimatedChangedLines) || facts.estimatedChangedLines < 0
9
+ || !Number.isSafeInteger(facts.fileCount) || facts.fileCount < 1 || typeof facts.crossModule !== "boolean"
10
+ || typeof facts.parallelSafe !== "boolean" || !validId(facts.ownerId)
11
+ || !Array.isArray(facts.targetPaths) || facts.targetPaths.length !== facts.fileCount
12
+ || new Set(facts.targetPaths).size !== facts.fileCount
13
+ || facts.targetPaths.some(path => typeof path !== "string" || !path.trim() || path.startsWith("/")
14
+ || path.split("/").some(part => !part || part === "." || part === "..") || /[\\*?\[\]{}]/.test(path))) return "dispatch-facts-required";
15
+ const delegation = facts.delegation;
16
+ if (!isObject(delegation) || ["businessNeed", "deliverable", "acceptanceCriteria", "mainAgentWork"]
17
+ .some(key => typeof delegation[key] !== "string" || !delegation[key].trim())
18
+ || typeof delegation.independent !== "boolean" || typeof delegation.substantial !== "boolean"
19
+ || !Number.isFinite(delegation.estimatedSavedMinutes) || delegation.estimatedSavedMinutes < 0
20
+ || !Number.isFinite(delegation.coordinationMinutes) || delegation.coordinationMinutes < 0) return "dispatch-business-case-required";
21
+ if (!delegation.substantial) return "dispatch-simple-work-stays-local";
22
+ if (!delegation.independent) return "dispatch-not-independent";
23
+ if (delegation.estimatedSavedMinutes <= delegation.coordinationMinutes) return "dispatch-overhead-exceeds-benefit";
24
+ if (facts.estimatedChangedLines < MIN_DISPATCH_LINES && facts.fileCount < MIN_DISPATCH_FILES
25
+ && !facts.crossModule) return "dispatch-under-threshold";
26
+ if (!facts.parallelSafe) return "dispatch-not-parallel-safe";
27
+ if (task.originOwnerId !== undefined && task.originOwnerId !== facts.ownerId) return "dispatch-owner-mismatch";
28
+ const overlaps = (left, right) => left === right || left.startsWith(right + "/") || right.startsWith(left + "/");
29
+ for (const other of tasks.filter(item => item !== task && ACTIVE_TASK_STATES.has(item.status))) {
30
+ if (!Array.isArray(other.dispatch?.targetPaths)) return "dispatch-peer-scope-missing";
31
+ if (facts.targetPaths.some(path => other.dispatch.targetPaths.some(otherPath => overlaps(path, otherPath)))) return "dispatch-write-conflict";
32
+ }
33
+ return null;
34
+ }
35
+ function hasTrustedTestEvidence(task) {
36
+ if (!hasPassingTestEvidence(task) || !isObject(task.validationContext)
37
+ || task.validationContext.validationRunId !== task.taskId) return false;
38
+ const subject = readValidationSubject(task.validationContext, "task.validationContext");
39
+ if (!subject.value) return false;
40
+ const digest = validatorReceiptSubject(subject.value);
41
+ return task.report.evidence.every(evidence => evidenceState(evidence, subject.value, digest) === "valid");
42
+ }
43
+ function reclaimTask(task, workerId) {
44
+ if (task.status === "assigned") {
45
+ task.status = "backlog"; task.owner = null; task.inheritedFrom = workerId;
46
+ return true;
47
+ }
48
+ if (["claimed", "running"].includes(task.status)) {
49
+ task.status = "blocked"; task.blockedReason = "execution-outcome-unknown";
50
+ task.progressNote = "Reconcile the original execution before authorizing another attempt";
51
+ }
52
+ return false;
53
+ }
54
+ function buildTasks(project, org) {
55
+ return (project.tasks ?? []).map((task, index) => ({
56
+ taskId: validId(task.taskId) ? task.taskId : `task-${String(index + 1).padStart(4, "0")}`, title: text(task.title || task.name || `任务 ${index + 1}`), owner: null, status: "backlog",
57
+ priority: text(task.priority || "normal"), dependsOn: Array.isArray(task.dependsOn) ? task.dependsOn : [], assignedBy: null, claimedAt: null, reportedAt: null, report: null, progressPercent: 0,
58
+ progressNote: "", inheritedFrom: null, ...(task.validationContext === undefined ? {} : { validationContext: structuredClone(task.validationContext) }) }));
59
+ }
60
+ function dispatchTask(tasks, taskId, workerId, actorRole, facts) {
61
+ if (!ORG_PERMISSIONS[actorRole]?.includes("dispatch")) return { ok: false, error: `role ${actorRole} cannot dispatch` };
62
+ const task = tasks.find((t) => t.taskId === taskId);
63
+ if (!task) return { ok: false, error: `task ${taskId} not found` };
64
+ if (task.status !== "backlog") return { ok: false, error: `task ${taskId} is ${task.status}, not backlog` };
65
+ for (const dependencyId of Array.isArray(task.dependsOn) ? task.dependsOn : []) {
66
+ const dependency = tasks.find((candidate) => candidate.taskId === dependencyId);
67
+ if (!dependency) return { ok: false, error: `dependency ${dependencyId} not found` };
68
+ if (dependency.status === "accepted" && !hasTrustedTestEvidence(dependency)) return { ok: false, error: `dependency ${dependencyId} has no verified evidence` };
69
+ if (dependency.status !== "accepted") return { ok: false, error: `dependency ${dependencyId} is ${dependency.status}, not accepted` };
70
+ }
71
+ const error = dispatchEligibility(tasks, task, facts);
72
+ if (error) return { ok: false, error };
73
+ task.dispatch = structuredClone(facts);
74
+ if (task.originOwnerId === undefined) task.originOwnerId = facts.ownerId;
75
+ task.status = "assigned"; task.owner = workerId; task.assignedBy = actorRole;
76
+ return { ok: true, task };
77
+ }
78
+ function claimTask(tasks, taskId, workerId) {
79
+ const task = tasks.find((t) => t.taskId === taskId);
80
+ if (!task) return { ok: false, error: `task ${taskId} not found` };
81
+ if (task.status !== "assigned") return { ok: false, error: `task ${taskId} is ${task.status}, not assigned` };
82
+ if (task.owner && task.owner !== workerId) return { ok: false, error: `task ${taskId} claimed by another worker` };
83
+ task.status = "claimed"; task.owner = workerId; task.claimedAt = new Date().toISOString();
84
+ return { ok: true, task };
85
+ }
86
+ function validateTestEvidence(value, entityRef) {
87
+ const findings = [];
88
+ if (!isObject(value)) return [finding("P0", "TEST_EVIDENCE_OBJECT", entityRef, "TestEvidence must be an object")];
89
+ if (value.schemaVersion !== TEST_EVIDENCE_SCHEMA) findings.push(finding("P0", "TEST_EVIDENCE_SCHEMA", `${entityRef}.schemaVersion`, `Expected ${TEST_EVIDENCE_SCHEMA}`));
90
+ if (!text(value.evidenceId).trim()) findings.push(finding("P0", "TEST_EVIDENCE_ID", `${entityRef}.evidenceId`, "evidenceId is required"));
91
+ if (!["test", "build", "lint", "security", "benchmark"].includes(value.kind)) findings.push(finding("P0", "TEST_EVIDENCE_KIND", `${entityRef}.kind`, "kind is unsupported"));
92
+ if (!["local", "trusted-runner"].includes(value.runner)) findings.push(finding("P0", "TEST_EVIDENCE_RUNNER", `${entityRef}.runner`, "runner must be local or trusted-runner"));
93
+ if (!text(value.command).trim()) findings.push(finding("P0", "TEST_EVIDENCE_COMMAND", `${entityRef}.command`, "command is required"));
94
+ if (!Number.isInteger(value.exitCode)) findings.push(finding("P0", "TEST_EVIDENCE_EXIT", `${entityRef}.exitCode`, "exitCode must be an integer"));
95
+ if (typeof value.durationMs !== "number" || !Number.isFinite(value.durationMs) || value.durationMs < 0) findings.push(finding("P0", "TEST_EVIDENCE_DURATION", `${entityRef}.durationMs`, "durationMs must be a finite number >= 0"));
96
+ if (!text(value.summary).trim()) findings.push(finding("P0", "TEST_EVIDENCE_SUMMARY", `${entityRef}.summary`, "summary is required"));
97
+ if (value.artifactSha256 !== undefined && !SHA256_PATTERN.test(value.artifactSha256)) findings.push(finding("P0", "TEST_EVIDENCE_ARTIFACT", `${entityRef}.artifactSha256`, "artifactSha256 must be a lowercase SHA-256 digest"));
98
+ return findings;
99
+ }
100
+ function normalizeTestEvidence(values) {
101
+ if (!Array.isArray(values)) return { evidence: [], findings: [finding("P0", "TEST_EVIDENCE_ARRAY", "report.evidence", "evidence must be an array")] };
102
+ const findings = values.flatMap((value, index) => validateTestEvidence(value, `report.evidence[${index}]`));
103
+ return findings.length ? { evidence: [], findings } : { evidence: values.map((value) => ({ ...value })), findings: [] };
104
+ }
105
+ function hasPassingTestEvidence(task) {
106
+ const evidence = task?.report?.evidence;
107
+ return Array.isArray(evidence) && evidence.length > 0
108
+ && evidence.every((value, index) => validateTestEvidence(value, `task.report.evidence[${index}]`).length === 0 && value.exitCode === 0);
109
+ }
110
+ function reportTask(tasks, taskId, workerId, report) {
111
+ const task = tasks.find((t) => t.taskId === taskId);
112
+ if (!task) return { ok: false, error: `task ${taskId} not found` };
113
+ if (task.owner && task.owner !== workerId) return { ok: false, error: `task ${taskId} not owned by ${workerId}` };
114
+ if (!["claimed", "running"].includes(task.status)) return { ok: false, error: `task ${taskId} is ${task.status}, cannot report` };
115
+ if (!isObject(report)) return { ok: false, error: "report must be an object" };
116
+ if (!text(report.output).trim() && report.evidence === undefined) return { ok: false, error: "report must have output or evidence" };
117
+ const normalizedReport = { output: text(report.output) };
118
+ if (report.evidence !== undefined) {
119
+ const normalized = normalizeTestEvidence(report.evidence);
120
+ if (normalized.findings.length) return { ok: false, error: "report evidence is invalid", findings: normalized.findings };
121
+ normalizedReport.evidence = normalized.evidence;
122
+ }
123
+ task.status = "reported"; task.report = normalizedReport; task.reportedAt = new Date().toISOString();
124
+ task.selfReported = !hasTrustedTestEvidence(task);
125
+ return { ok: true, task, hasEvidence: hasPassingTestEvidence(task) };
126
+ }
127
+ function acceptTask(tasks, taskId, accept, actorRole) {
128
+ if (actorRole !== "board") return { ok: false, error: `role ${actorRole} cannot accept` };
129
+ const task = tasks.find((t) => t.taskId === taskId);
130
+ if (!task) return { ok: false, error: `task ${taskId} not found` };
131
+ if (task.status !== "reported") return { ok: false, error: `task ${taskId} is ${task.status}, not reported` };
132
+ if (accept && !hasTrustedTestEvidence(task)) return { ok: false, error: `task ${taskId} has no verified task-bound TestEvidence` };
133
+ task.status = accept ? "accepted" : "failed";
134
+ return { ok: true, task };
135
+ }
136
+ function taskTrafficLight(task) {
137
+ if (task.status === "accepted") return hasTrustedTestEvidence(task) ? "green" : "red";
138
+ if (task.status === "reported") return "yellow";
139
+ if (task.status === "failed" || task.status === "blocked" || task.status === "cancelled") return "red";
140
+ return "yellow";
141
+ }
142
+ export { dispatchEligibility, buildTasks, dispatchTask, claimTask, reportTask, acceptTask, taskTrafficLight, validateTestEvidence, normalizeTestEvidence, hasPassingTestEvidence, hasTrustedTestEvidence, reclaimTask };
@@ -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,178 @@
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
+ continuationNotifications: state.messages.filter(item => item.type === 'task-continuation'
22
+ && item.payload.taskId === task.taskId && item.payload.chainId === task.chainId
23
+ && item.to === task.agentId),
24
+ canContinue: task.status === 'active' && routing.handoff === null
25
+ && !pendingDecision(state, task) && !hasActiveWait(state, task),
26
+ completionAllowed: !remaining.original.length && !remaining.pending.length && routing.handoff === null,
27
+ inbox: relevant.filter(item => item.targetTaskId === task.taskId && !TERMINAL_REQUESTS.has(item.status)).map(requestView),
28
+ outbox: relevant.filter(item => item.sourceTaskId === task.taskId && !TERMINAL_REQUESTS.has(item.status)).map(requestView),
29
+ handoffs: relevant.filter(item => item.handoffTaskId === task.taskId && !TERMINAL_REQUESTS.has(item.status)).map(requestView) }
30
+ }
31
+ async function describeTask(root, input) {
32
+ return withCoordinationState(root, state => {
33
+ const task = findTask(state, input), routing = initialRouting(input)
34
+ if (task.routing) {
35
+ describedTask(state, input)
36
+ if (task.routing.descriptionDigest !== routing.descriptionDigest) {
37
+ routingError('GOAL_IMMUTABLE', 'a new message cannot replace this task goal, host identity or original requirements')
38
+ }
39
+ } else {
40
+ if (['completed', 'failed', 'reclaimed'].includes(task.status)) routingError('TASK_TERMINAL', 'a terminal task cannot register new work')
41
+ task.routing = routing
42
+ }
43
+ return changed(state, { task: resumeView(state, task) }, 'task-described', task.taskId)
44
+ })
45
+ }
46
+ async function checkpointTask(root, input) {
47
+ return withCoordinationState(root, state => {
48
+ const task = describedTask(state, input), routing = task.routing
49
+ if (!Number.isSafeInteger(input.expectedRevision) || routing.revision !== input.expectedRevision) {
50
+ routingError('REVISION_CONFLICT', 'reload the current checkpoint before updating it')
51
+ }
52
+ if (task.status !== 'active' || routing.handoff !== null || pendingDecision(state, task) || hasActiveWait(state, task)) {
53
+ routingError('TASK_SUSPENDED', 'a suspended task cannot advance its checkpoint')
54
+ }
55
+ const completed = strings(input.completedRequirementIds, 'completedRequirementIds')
56
+ if (completed.some(id => !routing.requirements.some(item => item.id === id))
57
+ || routing.completedRequirementIds.some(id => !completed.includes(id))) {
58
+ routingError('CHECKPOINT_INVALID', 'completed requirements must belong to the original goal and cannot be silently discarded')
59
+ }
60
+ routing.completedRequirementIds = completed
61
+ routing.nextAction = requireString(input.nextAction, 'nextAction')
62
+ routing.checkpointAt = new Date().toISOString()
63
+ routing.revision += 1
64
+ return changed(state, { task: resumeView(state, task) }, 'task-checkpoint', task.taskId)
65
+ })
66
+ }
67
+ async function resumeTask(root, input) {
68
+ return withCoordinationReadLock(root, state => resumeView(state, describedTask(state, input)))
69
+ }
70
+ function newRequest(state, source, message, item) {
71
+ const selected = selectOwner(state, source, item)
72
+ const target = item.forceCurrent ? source : selected.target
73
+ const handoff = item.forceCurrent && selected.target && selected.target.taskId !== source.taskId ? selected.target : null
74
+ const unavailable = target && ['completed', 'failed', 'reclaimed'].includes(target.status)
75
+ const requestId = 'request-' + digest([source.taskId, message.messageId, item.itemId])
76
+ const request = { schemaVersion: REQUEST_SCHEMA, requestId, messageId: message.messageId, itemId: item.itemId,
77
+ messageDigest: digest(message), sourceTaskId: source.taskId, sourceAgentId: source.agentId, sourceChainId: source.chainId,
78
+ ownerId: source.routing.ownerId, projectId: source.routing.projectId, text: item.text, targetPaths: item.targetPaths,
79
+ targetTaskId: target ? target.taskId : null, handoffTaskId: handoff ? handoff.taskId : null,
80
+ forceCurrent: item.forceCurrent, status: unavailable || !target ? 'pending-routing' : handoff ? 'pending-handoff' : 'pending-delivery',
81
+ reason: unavailable ? 'target-task-terminal' : item.forceCurrent ? 'explicit-current-task' : selected.reason,
82
+ candidates: selected.candidates, receiptId: null, deliveryAttempt: null, resultSummary: null,
83
+ createdAt: new Date().toISOString(), acceptedAt: null, completedAt: null, history: [] }
84
+ event(request, 'message-recorded', { source: identity(source), selectedTaskId: request.targetTaskId, reason: request.reason })
85
+ return request
86
+ }
87
+ async function routeMessage(root, input) {
88
+ return withCoordinationState(root, state => {
89
+ const source = describedTask(state, input), message = normalizeMessage(input)
90
+ if (['completed', 'failed', 'reclaimed'].includes(source.status)) routingError('TASK_TERMINAL', 'resume or register a follow-up before submitting new work')
91
+ const existing = requests(state).filter(item => item.sourceTaskId === source.taskId && item.messageId === message.messageId)
92
+ if (existing.length && (existing.length !== message.items.length || existing.some(item => item.messageDigest !== digest(message)))) {
93
+ routingError('MESSAGE_CONFLICT', 'this message ID already has different content or routing instructions; use explicit resolution')
94
+ }
95
+ const recorded = existing.length ? existing : message.items.map(item => newRequest(state, source, message, item))
96
+ if (!existing.length) state.messages.push(...recorded)
97
+ const deliveries = recorded.filter(item => item.status === 'pending-delivery').map(item => deliveryView(state, item))
98
+ return changed(state, { messageId: message.messageId, source: identity(source), replayed: existing.length > 0,
99
+ requests: recorded.map(requestView), deliveries, continuation: resumeView(state, source) }, 'message-routed', source.taskId)
100
+ })
101
+ }
102
+ async function messageStatus(root, input) {
103
+ return withCoordinationReadLock(root, state => {
104
+ const task = describedTask(state, input), request = accessRequest(state, task, input.requestId)
105
+ return { schemaVersion: TASKS_SCHEMA, request: requestView(request), receipt: receipt(request) }
106
+ })
107
+ }
108
+ async function startDelivery(root, input) {
109
+ return withCoordinationState(root, state => {
110
+ const { task, request } = sourceRequest(state, input)
111
+ let claimed = false
112
+ if (request.status === 'pending-delivery' && request.deliveryAttempt === null) {
113
+ request.deliveryAttempt = { attemptId: 'delivery-' + randomUUID(), startedAt: new Date().toISOString(),
114
+ status: 'started', errorCode: null, errorMessage: null }
115
+ event(request, 'delivery-started', { attemptId: request.deliveryAttempt.attemptId })
116
+ claimed = true
117
+ }
118
+ return changed(state, { claimed, attemptId: request.deliveryAttempt === null ? null : request.deliveryAttempt.attemptId,
119
+ request: requestView(request) }, 'delivery-claim', task.taskId, request.requestId)
120
+ })
121
+ }
122
+ async function reportDelivery(root, input) {
123
+ return withCoordinationState(root, state => {
124
+ const { task, request } = sourceRequest(state, input)
125
+ const errorCode = requireString(input.errorCode, 'errorCode'), errorMessage = requireString(input.errorMessage, 'errorMessage')
126
+ if (request.deliveryAttempt === null) routingError('DELIVERY_NOT_STARTED', 'cannot report an unclaimed delivery')
127
+ request.deliveryAttempt.status = request.receiptId === null ? 'uncertain' : 'received'
128
+ request.deliveryAttempt.errorCode = errorCode
129
+ request.deliveryAttempt.errorMessage = errorMessage
130
+ event(request, 'delivery-reported', { attemptId: request.deliveryAttempt.attemptId, errorCode, errorMessage })
131
+ return changed(state, { request: requestView(request), receipt: receipt(request) }, 'delivery-reported', task.taskId, request.requestId)
132
+ })
133
+ }
134
+ function checkTargetScope(task, request) {
135
+ if (!request.targetPaths.every(path => task.taskScope.some(scope => path === scope || path.startsWith(scope + '/')))) {
136
+ routingError('SCOPE_REQUIRED', 'the target must approve an updated task scope before accepting these paths')
137
+ }
138
+ }
139
+ async function acceptMessage(root, input) {
140
+ return withCoordinationState(root, state => {
141
+ const { task, request } = targetRequest(state, input)
142
+ if (request.receiptId !== null) return changed(state, { ...receipt(request), status: request.status }, 'message-accept-replayed', task.taskId, request.requestId)
143
+ if (request.status !== 'pending-delivery') routingError('DELIVERY_NOT_READY', 'resolve ownership and complete any handoff before accepting')
144
+ if (!resumeView(state, task).canContinue) routingError('TASK_SUSPENDED', 'target task is not ready to accept work')
145
+ checkTargetScope(task, request)
146
+ request.receiptId = 'receipt-' + randomUUID()
147
+ request.acceptedAt = new Date().toISOString()
148
+ request.status = 'accepted'
149
+ if (request.deliveryAttempt !== null) request.deliveryAttempt.status = 'received'
150
+ event(request, 'message-accepted', { ...identity(task), receiptId: request.receiptId })
151
+ return changed(state, { ...receipt(request), status: 'accepted' }, 'message-accepted', task.taskId, request.requestId)
152
+ })
153
+ }
154
+ async function resolveMessage(root, input) {
155
+ return withCoordinationState(root, state => {
156
+ const { task, request } = sourceRequest(state, input)
157
+ const target = requestedTask(state, task, input.targetTaskId)
158
+ const userMessageId = requireString(input.userMessageId, 'userMessageId'), userText = requireString(input.userText, 'userText')
159
+ const previous = request.history.find(entry => entry.type === 'user-resolution' && entry.details.userMessageId === userMessageId)
160
+ if (previous) {
161
+ if (previous.details.targetTaskId !== target.taskId || previous.details.userText !== userText) routingError('MESSAGE_CONFLICT', 'resolution ID was already used with different content')
162
+ return changed(state, { request: requestView(request) }, 'message-resolution-replayed', task.taskId, request.requestId)
163
+ }
164
+ if (request.status !== 'pending-routing' || request.receiptId !== null || request.deliveryAttempt !== null) routingError('ALREADY_ROUTED', 'an active delivery cannot be silently redirected')
165
+ if (['completed', 'failed', 'reclaimed'].includes(target.status)) routingError('TASK_TERMINAL', 'select an active follow-up task')
166
+ request.targetTaskId = target.taskId
167
+ request.status = 'pending-delivery'
168
+ request.reason = 'explicit-user-resolution'
169
+ event(request, 'user-resolution', { userMessageId, userText, targetTaskId: target.taskId })
170
+ return changed(state, { request: requestView(request), delivery: deliveryView(state, request) }, 'message-resolved', task.taskId, request.requestId)
171
+ })
172
+ }
173
+ export const TASK_ROUTING_HANDLERS = Object.freeze({
174
+ 'task-describe': describeTask, 'task-checkpoint': checkpointTask, 'task-resume': resumeTask,
175
+ 'message-route': routeMessage, 'message-status': messageStatus, 'message-delivery-start': startDelivery,
176
+ 'message-delivery-report': reportDelivery, 'message-accept': acceptMessage, 'message-resolve': resolveMessage,
177
+ ...TASK_HANDOFF_HANDLERS,
178
+ })