@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/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
- validation: validateTasksForManifest(compiled, tasks),
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: input.status ?? (input.exitCode === 0 || input.exitCode === undefined ? 'completed' : 'failed'),
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)