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.
- package/README.md +14 -0
- package/broker-account-storage.mjs +135 -0
- package/broker-credentials.mjs +125 -0
- package/broker.mjs +13 -93
- package/installer-storage.mjs +205 -0
- package/installer.mjs +48 -63
- package/official-skill-update.mjs +151 -0
- package/package.json +16 -2
- package/skill/SKILL.md +82 -134
- package/skill/references/ops-heartbeat.md +3 -3
- package/skill/references/org-chart.md +5 -26
- package/skill/references/task-lifecycle.md +10 -2
- package/skill/references/task-routing.md +64 -0
- package/skill/references/traffic-light.md +3 -3
- package/skill/skill.json +1 -1
- package/swarm-coordinator-model.mjs +52 -0
- package/swarm-coordinator-waits.mjs +44 -24
- package/swarm-coordinator.mjs +11 -4
- package/swarm-runtime.mjs +45 -111
- package/swarm-task-contract.mjs +8 -0
- package/swarm-task-handoff.mjs +180 -0
- package/swarm-task-policy.mjs +142 -0
- package/swarm-task-routing-model.mjs +163 -0
- package/swarm-task-routing-schemas.mjs +24 -0
- package/swarm-task-routing.mjs +178 -0
|
@@ -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
|
+
})
|