@shapeshift-labs/frontier-swarm 0.2.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,22 +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;
13
17
  export const FRONTIER_SWARM_SCHEDULE_KIND = 'frontier.swarm.schedule';
14
18
  export const FRONTIER_SWARM_SCHEDULE_VERSION = 1;
15
19
  export const FRONTIER_SWARM_LEASE_KIND = 'frontier.swarm.lease';
16
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;
17
25
  export const FRONTIER_SWARM_ARTIFACT_INDEX_KIND = 'frontier.swarm.artifact-index';
18
26
  export const FRONTIER_SWARM_ARTIFACT_INDEX_VERSION = 1;
19
27
  export const FRONTIER_SWARM_REVIEW_PLAN_KIND = 'frontier.swarm.review-plan';
20
28
  export const FRONTIER_SWARM_REVIEW_PLAN_VERSION = 1;
21
29
  export const FRONTIER_SWARM_MERGE_PLAN_KIND = 'frontier.swarm.merge-plan';
22
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;
23
33
  export const FRONTIER_SWARM_DEFAULT_CODEX_COMPUTE_ID = 'codex.gpt-5.5.xhigh';
24
34
  export const FRONTIER_SWARM_DEFAULT_MODEL = 'gpt-5.5';
25
35
  export const FRONTIER_SWARM_DEFAULT_REASONING_EFFORT = 'xhigh';
26
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
+ ];
27
50
  export function defineSwarmManifest(input = {}) {
28
51
  return createSwarmManifest(input);
29
52
  }
@@ -158,6 +181,41 @@ export function createSwarmPlan(manifestInput, taskInput, options = {}) {
158
181
  ...(toJsonObject(options.metadata) ? { metadata: toJsonObject(options.metadata) } : {})
159
182
  };
160
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
+ }
161
219
  export function createSwarmRun(input) {
162
220
  const results = (input.results ?? []).map(normalizeResult);
163
221
  const events = (input.events ?? []).map((event) => normalizeEvent({ ...event, runId: event.runId ?? input.id ?? input.plan.runId }));
@@ -182,6 +240,67 @@ export function recordSwarmEvent(runInput, eventInput) {
182
240
  run.events = run.events.concat(normalizeEvent({ ...eventInput, runId: eventInput.runId ?? run.id }));
183
241
  return run;
184
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
+ }
185
304
  export function completeSwarmJob(runInput, resultInput) {
186
305
  const run = cloneJsonValue(runInput);
187
306
  const result = normalizeResult(resultInput);
@@ -211,6 +330,102 @@ export function checkSwarmOwnership(job, changedPaths) {
211
330
  violations
212
331
  };
213
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
+ }
214
429
  export function resolveSwarmCompute(manifestInput, taskInput) {
215
430
  const compiled = compileSwarm(manifestInput);
216
431
  const task = isSwarmTask(taskInput) ? taskInput : normalizeTask(taskInput);
@@ -248,7 +463,14 @@ export function createSwarmSchedule(input) {
248
463
  }
249
464
  const runningJobs = (run?.jobs ?? plan.jobs)
250
465
  .filter((job) => job.status === 'running')
251
- .map((job) => ({ jobId: job.id, lane: job.lane, compute: job.compute.id, concurrencyKey: job.concurrencyKey }));
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
+ }));
252
474
  const runningByLane = countBy(runningJobs.map((job) => job.lane));
253
475
  const runningByKey = countBy(runningJobs.map((job) => job.concurrencyKey));
254
476
  const runningByCompute = countBy(runningJobs.map((job) => job.compute));
@@ -330,6 +552,65 @@ export function createSwarmLeases(input) {
330
552
  status: 'active'
331
553
  }));
