@shapeshift-labs/frontier-swarm 0.1.0 → 0.3.0
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 +62 -2
- package/benchmarks/package-bench.mjs +35 -1
- package/dist/index.d.ts +632 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1016 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,12 +8,45 @@ export const FRONTIER_SWARM_RUN_KIND = 'frontier.swarm.run';
|
|
|
8
8
|
export const FRONTIER_SWARM_RUN_VERSION = 1;
|
|
9
9
|
export const FRONTIER_SWARM_EVENT_KIND = 'frontier.swarm.event';
|
|
10
10
|
export const FRONTIER_SWARM_EVENT_VERSION = 1;
|
|
11
|
+
export const FRONTIER_SWARM_EVENT_STREAM_KIND = 'frontier.swarm.event-stream';
|
|
12
|
+
export const FRONTIER_SWARM_EVENT_STREAM_VERSION = 1;
|
|
13
|
+
export const FRONTIER_SWARM_MAILBOX_KIND = 'frontier.swarm.mailbox';
|
|
14
|
+
export const FRONTIER_SWARM_MAILBOX_VERSION = 1;
|
|
11
15
|
export const FRONTIER_SWARM_PROOF_KIND = 'frontier.swarm.proof';
|
|
12
16
|
export const FRONTIER_SWARM_PROOF_VERSION = 1;
|
|
17
|
+
export const FRONTIER_SWARM_SCHEDULE_KIND = 'frontier.swarm.schedule';
|
|
18
|
+
export const FRONTIER_SWARM_SCHEDULE_VERSION = 1;
|
|
19
|
+
export const FRONTIER_SWARM_LEASE_KIND = 'frontier.swarm.lease';
|
|
20
|
+
export const FRONTIER_SWARM_LEASE_VERSION = 1;
|
|
21
|
+
export const FRONTIER_SWARM_QUEUE_SNAPSHOT_KIND = 'frontier.swarm.queue-snapshot';
|
|
22
|
+
export const FRONTIER_SWARM_QUEUE_SNAPSHOT_VERSION = 1;
|
|
23
|
+
export const FRONTIER_SWARM_RUN_CHECKPOINT_KIND = 'frontier.swarm.run-checkpoint';
|
|
24
|
+
export const FRONTIER_SWARM_RUN_CHECKPOINT_VERSION = 1;
|
|
25
|
+
export const FRONTIER_SWARM_ARTIFACT_INDEX_KIND = 'frontier.swarm.artifact-index';
|
|
26
|
+
export const FRONTIER_SWARM_ARTIFACT_INDEX_VERSION = 1;
|
|
27
|
+
export const FRONTIER_SWARM_REVIEW_PLAN_KIND = 'frontier.swarm.review-plan';
|
|
28
|
+
export const FRONTIER_SWARM_REVIEW_PLAN_VERSION = 1;
|
|
29
|
+
export const FRONTIER_SWARM_MERGE_PLAN_KIND = 'frontier.swarm.merge-plan';
|
|
30
|
+
export const FRONTIER_SWARM_MERGE_PLAN_VERSION = 1;
|
|
31
|
+
export const FRONTIER_SWARM_MERGE_BUNDLE_KIND = 'frontier.swarm.merge-bundle';
|
|
32
|
+
export const FRONTIER_SWARM_MERGE_BUNDLE_VERSION = 1;
|
|
13
33
|
export const FRONTIER_SWARM_DEFAULT_CODEX_COMPUTE_ID = 'codex.gpt-5.5.xhigh';
|
|
14
34
|
export const FRONTIER_SWARM_DEFAULT_MODEL = 'gpt-5.5';
|
|
15
35
|
export const FRONTIER_SWARM_DEFAULT_REASONING_EFFORT = 'xhigh';
|
|
16
36
|
const DEFAULT_COMPLETED_STATUSES = ['completed', 'verified', 'done', 'verified-local-harness'];
|
|
37
|
+
const DEFAULT_SWARM_EVENT_TYPES = [
|
|
38
|
+
'swarm.started',
|
|
39
|
+
'swarm.finished',
|
|
40
|
+
'agent.scheduled',
|
|
41
|
+
'agent.finished',
|
|
42
|
+
'agent.handoff',
|
|
43
|
+
'agent.blocked',
|
|
44
|
+
'agent.ownership-request',
|
|
45
|
+
'agent.evidence',
|
|
46
|
+
'review.requested',
|
|
47
|
+
'review.completed',
|
|
48
|
+
'merge.proposed'
|
|
49
|
+
];
|
|
17
50
|
export function defineSwarmManifest(input = {}) {
|
|
18
51
|
return createSwarmManifest(input);
|
|
19
52
|
}
|
|
@@ -122,6 +155,8 @@ export function createSwarmPlan(manifestInput, taskInput, options = {}) {
|
|
|
122
155
|
const tasks = normalizeTaskList(taskInput);
|
|
123
156
|
const jobs = selectSwarmTasks(compiled.manifest, tasks, options).map((task) => createJob(compiled, task, options));
|
|
124
157
|
const id = options.id ?? 'swarm-plan:' + stableHash([compiled.manifest.id, jobs.map((job) => job.id), options]);
|
|
158
|
+
const graph = createSwarmJobGraph(jobs);
|
|
159
|
+
const validation = validateTasksForManifest(compiled, tasks, graph);
|
|
125
160
|
return {
|
|
126
161
|
kind: FRONTIER_SWARM_PLAN_KIND,
|
|
127
162
|
version: FRONTIER_SWARM_PLAN_VERSION,
|
|
@@ -138,12 +173,49 @@ export function createSwarmPlan(manifestInput, taskInput, options = {}) {
|
|
|
138
173
|
limit: options.limit,
|
|
139
174
|
compute: options.compute
|
|
140
175
|
},
|
|
141
|
-
|
|
176
|
+
limits: normalizeScheduleLimits(compiled.manifest, options),
|
|
177
|
+
validation,
|
|
142
178
|
jobs,
|
|
179
|
+
graph,
|
|
143
180
|
summary: summarizeJobs(jobs),
|
|
144
181
|
...(toJsonObject(options.metadata) ? { metadata: toJsonObject(options.metadata) } : {})
|
|
145
182
|
};
|
|
146
183
|
}
|
|
184
|
+
export function createSwarmTaskSelection(manifestInput, taskInput, options = {}) {
|
|
185
|
+
const manifest = compileSwarm(manifestInput).manifest;
|
|
186
|
+
const tasks = normalizeTaskList(taskInput);
|
|
187
|
+
const lanes = new Set(options.lanes ?? []);
|
|
188
|
+
const layers = new Set(options.layers ?? []);
|
|
189
|
+
const statuses = new Set(options.statuses ?? []);
|
|
190
|
+
const workKinds = new Set(options.workKinds ?? []);
|
|
191
|
+
const selectors = (options.selectors ?? []).map((selector) => selector.toLowerCase());
|
|
192
|
+
const completed = new Set(manifest.policy.completedStatuses);
|
|
193
|
+
const limit = options.limit === undefined ? tasks.length : Math.max(0, Math.floor(options.limit));
|
|
194
|
+
const candidates = tasks
|
|
195
|
+
.filter((task) => !task.lane || manifest.lanes.some((lane) => lane.id === task.lane))
|
|
196
|
+
.filter((task) => lanes.size === 0 || (task.lane !== undefined && lanes.has(task.lane)))
|
|
197
|
+
.filter((task) => layers.size === 0 || taskLayer(manifest, task) !== undefined && layers.has(taskLayer(manifest, task)))
|
|
198
|
+
.filter((task) => statuses.size === 0 || statuses.has(task.status))
|
|
199
|
+
.filter((task) => workKinds.size === 0 || workKinds.has(task.workKind))
|
|
200
|
+
.filter((task) => options.includeCompleted || !completed.has(task.status))
|
|
201
|
+
.filter((task) => selectors.length === 0 || selectors.some((selector) => searchableTask(task).includes(selector)))
|
|
202
|
+
.map((task) => createSelectionEntry(manifest, task, options.priority))
|
|
203
|
+
.filter((entry) => options.includeOwnershipWarnings || entry.ownershipWarnings.length === 0)
|
|
204
|
+
.sort((left, right) => (left.selectionPriority - right.selectionPriority
|
|
205
|
+
|| left.task.priority - right.task.priority
|
|
206
|
+
|| left.task.id.localeCompare(right.task.id)));
|
|
207
|
+
const ordered = options.spreadLanes ? roundRobinSelectionByLane(candidates) : candidates;
|
|
208
|
+
const entries = ordered.slice(0, limit).map((entry, index) => {
|
|
209
|
+
if (!options.assignSelectionPriority)
|
|
210
|
+
return entry;
|
|
211
|
+
return { ...entry, task: { ...entry.task, priority: index } };
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
tasks: entries.map((entry) => entry.task),
|
|
215
|
+
entries,
|
|
216
|
+
summary: summarizeTaskSelection(entries)
|
|
217
|
+
};
|
|
218
|
+
}
|
|
147
219
|
export function createSwarmRun(input) {
|
|
148
220
|
const results = (input.results ?? []).map(normalizeResult);
|
|
149
221
|
const events = (input.events ?? []).map((event) => normalizeEvent({ ...event, runId: event.runId ?? input.id ?? input.plan.runId }));
|
|
@@ -168,6 +240,67 @@ export function recordSwarmEvent(runInput, eventInput) {
|
|
|
168
240
|
run.events = run.events.concat(normalizeEvent({ ...eventInput, runId: eventInput.runId ?? run.id }));
|
|
169
241
|
return run;
|
|
170
242
|
}
|
|
243
|
+
export function createSwarmMailbox(input = {}) {
|
|
244
|
+
const scope = input.scope ?? (input.lane ? 'lane' : input.jobId ? 'job' : 'global');
|
|
245
|
+
const eventTypes = uniqueStrings(input.eventTypes ?? DEFAULT_SWARM_EVENT_TYPES);
|
|
246
|
+
return {
|
|
247
|
+
kind: FRONTIER_SWARM_MAILBOX_KIND,
|
|
248
|
+
version: FRONTIER_SWARM_MAILBOX_VERSION,
|
|
249
|
+
id: input.id ?? 'swarm-mailbox:' + stableHash([input.runId, scope, input.lane, input.jobId, input.path, eventTypes]),
|
|
250
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
251
|
+
scope,
|
|
252
|
+
...(input.lane ? { lane: input.lane } : {}),
|
|
253
|
+
...(input.jobId ? { jobId: input.jobId } : {}),
|
|
254
|
+
...(input.path ? { path: input.path } : {}),
|
|
255
|
+
eventTypes,
|
|
256
|
+
appendOnly: input.appendOnly ?? true,
|
|
257
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
export function createSwarmEventStream(input = {}) {
|
|
261
|
+
const laneIds = uniqueStrings((input.lanes ?? []).map(readLaneId));
|
|
262
|
+
const eventTypes = uniqueStrings(input.eventTypes ?? DEFAULT_SWARM_EVENT_TYPES);
|
|
263
|
+
const appendOnly = input.appendOnly ?? true;
|
|
264
|
+
const global = createSwarmMailbox({
|
|
265
|
+
runId: input.runId,
|
|
266
|
+
scope: 'global',
|
|
267
|
+
path: input.root ? joinPathParts(input.root, 'global.jsonl') : undefined,
|
|
268
|
+
eventTypes,
|
|
269
|
+
appendOnly
|
|
270
|
+
});
|
|
271
|
+
const lanes = Object.fromEntries(laneIds.map((lane) => [lane, createSwarmMailbox({
|
|
272
|
+
runId: input.runId,
|
|
273
|
+
scope: 'lane',
|
|
274
|
+
lane,
|
|
275
|
+
path: input.root ? joinPathParts(input.root, 'lanes', `${lane}.jsonl`) : undefined,
|
|
276
|
+
eventTypes,
|
|
277
|
+
appendOnly
|
|
278
|
+
})]));
|
|
279
|
+
return {
|
|
280
|
+
kind: FRONTIER_SWARM_EVENT_STREAM_KIND,
|
|
281
|
+
version: FRONTIER_SWARM_EVENT_STREAM_VERSION,
|
|
282
|
+
id: input.id ?? 'swarm-event-stream:' + stableHash([input.runId, input.root, laneIds, eventTypes]),
|
|
283
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
284
|
+
...(input.root ? { root: input.root } : {}),
|
|
285
|
+
appendOnly,
|
|
286
|
+
global,
|
|
287
|
+
lanes,
|
|
288
|
+
eventTypes,
|
|
289
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {}),
|
|
290
|
+
summary: {
|
|
291
|
+
mailboxCount: 1 + laneIds.length,
|
|
292
|
+
laneCount: laneIds.length,
|
|
293
|
+
eventTypeCount: eventTypes.length
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
export function routeSwarmEventToMailboxes(stream, eventInput) {
|
|
298
|
+
const event = isSwarmEvent(eventInput) ? eventInput : normalizeEvent(eventInput);
|
|
299
|
+
const mailboxes = [stream.global];
|
|
300
|
+
if (event.lane && stream.lanes[event.lane])
|
|
301
|
+
mailboxes.push(stream.lanes[event.lane]);
|
|
302
|
+
return mailboxes;
|
|
303
|
+
}
|
|
171
304
|
export function completeSwarmJob(runInput, resultInput) {
|
|
172
305
|
const run = cloneJsonValue(runInput);
|
|
173
306
|
const result = normalizeResult(resultInput);
|
|
@@ -197,6 +330,102 @@ export function checkSwarmOwnership(job, changedPaths) {
|
|
|
197
330
|
violations
|
|
198
331
|
};
|
|
199
332
|
}
|
|
333
|
+
export function resolveSwarmChangedRegions(job, changedPaths) {
|
|
334
|
+
const changed = uniqueStrings(changedPaths);
|
|
335
|
+
const regions = new Set(job.changedRegions);
|
|
336
|
+
for (const region of job.ownershipRegions) {
|
|
337
|
+
if (region.globs.some((glob) => changed.some((file) => matchesGlob(file, glob))))
|
|
338
|
+
regions.add(region.id);
|
|
339
|
+
for (const selector of region.selectors) {
|
|
340
|
+
if (changed.includes(selector))
|
|
341
|
+
regions.add(region.id);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return Array.from(regions).sort();
|
|
345
|
+
}
|
|
346
|
+
export function classifySwarmMergeReadiness(result) {
|
|
347
|
+
if (result.mergeReadiness)
|
|
348
|
+
return result.mergeReadiness;
|
|
349
|
+
if (result.status === 'blocked')
|
|
350
|
+
return 'blocked';
|
|
351
|
+
if (result.status === 'failed' || result.exitCode !== undefined && result.exitCode !== 0)
|
|
352
|
+
return 'rejected';
|
|
353
|
+
const changedPaths = result.changedPaths ?? [];
|
|
354
|
+
if (changedPaths.length === 0)
|
|
355
|
+
return 'discovery-only';
|
|
356
|
+
const ownershipViolations = result.ownershipViolations ?? [];
|
|
357
|
+
if (ownershipViolations.length)
|
|
358
|
+
return 'rejected';
|
|
359
|
+
const verification = result.verification ?? [];
|
|
360
|
+
const failedRequired = verification.some((entry) => entry.required !== false && entry.status !== 0);
|
|
361
|
+
if (failedRequired)
|
|
362
|
+
return 'patch-candidate';
|
|
363
|
+
return verification.length > 0 || result.status === 'verified' ? 'verified-patch' : 'patch-candidate';
|
|
364
|
+
}
|
|
365
|
+
export function classifySwarmMergeDisposition(result, input = {}) {
|
|
366
|
+
if (result.mergeDisposition)
|
|
367
|
+
return result.mergeDisposition;
|
|
368
|
+
if (input.staleAgainstHead)
|
|
369
|
+
return 'stale-against-head';
|
|
370
|
+
const readiness = classifySwarmMergeReadiness(result);
|
|
371
|
+
if (readiness === 'discovery-only')
|
|
372
|
+
return 'discovery-only';
|
|
373
|
+
if (readiness === 'blocked')
|
|
374
|
+
return 'blocked';
|
|
375
|
+
if (readiness === 'rejected')
|
|
376
|
+
return 'rejected';
|
|
377
|
+
if (readiness === 'verified-patch')
|
|
378
|
+
return 'auto-mergeable';
|
|
379
|
+
return 'needs-port';
|
|
380
|
+
}
|
|
381
|
+
export function createSwarmMergeBundle(input) {
|
|
382
|
+
const generatedAt = input.generatedAt ?? Date.now();
|
|
383
|
+
const result = isSwarmJobResult(input.result) ? cloneJsonValue(input.result) : normalizeResult(input.result);
|
|
384
|
+
const job = input.job;
|
|
385
|
+
const changedPaths = uniqueStrings(result.changedPaths);
|
|
386
|
+
const changedRegions = uniqueStrings([
|
|
387
|
+
...result.changedRegions,
|
|
388
|
+
...(job ? resolveSwarmChangedRegions(job, changedPaths) : [])
|
|
389
|
+
]);
|
|
390
|
+
const evidencePaths = uniqueStrings([...(result.evidencePaths ?? []), ...(input.evidencePaths ?? [])]);
|
|
391
|
+
const queueItemIds = uniqueStrings([...(result.queueItemIds ?? []), ...(input.queueItemIds ?? []), ...(job ? [job.taskId] : [])]);
|
|
392
|
+
const disposition = input.disposition ?? classifySwarmMergeDisposition(result, { staleAgainstHead: input.staleAgainstHead });
|
|
393
|
+
const commandsPassed = result.verification.filter((entry) => entry.status === 0 || entry.required === false && entry.status === undefined);
|
|
394
|
+
const commandsFailed = result.verification.filter((entry) => entry.status !== undefined && entry.status !== 0 && entry.required !== false);
|
|
395
|
+
const ownedFilesTouched = job ? changedPaths.filter((file) => job.allowedWrites.some((glob) => matchesGlob(file, glob))) : changedPaths;
|
|
396
|
+
const reasons = mergeBundleReasons(result, disposition, input.staleAgainstHead ?? false);
|
|
397
|
+
return {
|
|
398
|
+
kind: FRONTIER_SWARM_MERGE_BUNDLE_KIND,
|
|
399
|
+
version: FRONTIER_SWARM_MERGE_BUNDLE_VERSION,
|
|
400
|
+
id: input.id ?? 'swarm-merge-bundle:' + stableHash([input.runId, input.planId, result.jobId, changedPaths, changedRegions, disposition, generatedAt]),
|
|
401
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
402
|
+
...(input.planId ? { planId: input.planId } : {}),
|
|
403
|
+
jobId: result.jobId,
|
|
404
|
+
...(job ? { taskId: job.taskId, lane: job.lane, title: job.title } : {}),
|
|
405
|
+
generatedAt,
|
|
406
|
+
status: result.status,
|
|
407
|
+
mergeReadiness: result.mergeReadiness,
|
|
408
|
+
disposition,
|
|
409
|
+
riskLevel: input.riskLevel ?? result.riskLevel ?? inferMergeRisk(result, disposition),
|
|
410
|
+
autoMergeable: disposition === 'auto-mergeable' && reasons.length === 0,
|
|
411
|
+
changedPaths,
|
|
412
|
+
changedRegions,
|
|
413
|
+
ownedFilesTouched,
|
|
414
|
+
allowedWrites: job ? [...job.allowedWrites] : [],
|
|
415
|
+
ownershipViolations: [...result.ownershipViolations],
|
|
416
|
+
...(input.patchPath ?? result.patchPath ? { patchPath: input.patchPath ?? result.patchPath } : {}),
|
|
417
|
+
...(input.patchHash ? { patchHash: input.patchHash } : {}),
|
|
418
|
+
evidencePaths,
|
|
419
|
+
commandsPassed,
|
|
420
|
+
commandsFailed,
|
|
421
|
+
queueItemIds,
|
|
422
|
+
...(input.branchName ? { branchName: input.branchName } : {}),
|
|
423
|
+
...(input.commit ? { commit: input.commit } : {}),
|
|
424
|
+
staleAgainstHead: input.staleAgainstHead ?? false,
|
|
425
|
+
reasons,
|
|
426
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
427
|
+
};
|
|
428
|
+
}
|
|
200
429
|
export function resolveSwarmCompute(manifestInput, taskInput) {
|
|
201
430
|
const compiled = compileSwarm(manifestInput);
|
|
202
431
|
const task = isSwarmTask(taskInput) ? taskInput : normalizeTask(taskInput);
|
|
@@ -218,6 +447,304 @@ export function createSwarmProof(input, options = {}) {
|
|
|
218
447
|
...(toJsonObject(options.metadata) ? { metadata: toJsonObject(options.metadata) } : {})
|
|
219
448
|
};
|
|
220
449
|
}
|
|
450
|
+
export function createSwarmSchedule(input) {
|
|
451
|
+
const plan = 'plan' in input ? input.plan : input;
|
|
452
|
+
const run = 'plan' in input ? input.run : undefined;
|
|
453
|
+
const createdAt = 'plan' in input ? input.now ?? Date.now() : Date.now();
|
|
454
|
+
const limits = mergeScheduleLimits(plan.limits, 'plan' in input ? input : {});
|
|
455
|
+
const resultsByJob = new Map((run?.results ?? []).map((result) => [result.jobId, result]));
|
|
456
|
+
const completed = new Set();
|
|
457
|
+
const failed = new Set();
|
|
458
|
+
for (const result of resultsByJob.values()) {
|
|
459
|
+
if (result.status === 'completed' || result.status === 'verified')
|
|
460
|
+
completed.add(result.jobId);
|
|
461
|
+
if (result.status === 'failed' || result.exitCode !== undefined && result.exitCode !== 0)
|
|
462
|
+
failed.add(result.jobId);
|
|
463
|
+
}
|
|
464
|
+
const runningJobs = (run?.jobs ?? plan.jobs)
|
|
465
|
+
.filter((job) => job.status === 'running')
|
|
466
|
+
.map((job) => ({
|
|
467
|
+
jobId: job.id,
|
|
468
|
+
lane: job.lane,
|
|
469
|
+
compute: job.compute.id,
|
|
470
|
+
concurrencyKey: job.concurrencyKey,
|
|
471
|
+
capabilities: [...job.capabilities],
|
|
472
|
+
...(job.resourceRequirements ? { resourceRequirements: cloneJsonValue(job.resourceRequirements) } : {})
|
|
473
|
+
}));
|
|
474
|
+
const runningByLane = countBy(runningJobs.map((job) => job.lane));
|
|
475
|
+
const runningByKey = countBy(runningJobs.map((job) => job.concurrencyKey));
|
|
476
|
+
const runningByCompute = countBy(runningJobs.map((job) => job.compute));
|
|
477
|
+
const ready = [];
|
|
478
|
+
const blocked = [];
|
|
479
|
+
const sortedJobs = [...plan.jobs].sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id));
|
|
480
|
+
for (const job of sortedJobs) {
|
|
481
|
+
if (completed.has(job.id) || failed.has(job.id) || runningJobs.some((running) => running.jobId === job.id))
|
|
482
|
+
continue;
|
|
483
|
+
const dependencyIds = plan.graph.dependenciesByJobId[job.id] ?? [];
|
|
484
|
+
const waitingFor = dependencyIds.filter((dep) => !completed.has(dep));
|
|
485
|
+
const reasons = [];
|
|
486
|
+
if (waitingFor.length)
|
|
487
|
+
reasons.push('waiting-for-dependencies');
|
|
488
|
+
const laneMax = limits.maxLaneConcurrency[job.lane] ?? Number.POSITIVE_INFINITY;
|
|
489
|
+
const keyMax = limits.maxConcurrencyKeyConcurrency[job.concurrencyKey] ?? Number.POSITIVE_INFINITY;
|
|
490
|
+
const computeMax = limits.maxComputeConcurrency[job.compute.id] ?? job.compute.maxConcurrency ?? Number.POSITIVE_INFINITY;
|
|
491
|
+
if ((runningByLane[job.lane] ?? 0) >= laneMax)
|
|
492
|
+
reasons.push('lane-capacity');
|
|
493
|
+
if ((runningByKey[job.concurrencyKey] ?? 0) >= keyMax)
|
|
494
|
+
reasons.push('concurrency-key-capacity');
|
|
495
|
+
if ((runningByCompute[job.compute.id] ?? 0) >= computeMax)
|
|
496
|
+
reasons.push('compute-capacity');
|
|
497
|
+
const scheduled = scheduleJob(job, dependencyIds);
|
|
498
|
+
if (reasons.length) {
|
|
499
|
+
blocked.push({ ...scheduled, reasons, waitingFor });
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (limits.maxReadyJobs !== undefined && ready.length >= limits.maxReadyJobs) {
|
|
503
|
+
blocked.push({ ...scheduled, reasons: ['ready-capacity'], waitingFor: [] });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
ready.push(scheduled);
|
|
507
|
+
runningByLane[job.lane] = (runningByLane[job.lane] ?? 0) + 1;
|
|
508
|
+
runningByKey[job.concurrencyKey] = (runningByKey[job.concurrencyKey] ?? 0) + 1;
|
|
509
|
+
runningByCompute[job.compute.id] = (runningByCompute[job.compute.id] ?? 0) + 1;
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
kind: FRONTIER_SWARM_SCHEDULE_KIND,
|
|
513
|
+
version: FRONTIER_SWARM_SCHEDULE_VERSION,
|
|
514
|
+
id: 'swarm-schedule:' + stableHash([plan.id, run?.id, ready.map((job) => job.jobId), blocked.map((job) => [job.jobId, job.reasons]), createdAt]),
|
|
515
|
+
planId: plan.id,
|
|
516
|
+
...(run ? { runId: run.id } : {}),
|
|
517
|
+
createdAt,
|
|
518
|
+
ready,
|
|
519
|
+
blocked,
|
|
520
|
+
running: runningJobs,
|
|
521
|
+
completed: Array.from(completed).sort(),
|
|
522
|
+
failed: Array.from(failed).sort(),
|
|
523
|
+
summary: {
|
|
524
|
+
jobCount: plan.jobs.length,
|
|
525
|
+
readyCount: ready.length,
|
|
526
|
+
blockedCount: blocked.length,
|
|
527
|
+
runningCount: runningJobs.length,
|
|
528
|
+
completedCount: completed.size,
|
|
529
|
+
failedCount: failed.size
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
export function createSwarmLeases(input) {
|
|
534
|
+
const now = input.now ?? Date.now();
|
|
535
|
+
const leaseMs = Math.max(1, Math.floor(input.leaseMs ?? 900000));
|
|
536
|
+
const activeJobIds = new Set((input.existingLeases ?? []).filter((lease) => lease.status === 'active' && lease.expiresAt > now).map((lease) => lease.jobId));
|
|
537
|
+
const existingMaxFence = Math.max(0, ...(input.existingLeases ?? []).map((lease) => lease.fencingToken));
|
|
538
|
+
const count = Math.max(0, Math.floor(input.count ?? input.schedule.ready.length));
|
|
539
|
+
return input.schedule.ready
|
|
540
|
+
.filter((job) => !activeJobIds.has(job.jobId))
|
|
541
|
+
.slice(0, count)
|
|
542
|
+
.map((job, index) => ({
|
|
543
|
+
kind: FRONTIER_SWARM_LEASE_KIND,
|
|
544
|
+
version: FRONTIER_SWARM_LEASE_VERSION,
|
|
545
|
+
id: 'swarm-lease:' + stableHash([input.schedule.id, job.jobId, input.workerId, now, existingMaxFence + index + 1]),
|
|
546
|
+
jobId: job.jobId,
|
|
547
|
+
workerId: input.workerId,
|
|
548
|
+
token: stableHash([job.jobId, input.workerId, now, existingMaxFence + index + 1]),
|
|
549
|
+
leasedAt: now,
|
|
550
|
+
expiresAt: now + leaseMs,
|
|
551
|
+
fencingToken: existingMaxFence + index + 1,
|
|
552
|
+
status: 'active'
|
|
553
|
+
}));
|
|
554
|
+
}
|
|
555
|
+
export function renewSwarmLease(input) {
|
|
556
|
+
const now = input.now ?? Date.now();
|
|
557
|
+
const leaseMs = Math.max(1, Math.floor(input.leaseMs ?? Math.max(1, input.lease.expiresAt - input.lease.leasedAt)));
|
|
558
|
+
return {
|
|
559
|
+
...cloneJsonValue(input.lease),
|
|
560
|
+
leasedAt: now,
|
|
561
|
+
expiresAt: now + leaseMs,
|
|
562
|
+
status: input.status ?? 'active'
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
export function createSwarmQueueSnapshot(input) {
|
|
566
|
+
const generatedAt = input.generatedAt ?? Date.now();
|
|
567
|
+
const leases = [...(input.leases ?? [])].map((lease) => cloneJsonValue(lease));
|
|
568
|
+
const jobs = (input.jobs ? input.jobs.map(normalizeQueueJob) : queueJobsFromPlan(input.plan, input.run, leases)).sort((left, right) => (left.priority - right.priority
|
|
569
|
+
|| left.jobId.localeCompare(right.jobId)));
|
|
570
|
+
const byStatus = groupIds(jobs, (job) => job.status);
|
|
571
|
+
const byLane = groupIds(jobs, (job) => job.lane ?? 'unassigned');
|
|
572
|
+
return {
|
|
573
|
+
kind: FRONTIER_SWARM_QUEUE_SNAPSHOT_KIND,
|
|
574
|
+
version: FRONTIER_SWARM_QUEUE_SNAPSHOT_VERSION,
|
|
575
|
+
id: input.id ?? 'swarm-queue-snapshot:' + stableHash([input.plan.id, input.run?.id, jobs, leases, generatedAt]),
|
|
576
|
+
planId: input.plan.id,
|
|
577
|
+
runId: input.run?.id ?? input.plan.runId,
|
|
578
|
+
generatedAt,
|
|
579
|
+
jobs,
|
|
580
|
+
byStatus,
|
|
581
|
+
byLane,
|
|
582
|
+
leases,
|
|
583
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {}),
|
|
584
|
+
summary: {
|
|
585
|
+
jobCount: jobs.length,
|
|
586
|
+
leaseCount: leases.length,
|
|
587
|
+
readyCount: byStatus.ready?.length ?? 0,
|
|
588
|
+
leasedCount: byStatus.leased?.length ?? 0,
|
|
589
|
+
completedCount: byStatus.completed?.length ?? 0,
|
|
590
|
+
failedCount: byStatus.failed?.length ?? 0,
|
|
591
|
+
deadLetterCount: byStatus['dead-letter']?.length ?? 0
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
export function createSwarmRunCheckpoint(input) {
|
|
596
|
+
const run = 'kind' in input ? input : input.run;
|
|
597
|
+
const sequence = 'kind' in input ? run.events.length + run.results.length : input.sequence ?? run.events.length + run.results.length;
|
|
598
|
+
const savedAt = 'kind' in input ? Date.now() : input.savedAt ?? Date.now();
|
|
599
|
+
return {
|
|
600
|
+
kind: FRONTIER_SWARM_RUN_CHECKPOINT_KIND,
|
|
601
|
+
version: FRONTIER_SWARM_RUN_CHECKPOINT_VERSION,
|
|
602
|
+
id: 'swarm-run-checkpoint:' + stableHash([run.id, sequence, savedAt, run.summary]),
|
|
603
|
+
runId: run.id,
|
|
604
|
+
planId: run.planId,
|
|
605
|
+
sequence,
|
|
606
|
+
savedAt,
|
|
607
|
+
status: run.status,
|
|
608
|
+
eventCount: run.events.length,
|
|
609
|
+
resultCount: run.results.length,
|
|
610
|
+
hash: stableHash(run),
|
|
611
|
+
...(!('kind' in input) && toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
export function checkSwarmBudget(job, usageInput) {
|
|
615
|
+
const usage = normalizeUsage(usageInput);
|
|
616
|
+
const budget = job.budget;
|
|
617
|
+
const violations = [];
|
|
618
|
+
if (budget?.maxCostUsd !== undefined && usage.costUsd > budget.maxCostUsd)
|
|
619
|
+
violations.push('max-cost-usd');
|
|
620
|
+
if (budget?.maxInputTokens !== undefined && usage.inputTokens > budget.maxInputTokens)
|
|
621
|
+
violations.push('max-input-tokens');
|
|
622
|
+
if (budget?.maxOutputTokens !== undefined && usage.outputTokens > budget.maxOutputTokens)
|
|
623
|
+
violations.push('max-output-tokens');
|
|
624
|
+
if (budget?.maxDurationMs !== undefined && usage.durationMs > budget.maxDurationMs)
|
|
625
|
+
violations.push('max-duration-ms');
|
|
626
|
+
if (budget && usage.attempts > budget.maxRetries + 1)
|
|
627
|
+
violations.push('max-retries');
|
|
628
|
+
return { ok: violations.length === 0, jobId: job.id, usage, ...(budget ? { budget } : {}), violations };
|
|
629
|
+
}
|
|
630
|
+
export function createSwarmArtifactIndex(input) {
|
|
631
|
+
const run = 'kind' in input ? input : input.run;
|
|
632
|
+
const generatedAt = 'kind' in input ? Date.now() : input.generatedAt ?? Date.now();
|
|
633
|
+
const explicit = 'kind' in input ? [] : input.artifacts ?? [];
|
|
634
|
+
const artifacts = [
|
|
635
|
+
...(run?.results ?? []).flatMap((result) => result.evidencePaths.map((evidencePath) => normalizeArtifact({ jobId: result.jobId, path: evidencePath, kind: 'evidence' }))),
|
|
636
|
+
...explicit.map(normalizeArtifact)
|
|
637
|
+
].sort((left, right) => left.jobId.localeCompare(right.jobId) || left.path.localeCompare(right.path));
|
|
638
|
+
const byJobId = groupArtifacts(artifacts, (artifact) => artifact.jobId);
|
|
639
|
+
const byKind = groupArtifacts(artifacts, (artifact) => artifact.kind);
|
|
640
|
+
return {
|
|
641
|
+
kind: FRONTIER_SWARM_ARTIFACT_INDEX_KIND,
|
|
642
|
+
version: FRONTIER_SWARM_ARTIFACT_INDEX_VERSION,
|
|
643
|
+
id: 'swarm-artifact-index:' + stableHash([artifacts, generatedAt]),
|
|
644
|
+
generatedAt,
|
|
645
|
+
artifacts,
|
|
646
|
+
byJobId,
|
|
647
|
+
byKind,
|
|
648
|
+
summary: {
|
|
649
|
+
artifactCount: artifacts.length,
|
|
650
|
+
jobCount: Object.keys(byJobId).length,
|
|
651
|
+
kindCount: Object.keys(byKind).length,
|
|
652
|
+
totalBytes: artifacts.reduce((total, artifact) => total + (artifact.bytes ?? 0), 0)
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
export function createSwarmReviewPlan(input) {
|
|
657
|
+
const generatedAt = input.generatedAt ?? Date.now();
|
|
658
|
+
const resultsByJob = new Map((input.run?.results ?? []).map((result) => [result.jobId, result]));
|
|
659
|
+
const budgetsByJob = new Map((input.budgetDecisions ?? []).map((decision) => [decision.jobId, decision]));
|
|
660
|
+
const assignments = [];
|
|
661
|
+
for (const job of input.plan.jobs) {
|
|
662
|
+
const result = resultsByJob.get(job.id);
|
|
663
|
+
const budget = budgetsByJob.get(job.id);
|
|
664
|
+
const reason = reviewReason(job, result, budget, input.sampleSalt ?? input.plan.id);
|
|
665
|
+
if (!reason)
|
|
666
|
+
continue;
|
|
667
|
+
const pool = job.review.reviewerPool.length ? job.review.reviewerPool : [...(input.reviewers ?? [])];
|
|
668
|
+
assignments.push({
|
|
669
|
+
jobId: job.id,
|
|
670
|
+
taskId: job.taskId,
|
|
671
|
+
reviewers: selectReviewers(pool, job.review.requiredReviewers, job.id),
|
|
672
|
+
required: job.review.alwaysReview || reason === 'violations' || reason === 'failed' || reason === 'budget',
|
|
673
|
+
reason
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return {
|
|
677
|
+
kind: FRONTIER_SWARM_REVIEW_PLAN_KIND,
|
|
678
|
+
version: FRONTIER_SWARM_REVIEW_PLAN_VERSION,
|
|
679
|
+
id: 'swarm-review-plan:' + stableHash([input.plan.id, assignments, generatedAt]),
|
|
680
|
+
planId: input.plan.id,
|
|
681
|
+
generatedAt,
|
|
682
|
+
assignments,
|
|
683
|
+
summary: {
|
|
684
|
+
assignmentCount: assignments.length,
|
|
685
|
+
requiredCount: assignments.filter((assignment) => assignment.required).length,
|
|
686
|
+
sampledCount: assignments.filter((assignment) => assignment.reason === 'sampled').length
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
export function createSwarmMergePlan(input) {
|
|
691
|
+
const generatedAt = input.generatedAt ?? Date.now();
|
|
692
|
+
const resultsByJob = new Map(input.run.results.map((result) => [result.jobId, result]));
|
|
693
|
+
const reviewRequired = new Set((input.reviewPlan?.assignments ?? []).filter((assignment) => assignment.required).map((assignment) => assignment.jobId));
|
|
694
|
+
const conflicts = conflictMap(input.run.results);
|
|
695
|
+
const ready = [];
|
|
696
|
+
const blocked = [];
|
|
697
|
+
for (const job of input.plan.jobs) {
|
|
698
|
+
const result = resultsByJob.get(job.id);
|
|
699
|
+
const reasons = [];
|
|
700
|
+
if (!result || result.status !== 'completed' && result.status !== 'verified')
|
|
701
|
+
reasons.push('not-completed');
|
|
702
|
+
if (result?.ownershipViolations.length)
|
|
703
|
+
reasons.push('ownership-violations');
|
|
704
|
+
if (reviewRequired.has(job.id))
|
|
705
|
+
reasons.push('review-required');
|
|
706
|
+
const conflictingJobIds = Array.from(conflicts.get(job.id) ?? []).sort();
|
|
707
|
+
if (conflictingJobIds.length)
|
|
708
|
+
reasons.push('conflicting-changes');
|
|
709
|
+
if (reasons.length)
|
|
710
|
+
blocked.push({ jobId: job.id, reasons: uniqueStrings(reasons), conflictingJobIds });
|
|
711
|
+
else
|
|
712
|
+
ready.push(job.id);
|
|
713
|
+
}
|
|
714
|
+
const groups = groupMergeReadyJobs(ready, input.run.results);
|
|
715
|
+
return {
|
|
716
|
+
kind: FRONTIER_SWARM_MERGE_PLAN_KIND,
|
|
717
|
+
version: FRONTIER_SWARM_MERGE_PLAN_VERSION,
|
|
718
|
+
id: 'swarm-merge-plan:' + stableHash([input.plan.id, ready, blocked, generatedAt]),
|
|
719
|
+
planId: input.plan.id,
|
|
720
|
+
generatedAt,
|
|
721
|
+
ready,
|
|
722
|
+
blocked,
|
|
723
|
+
groups,
|
|
724
|
+
summary: { readyCount: ready.length, blockedCount: blocked.length, groupCount: groups.length }
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
export function decomposeSwarmFeature(input) {
|
|
728
|
+
const filesByLane = new Map();
|
|
729
|
+
const lanes = input.lanes.length ? [...input.lanes] : ['implementation'];
|
|
730
|
+
for (const lane of lanes)
|
|
731
|
+
filesByLane.set(lane, []);
|
|
732
|
+
for (const file of input.files ?? []) {
|
|
733
|
+
const selected = lanes.find((lane) => file.toLowerCase().includes(lane.toLowerCase())) ?? lanes[filesByLane.size ? stableHash(file).charCodeAt(10) % lanes.length : 0];
|
|
734
|
+
filesByLane.get(selected)?.push(file);
|
|
735
|
+
}
|
|
736
|
+
return lanes.map((lane, index) => ({
|
|
737
|
+
id: `${input.featureId}-${slug(lane)}`,
|
|
738
|
+
lane,
|
|
739
|
+
title: `${titleFromId(lane)} for ${input.featureId}`,
|
|
740
|
+
objective: input.objective,
|
|
741
|
+
priority: 100 + index,
|
|
742
|
+
targetRefs: filesByLane.get(lane) ?? [],
|
|
743
|
+
verification: input.checks ?? [],
|
|
744
|
+
review: input.reviewers?.length ? { requiredReviewers: 1, reviewerPool: input.reviewers } : undefined,
|
|
745
|
+
metadata: toJsonObject(input.metadata)
|
|
746
|
+
}));
|
|
747
|
+
}
|
|
221
748
|
export function encodeSwarmJsonl(records) {
|
|
222
749
|
return records.map((record) => JSON.stringify(record)).join('\n') + (records.length ? '\n' : '');
|
|
223
750
|
}
|
|
@@ -232,6 +759,231 @@ export function matchesGlob(file, glob) {
|
|
|
232
759
|
.replace(/\u0000/g, '.*');
|
|
233
760
|
return new RegExp('^' + escaped + '$').test(file);
|
|
234
761
|
}
|
|
762
|
+
function createSwarmJobGraph(jobs) {
|
|
763
|
+
const nodes = jobs.map((job) => job.id).sort();
|
|
764
|
+
const nodeSet = new Set(nodes);
|
|
765
|
+
const taskToJob = new Map(jobs.map((job) => [job.taskId, job.id]));
|
|
766
|
+
const edges = [];
|
|
767
|
+
const issues = [];
|
|
768
|
+
for (const job of jobs) {
|
|
769
|
+
for (const rawDep of job.dependsOn) {
|
|
770
|
+
const dep = nodeSet.has(rawDep) ? rawDep : taskToJob.get(rawDep);
|
|
771
|
+
if (!dep) {
|
|
772
|
+
addIssue(issues, 'missing-job-dependency', 'error', `jobs.${job.id}.dependsOn`, `Job dependency is not in this plan: ${rawDep}`);
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (dep === job.id) {
|
|
776
|
+
addIssue(issues, 'self-job-dependency', 'error', `jobs.${job.id}.dependsOn`, `Job cannot depend on itself: ${job.id}`);
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
edges.push({ from: dep, to: job.id, type: 'depends-on' });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const dependenciesByJobId = Object.fromEntries(nodes.map((node) => [node, []]));
|
|
783
|
+
const dependentsByJobId = Object.fromEntries(nodes.map((node) => [node, []]));
|
|
784
|
+
for (const edge of edges) {
|
|
785
|
+
dependenciesByJobId[edge.to]?.push(edge.from);
|
|
786
|
+
dependentsByJobId[edge.from]?.push(edge.to);
|
|
787
|
+
}
|
|
788
|
+
for (const key of nodes) {
|
|
789
|
+
dependenciesByJobId[key] = uniqueStrings(dependenciesByJobId[key] ?? []).sort();
|
|
790
|
+
dependentsByJobId[key] = uniqueStrings(dependentsByJobId[key] ?? []).sort();
|
|
791
|
+
if (hasJobDependencyCycle(key, dependenciesByJobId)) {
|
|
792
|
+
addIssue(issues, 'job-dependency-cycle', 'error', `jobs.${key}.dependsOn`, `Job dependency graph contains a cycle at ${key}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
nodes,
|
|
797
|
+
edges: edges.sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)),
|
|
798
|
+
dependentsByJobId,
|
|
799
|
+
dependenciesByJobId,
|
|
800
|
+
roots: nodes.filter((node) => dependenciesByJobId[node]?.length === 0),
|
|
801
|
+
leaves: nodes.filter((node) => dependentsByJobId[node]?.length === 0),
|
|
802
|
+
issues
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
function normalizeScheduleLimits(manifest, options) {
|
|
806
|
+
const maxLaneConcurrency = {};
|
|
807
|
+
for (const lane of manifest.lanes) {
|
|
808
|
+
const browserMax = lane.resourceRequirements?.browser?.maxConcurrency;
|
|
809
|
+
const value = options.maxLaneConcurrency?.[lane.id] ?? lane.maxConcurrency ?? browserMax ?? manifest.policy.defaultConcurrency;
|
|
810
|
+
maxLaneConcurrency[lane.id] = Math.max(1, Math.floor(value));
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
...(positiveNumber(options.maxReadyJobs) ? { maxReadyJobs: Math.floor(options.maxReadyJobs) } : {}),
|
|
814
|
+
maxLaneConcurrency: { ...maxLaneConcurrency, ...(options.maxLaneConcurrency ?? {}) },
|
|
815
|
+
maxConcurrencyKeyConcurrency: { ...(options.maxConcurrencyKeyConcurrency ?? {}) },
|
|
816
|
+
maxComputeConcurrency: { ...(options.maxComputeConcurrency ?? {}) }
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
function mergeScheduleLimits(base, override) {
|
|
820
|
+
return {
|
|
821
|
+
maxReadyJobs: positiveNumber(override.maxReadyJobs) ? Math.floor(override.maxReadyJobs) : base.maxReadyJobs,
|
|
822
|
+
maxLaneConcurrency: { ...base.maxLaneConcurrency, ...(override.maxLaneConcurrency ?? {}) },
|
|
823
|
+
maxConcurrencyKeyConcurrency: { ...base.maxConcurrencyKeyConcurrency, ...(override.maxConcurrencyKeyConcurrency ?? {}) },
|
|
824
|
+
maxComputeConcurrency: { ...base.maxComputeConcurrency, ...(override.maxComputeConcurrency ?? {}) }
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function scheduleJob(job, dependsOn = job.dependsOn) {
|
|
828
|
+
return {
|
|
829
|
+
jobId: job.id,
|
|
830
|
+
taskId: job.taskId,
|
|
831
|
+
lane: job.lane,
|
|
832
|
+
compute: job.compute.id,
|
|
833
|
+
concurrencyKey: job.concurrencyKey,
|
|
834
|
+
priority: job.priority,
|
|
835
|
+
dependsOn: [...dependsOn],
|
|
836
|
+
capabilities: [...job.capabilities],
|
|
837
|
+
...(job.resourceRequirements ? { resourceRequirements: cloneJsonValue(job.resourceRequirements) } : {})
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
function normalizeBudget(input = {}) {
|
|
841
|
+
return {
|
|
842
|
+
...(positiveNumber(input.maxCostUsd) ? { maxCostUsd: input.maxCostUsd } : {}),
|
|
843
|
+
...(positiveNumber(input.maxInputTokens) ? { maxInputTokens: Math.floor(input.maxInputTokens) } : {}),
|
|
844
|
+
...(positiveNumber(input.maxOutputTokens) ? { maxOutputTokens: Math.floor(input.maxOutputTokens) } : {}),
|
|
845
|
+
...(positiveNumber(input.maxDurationMs) ? { maxDurationMs: Math.floor(input.maxDurationMs) } : {}),
|
|
846
|
+
maxRetries: Math.max(0, Math.floor(input.maxRetries ?? 0)),
|
|
847
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function normalizeUsage(input) {
|
|
851
|
+
return {
|
|
852
|
+
costUsd: Math.max(0, input.costUsd ?? 0),
|
|
853
|
+
inputTokens: Math.max(0, Math.floor(input.inputTokens ?? 0)),
|
|
854
|
+
outputTokens: Math.max(0, Math.floor(input.outputTokens ?? 0)),
|
|
855
|
+
durationMs: Math.max(0, Math.floor(input.durationMs ?? 0)),
|
|
856
|
+
attempts: Math.max(1, Math.floor(input.attempts ?? 1)),
|
|
857
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function normalizeReviewPolicy(input = {}) {
|
|
861
|
+
const sampleRate = typeof input.sampleRate === 'number' && Number.isFinite(input.sampleRate)
|
|
862
|
+
? Math.min(1, Math.max(0, input.sampleRate))
|
|
863
|
+
: 0;
|
|
864
|
+
return {
|
|
865
|
+
requiredReviewers: Math.max(0, Math.floor(input.requiredReviewers ?? 0)),
|
|
866
|
+
sampleRate,
|
|
867
|
+
alwaysReview: input.alwaysReview ?? false,
|
|
868
|
+
reviewerPool: uniqueStrings(input.reviewerPool ?? []),
|
|
869
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
function normalizeArtifact(input) {
|
|
873
|
+
return {
|
|
874
|
+
jobId: normalizeId(input.jobId, 'artifact job id'),
|
|
875
|
+
path: normalizeId(input.path, 'artifact path'),
|
|
876
|
+
kind: input.kind ?? 'artifact',
|
|
877
|
+
...(positiveNumber(input.bytes) ? { bytes: Math.floor(input.bytes) } : {}),
|
|
878
|
+
...(input.hash ? { hash: input.hash } : {}),
|
|
879
|
+
...(input.producedAt !== undefined ? { producedAt: input.producedAt } : {}),
|
|
880
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
function reviewReason(job, result, budget, sampleSalt) {
|
|
884
|
+
if (result?.ownershipViolations.length)
|
|
885
|
+
return 'violations';
|
|
886
|
+
if (result?.status === 'failed' || result?.exitCode !== undefined && result.exitCode !== 0)
|
|
887
|
+
return 'failed';
|
|
888
|
+
if (budget && !budget.ok)
|
|
889
|
+
return 'budget';
|
|
890
|
+
if (job.review.alwaysReview)
|
|
891
|
+
return 'always-review';
|
|
892
|
+
if (job.review.sampleRate > 0 && deterministicUnitInterval([sampleSalt, job.id]) < job.review.sampleRate)
|
|
893
|
+
return 'sampled';
|
|
894
|
+
return undefined;
|
|
895
|
+
}
|
|
896
|
+
function selectReviewers(pool, required, salt) {
|
|
897
|
+
if (required <= 0 || pool.length === 0)
|
|
898
|
+
return [];
|
|
899
|
+
const sorted = [...uniqueStrings(pool)].sort((left, right) => stableHash([salt, left]).localeCompare(stableHash([salt, right])));
|
|
900
|
+
return sorted.slice(0, Math.min(required, sorted.length));
|
|
901
|
+
}
|
|
902
|
+
function conflictMap(results) {
|
|
903
|
+
const byPath = new Map();
|
|
904
|
+
for (const result of results) {
|
|
905
|
+
const keys = result.changedRegions.length
|
|
906
|
+
? result.changedRegions.map((region) => `region:${region}`)
|
|
907
|
+
: result.changedPaths.map((file) => `file:${file}`);
|
|
908
|
+
for (const key of keys) {
|
|
909
|
+
const list = byPath.get(key) ?? [];
|
|
910
|
+
list.push(result.jobId);
|
|
911
|
+
byPath.set(key, list);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const conflicts = new Map();
|
|
915
|
+
for (const jobIds of byPath.values()) {
|
|
916
|
+
if (jobIds.length < 2)
|
|
917
|
+
continue;
|
|
918
|
+
for (const jobId of jobIds) {
|
|
919
|
+
const set = conflicts.get(jobId) ?? new Set();
|
|
920
|
+
for (const other of jobIds) {
|
|
921
|
+
if (other !== jobId)
|
|
922
|
+
set.add(other);
|
|
923
|
+
}
|
|
924
|
+
conflicts.set(jobId, set);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return conflicts;
|
|
928
|
+
}
|
|
929
|
+
function groupMergeReadyJobs(ready, results) {
|
|
930
|
+
const byJob = new Map(results.map((result) => [result.jobId, result]));
|
|
931
|
+
return ready.map((jobId) => {
|
|
932
|
+
const changedPaths = [...(byJob.get(jobId)?.changedPaths ?? [])].sort();
|
|
933
|
+
return {
|
|
934
|
+
id: 'merge-group:' + stableHash([jobId, changedPaths]),
|
|
935
|
+
jobIds: [jobId],
|
|
936
|
+
changedPaths
|
|
937
|
+
};
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
function groupArtifacts(artifacts, key) {
|
|
941
|
+
const out = {};
|
|
942
|
+
for (const artifact of artifacts) {
|
|
943
|
+
const group = key(artifact);
|
|
944
|
+
out[group] = [...(out[group] ?? []), artifact];
|
|
945
|
+
}
|
|
946
|
+
return out;
|
|
947
|
+
}
|
|
948
|
+
function groupIds(items, key) {
|
|
949
|
+
const out = {};
|
|
950
|
+
for (const item of items) {
|
|
951
|
+
const group = key(item);
|
|
952
|
+
out[group] = [...(out[group] ?? []), item.jobId];
|
|
953
|
+
}
|
|
954
|
+
for (const ids of Object.values(out))
|
|
955
|
+
ids.sort();
|
|
956
|
+
return out;
|
|
957
|
+
}
|
|
958
|
+
function countBy(values) {
|
|
959
|
+
const out = {};
|
|
960
|
+
for (const value of values)
|
|
961
|
+
out[value] = (out[value] ?? 0) + 1;
|
|
962
|
+
return out;
|
|
963
|
+
}
|
|
964
|
+
function hasJobDependencyCycle(start, dependenciesByJobId) {
|
|
965
|
+
const visiting = new Set();
|
|
966
|
+
const visited = new Set();
|
|
967
|
+
const visit = (node) => {
|
|
968
|
+
if (visiting.has(node))
|
|
969
|
+
return true;
|
|
970
|
+
if (visited.has(node))
|
|
971
|
+
return false;
|
|
972
|
+
visiting.add(node);
|
|
973
|
+
for (const dep of dependenciesByJobId[node] ?? []) {
|
|
974
|
+
if (visit(dep))
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
visiting.delete(node);
|
|
978
|
+
visited.add(node);
|
|
979
|
+
return false;
|
|
980
|
+
};
|
|
981
|
+
return visit(start);
|
|
982
|
+
}
|
|
983
|
+
function deterministicUnitInterval(value) {
|
|
984
|
+
const hex = stableHash(value).split(':')[1] ?? '0';
|
|
985
|
+
return parseInt(hex, 16) / 0xffffffff;
|
|
986
|
+
}
|
|
235
987
|
function normalizeComputeList(input) {
|
|
236
988
|
const values = input && input.length > 0 ? input : [{
|
|
237
989
|
id: FRONTIER_SWARM_DEFAULT_CODEX_COMPUTE_ID,
|
|
@@ -278,6 +1030,9 @@ function normalizeLane(input) {
|
|
|
278
1030
|
allowedWrites,
|
|
279
1031
|
sharedReadOnly: uniqueStrings(input.sharedReadOnly ?? []),
|
|
280
1032
|
neverEdit: uniqueStrings(input.neverEdit ?? []),
|
|
1033
|
+
ownershipRegions: normalizeOwnershipRegions(input.ownershipRegions ?? []),
|
|
1034
|
+
capabilities: uniqueStrings(input.capabilities ?? []),
|
|
1035
|
+
...(input.resourceRequirements ? { resourceRequirements: normalizeResourceRequirements(input.resourceRequirements) } : {}),
|
|
281
1036
|
...(input.worktreePath ? { worktreePath: input.worktreePath } : {}),
|
|
282
1037
|
...(input.evidencePrefix || input.evidenceOutDirPrefix ? { evidencePrefix: input.evidencePrefix ?? input.evidenceOutDirPrefix } : {}),
|
|
283
1038
|
concurrencyKey: input.concurrencyKey ?? input.id,
|
|
@@ -287,6 +1042,68 @@ function normalizeLane(input) {
|
|
|
287
1042
|
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
288
1043
|
};
|
|
289
1044
|
}
|
|
1045
|
+
function normalizeOwnershipRegions(input = []) {
|
|
1046
|
+
return input.map((region) => {
|
|
1047
|
+
const globs = uniqueStrings([...(region.globs ?? []), ...(region.paths ?? [])]);
|
|
1048
|
+
return {
|
|
1049
|
+
id: normalizeId(region.id, 'ownership region id'),
|
|
1050
|
+
title: region.title ?? titleFromId(region.id),
|
|
1051
|
+
...(region.description ? { description: region.description } : {}),
|
|
1052
|
+
globs,
|
|
1053
|
+
selectors: uniqueStrings(region.selectors ?? []),
|
|
1054
|
+
...(region.owner ? { owner: region.owner } : {}),
|
|
1055
|
+
...(toJsonObject(region.metadata) ? { metadata: toJsonObject(region.metadata) } : {})
|
|
1056
|
+
};
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
function mergeOwnershipRegions(laneRegions, taskRegions) {
|
|
1060
|
+
const byId = new Map();
|
|
1061
|
+
for (const region of laneRegions)
|
|
1062
|
+
byId.set(region.id, cloneJsonValue(region));
|
|
1063
|
+
for (const region of taskRegions)
|
|
1064
|
+
byId.set(region.id, cloneJsonValue(region));
|
|
1065
|
+
return Array.from(byId.values()).sort((left, right) => left.id.localeCompare(right.id));
|
|
1066
|
+
}
|
|
1067
|
+
function normalizeResourceRequirements(input = {}) {
|
|
1068
|
+
const resources = {};
|
|
1069
|
+
for (const [key, value] of Object.entries(input.resources ?? {})) {
|
|
1070
|
+
if (Number.isFinite(value) && value > 0)
|
|
1071
|
+
resources[key] = value;
|
|
1072
|
+
}
|
|
1073
|
+
return {
|
|
1074
|
+
capabilities: uniqueStrings(input.capabilities ?? []),
|
|
1075
|
+
resources,
|
|
1076
|
+
...(input.browser ? { browser: normalizeBrowserResource(input.browser) } : {}),
|
|
1077
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
function normalizeBrowserResource(input) {
|
|
1081
|
+
return {
|
|
1082
|
+
required: input.required ?? true,
|
|
1083
|
+
portPool: uniqueStrings((input.portPool ?? []).map((port) => String(port))),
|
|
1084
|
+
...(input.profileDir ? { profileDir: input.profileDir } : {}),
|
|
1085
|
+
...(input.profileDirPrefix ? { profileDirPrefix: input.profileDirPrefix } : {}),
|
|
1086
|
+
...(positiveNumber(input.maxConcurrency) ? { maxConcurrency: Math.floor(input.maxConcurrency) } : {}),
|
|
1087
|
+
...(input.headless !== undefined ? { headless: input.headless } : {}),
|
|
1088
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function mergeResourceRequirements(lane, task, extraCapabilities = []) {
|
|
1092
|
+
if (!lane && !task && extraCapabilities.length === 0)
|
|
1093
|
+
return undefined;
|
|
1094
|
+
const capabilities = uniqueStrings([...(lane?.capabilities ?? []), ...(task?.capabilities ?? []), ...extraCapabilities]);
|
|
1095
|
+
const resources = { ...(lane?.resources ?? {}) };
|
|
1096
|
+
for (const [key, value] of Object.entries(task?.resources ?? {}))
|
|
1097
|
+
resources[key] = Math.max(resources[key] ?? 0, value);
|
|
1098
|
+
const browser = task?.browser ?? lane?.browser;
|
|
1099
|
+
const metadata = toJsonObject({ ...(lane?.metadata ?? {}), ...(task?.metadata ?? {}) });
|
|
1100
|
+
return {
|
|
1101
|
+
capabilities,
|
|
1102
|
+
resources,
|
|
1103
|
+
...(browser ? { browser } : {}),
|
|
1104
|
+
...(metadata && Object.keys(metadata).length ? { metadata } : {})
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
290
1107
|
function normalizePolicy(input, defaultCompute) {
|
|
291
1108
|
return {
|
|
292
1109
|
mode: input?.mode ?? 'hard-file-ownership',
|
|
@@ -315,10 +1132,19 @@ function normalizeTask(input) {
|
|
|
315
1132
|
...(input.layer ? { layer: input.layer } : {}),
|
|
316
1133
|
...(input.compute ? { compute: input.compute } : {}),
|
|
317
1134
|
...(input.parentTaskId ? { parentTaskId: input.parentTaskId } : {}),
|
|
1135
|
+
dependsOn: uniqueStrings(input.dependsOn ?? []),
|
|
1136
|
+
...(input.concurrencyKey ? { concurrencyKey: input.concurrencyKey } : {}),
|
|
1137
|
+
...(input.budget ? { budget: normalizeBudget(input.budget) } : {}),
|
|
1138
|
+
...(input.review ? { review: normalizeReviewPolicy(input.review) } : {}),
|
|
318
1139
|
priority: Number.isFinite(input.priority) ? Number(input.priority) : 100,
|
|
319
1140
|
sourceRefs: uniqueStrings(input.sourceRefs ?? []),
|
|
320
1141
|
targetRefs,
|
|
321
1142
|
allowedWrites: uniqueStrings([...(input.allowedWrites ?? []), ...targetRefs]),
|
|
1143
|
+
ownershipRegions: normalizeOwnershipRegions(input.ownershipRegions ?? []),
|
|
1144
|
+
ownedRegions: uniqueStrings(input.ownedRegions ?? []),
|
|
1145
|
+
changedRegions: uniqueStrings(input.changedRegions ?? []),
|
|
1146
|
+
capabilities: uniqueStrings(input.capabilities ?? []),
|
|
1147
|
+
...(input.resourceRequirements ? { resourceRequirements: normalizeResourceRequirements(input.resourceRequirements) } : {}),
|
|
322
1148
|
acceptance: normalizeAcceptance(input),
|
|
323
1149
|
verification: normalizeCommands(input.verification ?? []),
|
|
324
1150
|
...(input.evidenceCommand ? { evidenceCommand: input.evidenceCommand } : {}),
|
|
@@ -368,6 +1194,55 @@ function selectSwarmTasks(manifest, tasks, options) {
|
|
|
368
1194
|
.sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id))
|
|
369
1195
|
.slice(0, limit);
|
|
370
1196
|
}
|
|
1197
|
+
function createSelectionEntry(manifest, task, priority) {
|
|
1198
|
+
const lane = task.lane ? manifest.lanes.find((entry) => entry.id === task.lane) : undefined;
|
|
1199
|
+
return {
|
|
1200
|
+
task,
|
|
1201
|
+
...(lane ? { lane } : {}),
|
|
1202
|
+
ownershipWarnings: selectionOwnershipWarnings(task, lane),
|
|
1203
|
+
selectionPriority: selectionPriority(task, priority)
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
function selectionOwnershipWarnings(task, lane) {
|
|
1207
|
+
if (!lane || lane.allowedWrites.length === 0)
|
|
1208
|
+
return [];
|
|
1209
|
+
return task.targetRefs
|
|
1210
|
+
.filter((file) => !lane.allowedWrites.some((glob) => matchesGlob(file, glob)))
|
|
1211
|
+
.map((file) => `${file} is outside allowed write globs for ${lane.id}`);
|
|
1212
|
+
}
|
|
1213
|
+
function selectionPriority(task, input) {
|
|
1214
|
+
const statusRanks = input?.statuses ?? {};
|
|
1215
|
+
const workKindRanks = input?.workKinds ?? {};
|
|
1216
|
+
const statusRank = statusRanks[task.status] ?? input?.defaultStatusRank ?? 100;
|
|
1217
|
+
const workKindRank = workKindRanks[task.workKind] ?? input?.defaultWorkKindRank ?? 100;
|
|
1218
|
+
const statusWeight = input?.statusWeight ?? 1000;
|
|
1219
|
+
const workKindWeight = input?.workKindWeight ?? 1;
|
|
1220
|
+
return statusRank * statusWeight + workKindRank * workKindWeight;
|
|
1221
|
+
}
|
|
1222
|
+
function roundRobinSelectionByLane(entries) {
|
|
1223
|
+
const groups = new Map();
|
|
1224
|
+
for (const entry of entries)
|
|
1225
|
+
groups.set(entry.task.lane ?? 'unassigned', [...(groups.get(entry.task.lane ?? 'unassigned') ?? []), entry]);
|
|
1226
|
+
const selected = [];
|
|
1227
|
+
while (Array.from(groups.values()).some((group) => group.length > 0)) {
|
|
1228
|
+
for (const group of groups.values()) {
|
|
1229
|
+
const next = group.shift();
|
|
1230
|
+
if (next)
|
|
1231
|
+
selected.push(next);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
return selected;
|
|
1235
|
+
}
|
|
1236
|
+
function summarizeTaskSelection(entries) {
|
|
1237
|
+
return entries.reduce((summary, entry) => {
|
|
1238
|
+
const lane = entry.task.lane ?? 'unassigned';
|
|
1239
|
+
summary.total += 1;
|
|
1240
|
+
summary.byLane[lane] = (summary.byLane[lane] ?? 0) + 1;
|
|
1241
|
+
summary.byWorkKind[entry.task.workKind] = (summary.byWorkKind[entry.task.workKind] ?? 0) + 1;
|
|
1242
|
+
summary.ownershipWarningCount += entry.ownershipWarnings.length;
|
|
1243
|
+
return summary;
|
|
1244
|
+
}, { total: 0, byLane: {}, byWorkKind: {}, ownershipWarningCount: 0 });
|
|
1245
|
+
}
|
|
371
1246
|
function createJob(compiled, task, options) {
|
|
372
1247
|
const lane = task.lane ? compiled.lanesById.get(task.lane) : undefined;
|
|
373
1248
|
const layer = task.layer ?? lane?.layer ?? compiled.manifest.policy.defaultLayer;
|
|
@@ -383,6 +1258,10 @@ function createJob(compiled, task, options) {
|
|
|
383
1258
|
const ownershipWarnings = task.targetRefs
|
|
384
1259
|
.filter((file) => allowedWrites.length > 0 && !allowedWrites.some((glob) => matchesGlob(file, glob)))
|
|
385
1260
|
.map((file) => `${file} is outside allowed write globs for ${lane?.id ?? 'unassigned'}`);
|
|
1261
|
+
const capabilities = uniqueStrings([...(lane?.capabilities ?? []), ...task.capabilities]);
|
|
1262
|
+
const resourceRequirements = mergeResourceRequirements(lane?.resourceRequirements, task.resourceRequirements, capabilities);
|
|
1263
|
+
const ownershipRegions = mergeOwnershipRegions(lane?.ownershipRegions ?? [], task.ownershipRegions);
|
|
1264
|
+
const ownedRegions = uniqueStrings([...task.ownedRegions, ...ownershipRegions.map((region) => region.id)]);
|
|
386
1265
|
return {
|
|
387
1266
|
id: `${lane?.id ?? 'unassigned'}-${slug(task.id)}`,
|
|
388
1267
|
taskId: task.id,
|
|
@@ -396,16 +1275,30 @@ function createJob(compiled, task, options) {
|
|
|
396
1275
|
allowedWrites,
|
|
397
1276
|
sharedReadOnly: uniqueStrings([...(compiled.manifest.policy.sharedReadOnly ?? []), ...(lane?.sharedReadOnly ?? [])]),
|
|
398
1277
|
neverEdit: uniqueStrings([...(compiled.manifest.policy.neverEditWithoutParent ?? []), ...(lane?.neverEdit ?? [])]),
|
|
1278
|
+
ownershipRegions,
|
|
1279
|
+
ownedRegions,
|
|
1280
|
+
changedRegions: uniqueStrings(task.changedRegions),
|
|
1281
|
+
capabilities,
|
|
1282
|
+
...(resourceRequirements ? { resourceRequirements } : {}),
|
|
399
1283
|
...(lane?.worktreePath ? { worktreePath: lane.worktreePath } : {}),
|
|
400
1284
|
...(evidencePrefix ? { evidencePrefix } : {}),
|
|
401
|
-
concurrencyKey: lane?.concurrencyKey ?? task.lane ?? compute.id,
|
|
1285
|
+
concurrencyKey: task.concurrencyKey ?? lane?.concurrencyKey ?? task.lane ?? compute.id,
|
|
402
1286
|
ownershipWarnings,
|
|
403
1287
|
verification: task.verification.length ? task.verification : (lane?.handoffCommands ?? []),
|
|
404
1288
|
acceptance: [...task.acceptance],
|
|
1289
|
+
dependsOn: resolveJobDependencies(task),
|
|
1290
|
+
...(task.budget ? { budget: task.budget } : {}),
|
|
1291
|
+
review: task.review ?? normalizeReviewPolicy(),
|
|
405
1292
|
tags: uniqueStrings([...task.tags, ...(lane?.tags ?? []), ...(layer ? [layer] : []), compute.id]),
|
|
406
1293
|
...(task.metadata ? { metadata: task.metadata } : {})
|
|
407
1294
|
};
|
|
408
1295
|
}
|
|
1296
|
+
function resolveJobDependencies(task) {
|
|
1297
|
+
return uniqueStrings([
|
|
1298
|
+
...(task.parentTaskId ? [task.parentTaskId] : []),
|
|
1299
|
+
...task.dependsOn
|
|
1300
|
+
]);
|
|
1301
|
+
}
|
|
409
1302
|
function resolveTaskCompute(compiled, task) {
|
|
410
1303
|
if (task.compute)
|
|
411
1304
|
return readCompute(compiled, task.compute);
|
|
@@ -438,8 +1331,9 @@ function resolveLayerCompute(compiled, layerId) {
|
|
|
438
1331
|
function readCompute(compiled, id) {
|
|
439
1332
|
return compiled.computeById.get(id) ?? compiled.computeById.get(compiled.manifest.policy.defaultCompute) ?? compiled.manifest.compute[0];
|
|
440
1333
|
}
|
|
441
|
-
function validateTasksForManifest(compiled, tasks) {
|
|
442
|
-
const issues = [...compiled.validation.issues];
|
|
1334
|
+
function validateTasksForManifest(compiled, tasks, graph) {
|
|
1335
|
+
const issues = [...compiled.validation.issues, ...(graph?.issues ?? [])];
|
|
1336
|
+
const taskIds = new Set(tasks.map((task) => task.id));
|
|
443
1337
|
for (const task of tasks) {
|
|
444
1338
|
if (task.lane && !compiled.lanesById.has(task.lane)) {
|
|
445
1339
|
addIssue(issues, 'missing-task-lane', 'error', `tasks.${task.id}.lane`, `Task lane is not declared: ${task.lane}`);
|
|
@@ -450,6 +1344,14 @@ function validateTasksForManifest(compiled, tasks) {
|
|
|
450
1344
|
if (task.compute && !compiled.computeById.has(task.compute)) {
|
|
451
1345
|
addIssue(issues, 'missing-task-compute', 'error', `tasks.${task.id}.compute`, `Task compute is not declared: ${task.compute}`);
|
|
452
1346
|
}
|
|
1347
|
+
for (const dependency of task.dependsOn) {
|
|
1348
|
+
if (!taskIds.has(dependency)) {
|
|
1349
|
+
addIssue(issues, 'missing-task-dependency', 'warning', `tasks.${task.id}.dependsOn`, `Task dependency is not declared in the task set: ${dependency}`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (task.parentTaskId && !taskIds.has(task.parentTaskId)) {
|
|
1353
|
+
addIssue(issues, 'missing-parent-task', 'warning', `tasks.${task.id}.parentTaskId`, `Task parent is not declared in the task set: ${task.parentTaskId}`);
|
|
1354
|
+
}
|
|
453
1355
|
}
|
|
454
1356
|
return { valid: issues.every((issue) => issue.severity !== 'error'), issues };
|
|
455
1357
|
}
|
|
@@ -472,26 +1374,121 @@ function normalizeEvent(input) {
|
|
|
472
1374
|
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
473
1375
|
};
|
|
474
1376
|
}
|
|
1377
|
+
function normalizeQueueJob(input) {
|
|
1378
|
+
return {
|
|
1379
|
+
jobId: input.jobId,
|
|
1380
|
+
...(input.taskId ? { taskId: input.taskId } : {}),
|
|
1381
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
1382
|
+
status: input.status ?? 'ready',
|
|
1383
|
+
...(input.lane ? { lane: input.lane } : {}),
|
|
1384
|
+
...(input.compute ? { compute: input.compute } : {}),
|
|
1385
|
+
...(input.concurrencyKey ? { concurrencyKey: input.concurrencyKey } : {}),
|
|
1386
|
+
priority: input.priority ?? 100,
|
|
1387
|
+
attempts: Math.max(0, Math.floor(input.attempts ?? 0)),
|
|
1388
|
+
maxAttempts: Math.max(1, Math.floor(input.maxAttempts ?? 1)),
|
|
1389
|
+
...(input.availableAt !== undefined ? { availableAt: input.availableAt } : {}),
|
|
1390
|
+
...(input.lease ? { lease: cloneJsonValue(input.lease) } : {}),
|
|
1391
|
+
...(input.lastError ? { lastError: input.lastError } : {}),
|
|
1392
|
+
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
function queueJobsFromPlan(plan, run, leases) {
|
|
1396
|
+
const resultsByJob = new Map((run?.results ?? []).map((result) => [result.jobId, result]));
|
|
1397
|
+
const activeLeases = new Map(leases.filter((lease) => lease.status === 'active').map((lease) => [lease.jobId, lease]));
|
|
1398
|
+
return plan.jobs.map((job) => {
|
|
1399
|
+
const result = resultsByJob.get(job.id);
|
|
1400
|
+
const lease = activeLeases.get(job.id);
|
|
1401
|
+
const failed = result?.status === 'failed' || result?.exitCode !== undefined && result.exitCode !== 0;
|
|
1402
|
+
const completed = result?.status === 'completed' || result?.status === 'verified';
|
|
1403
|
+
const status = completed
|
|
1404
|
+
? 'completed'
|
|
1405
|
+
: failed
|
|
1406
|
+
? 'failed'
|
|
1407
|
+
: lease
|
|
1408
|
+
? 'leased'
|
|
1409
|
+
: job.status === 'running'
|
|
1410
|
+
? 'running'
|
|
1411
|
+
: 'ready';
|
|
1412
|
+
return normalizeQueueJob({
|
|
1413
|
+
jobId: job.id,
|
|
1414
|
+
taskId: job.taskId,
|
|
1415
|
+
runId: run?.id ?? plan.runId,
|
|
1416
|
+
status,
|
|
1417
|
+
lane: job.lane,
|
|
1418
|
+
compute: job.compute.id,
|
|
1419
|
+
concurrencyKey: job.concurrencyKey,
|
|
1420
|
+
priority: job.priority,
|
|
1421
|
+
attempts: result?.metadata && typeof result.metadata.attempts === 'number' ? result.metadata.attempts : undefined,
|
|
1422
|
+
maxAttempts: job.budget?.maxRetries !== undefined ? job.budget.maxRetries + 1 : 1,
|
|
1423
|
+
lease,
|
|
1424
|
+
lastError: result?.error
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
475
1428
|
function normalizeResult(input) {
|
|
476
1429
|
const startedAt = input.startedAt;
|
|
477
1430
|
const finishedAt = input.finishedAt;
|
|
1431
|
+
const status = input.status ?? (input.exitCode === 0 || input.exitCode === undefined ? 'completed' : 'failed');
|
|
478
1432
|
return {
|
|
479
1433
|
jobId: input.jobId,
|
|
480
|
-
status
|
|
1434
|
+
status,
|
|
1435
|
+
mergeReadiness: classifySwarmMergeReadiness({ ...input, status }),
|
|
481
1436
|
...(startedAt !== undefined ? { startedAt } : {}),
|
|
482
1437
|
...(finishedAt !== undefined ? { finishedAt } : {}),
|
|
483
1438
|
...(startedAt !== undefined && finishedAt !== undefined ? { durationMs: Math.max(0, finishedAt - startedAt) } : {}),
|
|
484
1439
|
...(input.exitCode !== undefined ? { exitCode: input.exitCode } : {}),
|
|
485
1440
|
...(input.signal ? { signal: input.signal } : {}),
|
|
486
1441
|
changedPaths: uniqueStrings(input.changedPaths ?? []),
|
|
1442
|
+
changedRegions: uniqueStrings(input.changedRegions ?? []),
|
|
487
1443
|
ownershipViolations: uniqueStrings(input.ownershipViolations ?? []),
|
|
488
1444
|
evidencePaths: uniqueStrings(input.evidencePaths ?? []),
|
|
1445
|
+
...(input.patchPath ? { patchPath: input.patchPath } : {}),
|
|
1446
|
+
queueItemIds: uniqueStrings(input.queueItemIds ?? []),
|
|
1447
|
+
riskLevel: input.riskLevel ?? 'unknown',
|
|
1448
|
+
mergeDisposition: input.mergeDisposition ?? classifySwarmMergeDisposition({ ...input, status }),
|
|
489
1449
|
verification: (input.verification ?? []).map(normalizeVerificationResult),
|
|
490
1450
|
...(input.lastMessage ? { lastMessage: input.lastMessage } : {}),
|
|
491
1451
|
...(input.error !== undefined ? { error: stringifyError(input.error) } : {}),
|
|
492
1452
|
...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
|
|
493
1453
|
};
|
|
494
1454
|
}
|
|
1455
|
+
function isSwarmJobResult(value) {
|
|
1456
|
+
return Array.isArray(value.changedPaths)
|
|
1457
|
+
&& Array.isArray(value.changedRegions)
|
|
1458
|
+
&& Array.isArray(value.verification)
|
|
1459
|
+
&& Array.isArray(value.queueItemIds)
|
|
1460
|
+
&& typeof value.riskLevel === 'string'
|
|
1461
|
+
&& typeof value.mergeDisposition === 'string';
|
|
1462
|
+
}
|
|
1463
|
+
function mergeBundleReasons(result, disposition, staleAgainstHead) {
|
|
1464
|
+
const reasons = [];
|
|
1465
|
+
if (staleAgainstHead)
|
|
1466
|
+
reasons.push('stale-against-head');
|
|
1467
|
+
if (result.status === 'blocked')
|
|
1468
|
+
reasons.push('blocked');
|
|
1469
|
+
if (result.status === 'failed')
|
|
1470
|
+
reasons.push('failed');
|
|
1471
|
+
if (result.ownershipViolations.length)
|
|
1472
|
+
reasons.push('ownership-violations');
|
|
1473
|
+
if (result.verification.some((entry) => entry.required !== false && entry.status !== 0))
|
|
1474
|
+
reasons.push('failed-verification');
|
|
1475
|
+
if (disposition === 'needs-port')
|
|
1476
|
+
reasons.push('needs-human-port');
|
|
1477
|
+
if (disposition === 'rejected')
|
|
1478
|
+
reasons.push('rejected');
|
|
1479
|
+
return uniqueStrings(reasons);
|
|
1480
|
+
}
|
|
1481
|
+
function inferMergeRisk(result, disposition) {
|
|
1482
|
+
if (disposition === 'discovery-only')
|
|
1483
|
+
return 'low';
|
|
1484
|
+
if (disposition === 'rejected' || disposition === 'blocked' || disposition === 'stale-against-head')
|
|
1485
|
+
return 'high';
|
|
1486
|
+
if (result.changedPaths.length <= 2 && result.ownershipViolations.length === 0)
|
|
1487
|
+
return 'low';
|
|
1488
|
+
if (result.changedPaths.length <= 8)
|
|
1489
|
+
return 'medium';
|
|
1490
|
+
return 'high';
|
|
1491
|
+
}
|
|
495
1492
|
function normalizeVerificationResult(input) {
|
|
496
1493
|
return {
|
|
497
1494
|
name: input.name ?? ((input.command ?? []).join(' ') || 'verification'),
|
|
@@ -567,6 +1564,20 @@ function isSwarmManifest(value) {
|
|
|
567
1564
|
function isSwarmTask(value) {
|
|
568
1565
|
return !!value && typeof value === 'object' && value.kind === FRONTIER_SWARM_TASK_KIND;
|
|
569
1566
|
}
|
|
1567
|
+
function isSwarmEvent(value) {
|
|
1568
|
+
return !!value && typeof value === 'object' && value.kind === FRONTIER_SWARM_EVENT_KIND;
|
|
1569
|
+
}
|
|
1570
|
+
function readLaneId(value) {
|
|
1571
|
+
return typeof value === 'string' ? value : value.id;
|
|
1572
|
+
}
|
|
1573
|
+
function joinPathParts(...parts) {
|
|
1574
|
+
const first = parts[0] ? String(parts[0]) : '';
|
|
1575
|
+
const prefix = first.startsWith('/') ? '/' : '';
|
|
1576
|
+
return prefix + parts
|
|
1577
|
+
.map((part, index) => String(part).replace(index === 0 ? /\/+$/g : /^\/+|\/+$/g, ''))
|
|
1578
|
+
.filter(Boolean)
|
|
1579
|
+
.join('/');
|
|
1580
|
+
}
|
|
570
1581
|
function normalizeId(value, label) {
|
|
571
1582
|
const id = String(value || '').trim();
|
|
572
1583
|
if (!id)
|