332
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
+ }
333
614
  export function checkSwarmBudget(job, usageInput) {
334
615
  const usage = normalizeUsage(usageInput);
335
616
  const budget = job.budget;
@@ -524,7 +805,8 @@ function createSwarmJobGraph(jobs) {
524
805
  function normalizeScheduleLimits(manifest, options) {
525
806
  const maxLaneConcurrency = {};
526
807
  for (const lane of manifest.lanes) {
527
- const value = options.maxLaneConcurrency?.[lane.id] ?? lane.maxConcurrency ?? manifest.policy.defaultConcurrency;
808
+ const browserMax = lane.resourceRequirements?.browser?.maxConcurrency;
809
+ const value = options.maxLaneConcurrency?.[lane.id] ?? lane.maxConcurrency ?? browserMax ?? manifest.policy.defaultConcurrency;
528
810
  maxLaneConcurrency[lane.id] = Math.max(1, Math.floor(value));
529
811
  }
530
812
  return {
@@ -550,7 +832,9 @@ function scheduleJob(job, dependsOn = job.dependsOn) {
550
832
  compute: job.compute.id,
551
833
  concurrencyKey: job.concurrencyKey,
552
834
  priority: job.priority,
553
- dependsOn: [...dependsOn]
835
+ dependsOn: [...dependsOn],
836
+ capabilities: [...job.capabilities],
837
+ ...(job.resourceRequirements ? { resourceRequirements: cloneJsonValue(job.resourceRequirements) } : {})
554
838
  };
555
839
  }
556
840
  function normalizeBudget(input = {}) {
@@ -618,10 +902,13 @@ function selectReviewers(pool, required, salt) {
618
902
  function conflictMap(results) {
619
903
  const byPath = new Map();
620
904
  for (const result of results) {
621
- for (const file of result.changedPaths) {
622
- const list = byPath.get(file) ?? [];
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) ?? [];
623
910
  list.push(result.jobId);
624
- byPath.set(file, list);
911
+ byPath.set(key, list);
625
912
  }
626
913
  }
627
914
  const conflicts = new Map();
@@ -658,6 +945,16 @@ function groupArtifacts(artifacts, key) {
658
945
  }
659
946
  return out;
660
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
+ }
661
958
  function countBy(values) {
662
959
  const out = {};
663
960
  for (const value of values)
@@ -733,6 +1030,9 @@ function normalizeLane(input) {
733
1030
  allowedWrites,
734
1031
  sharedReadOnly: uniqueStrings(input.sharedReadOnly ?? []),
735
1032
  neverEdit: uniqueStrings(input.neverEdit ?? []),
1033
+ ownershipRegions: normalizeOwnershipRegions(input.ownershipRegions ?? []),
1034
+ capabilities: uniqueStrings(input.capabilities ?? []),
1035
+ ...(input.resourceRequirements ? { resourceRequirements: normalizeResourceRequirements(input.resourceRequirements) } : {}),
736
1036
  ...(input.worktreePath ? { worktreePath: input.worktreePath } : {}),
737
1037
  ...(input.evidencePrefix || input.evidenceOutDirPrefix ? { evidencePrefix: input.evidencePrefix ?? input.evidenceOutDirPrefix } : {}),
738
1038
  concurrencyKey: input.concurrencyKey ?? input.id,
@@ -742,6 +1042,68 @@ function normalizeLane(input) {
742
1042
  ...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
743
1043
  };
744
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
+ }
745
1107
  function normalizePolicy(input, defaultCompute) {
746
1108
  return {
747
1109
  mode: input?.mode ?? 'hard-file-ownership',
@@ -778,6 +1140,11 @@ function normalizeTask(input) {
778
1140
  sourceRefs: uniqueStrings(input.sourceRefs ?? []),
779
1141
  targetRefs,
780
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) } : {}),
781
1148
  acceptance: normalizeAcceptance(input),
782
1149
  verification: normalizeCommands(input.verification ?? []),
783
1150
  ...(input.evidenceCommand ? { evidenceCommand: input.evidenceCommand } : {}),
@@ -827,6 +1194,55 @@ function selectSwarmTasks(manifest, tasks, options) {
827
1194
  .sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id))
828
1195
  .slice(0, limit);
829
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
+ }
830
1246
  function createJob(compiled, task, options) {
831
1247
  const lane = task.lane ? compiled.lanesById.get(task.lane) : undefined;
832
1248
  const layer = task.layer ?? lane?.layer ?? compiled.manifest.policy.defaultLayer;
@@ -842,6 +1258,10 @@ function createJob(compiled, task, options) {
842
1258
  const ownershipWarnings = task.targetRefs
843
1259
  .filter((file) => allowedWrites.length > 0 && !allowedWrites.some((glob) => matchesGlob(file, glob)))
844
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)]);
845
1265
  return {
846
1266
  id: `${lane?.id ?? 'unassigned'}-${slug(task.id)}`,
847
1267
  taskId: task.id,
@@ -855,6 +1275,11 @@ function createJob(compiled, task, options) {
855
1275
  allowedWrites,
856
1276
  sharedReadOnly: uniqueStrings([...(compiled.manifest.policy.sharedReadOnly ?? []), ...(lane?.sharedReadOnly ?? [])]),
857
1277
  neverEdit: uniqueStrings([...(compiled.manifest.policy.neverEditWithoutParent ?? []), ...(lane?.neverEdit ?? [])]),
1278
+ ownershipRegions,
1279
+ ownedRegions,
1280
+ changedRegions: uniqueStrings(task.changedRegions),
1281
+ capabilities,
1282
+ ...(resourceRequirements ? { resourceRequirements } : {}),
858
1283
  ...(lane?.worktreePath ? { worktreePath: lane.worktreePath } : {}),
859
1284
  ...(evidencePrefix ? { evidencePrefix } : {}),
860
1285
  concurrencyKey: task.concurrencyKey ?? lane?.concurrencyKey ?? task.lane ?? compute.id,
@@ -949,26 +1374,121 @@ function normalizeEvent(input) {
949
1374
  ...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
950
1375
  };
951
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
+ }
952
1428
  function normalizeResult(input) {
953
1429
  const startedAt = input.startedAt;
954
1430
  const finishedAt = input.finishedAt;
1431
+ const status = input.status ?? (input.exitCode === 0 || input.exitCode === undefined ? 'completed' : 'failed');
955
1432
  return {
956
1433
  jobId: input.jobId,
957
- status: input.status ?? (input.exitCode === 0 || input.exitCode === undefined ? 'completed' : 'failed'),
1434
+ status,
1435
+ mergeReadiness: classifySwarmMergeReadiness({ ...input, status }),
958
1436
  ...(startedAt !== undefined ? { startedAt } : {}),
959
1437
  ...(finishedAt !== undefined ? { finishedAt } : {}),
960
1438
  ...(startedAt !== undefined && finishedAt !== undefined ? { durationMs: Math.max(0, finishedAt - startedAt) } : {}),
961
1439
  ...(input.exitCode !== undefined ? { exitCode: input.exitCode } : {}),
962
1440
  ...(input.signal ? { signal: input.signal } : {}),
963
1441
  changedPaths: uniqueStrings(input.changedPaths ?? []),
1442
+ changedRegions: uniqueStrings(input.changedRegions ?? []),
964
1443
  ownershipViolations: uniqueStrings(input.ownershipViolations ?? []),
965
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 }),
966
1449
  verification: (input.verification ?? []).map(normalizeVerificationResult),
967
1450
  ...(input.lastMessage ? { lastMessage: input.lastMessage } : {}),
968
1451
  ...(input.error !== undefined ? { error: stringifyError(input.error) } : {}),
969
1452
  ...(toJsonObject(input.metadata) ? { metadata: toJsonObject(input.metadata) } : {})
970
1453
  };
971
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
+ }
972
1492
  function normalizeVerificationResult(input) {
973
1493
  return {
974
1494
  name: input.name ?? ((input.command ?? []).join(' ') || 'verification'),
@@ -1044,6 +1564,20 @@ function isSwarmManifest(value) {
1044
1564
  function isSwarmTask(value) {
1045
1565
  return !!value && typeof value === 'object' && value.kind === FRONTIER_SWARM_TASK_KIND;
1046
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
+ }
1047
1581
  function normalizeId(value, label) {
1048
1582
  const id = String(value || '').trim();
1049
1583
  if (!id)