@shapeshift-labs/frontier-swarm-codex 0.3.0 → 0.4.1

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
@@ -1,7 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs/promises';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
- import { FRONTIER_SWARM_DEFAULT_MODEL, FRONTIER_SWARM_DEFAULT_REASONING_EFFORT, checkSwarmOwnership, completeSwarmJob, createSwarmMergeBundle, createSwarmEventStream, createSwarmLeases, createSwarmPlan, createSwarmProof, createSwarmRun, createSwarmSchedule, recordSwarmEvent, routeSwarmEventToMailboxes } from '@shapeshift-labs/frontier-swarm';
5
+ import { FRONTIER_SWARM_DEFAULT_MODEL, FRONTIER_SWARM_DEFAULT_REASONING_EFFORT, checkSwarmOwnership, completeSwarmJob, FRONTIER_SWARM_MERGE_BUNDLE_KIND, FRONTIER_SWARM_MERGE_BUNDLE_VERSION, createSwarmMergeBundle, createSwarmMergeIndex, createSwarmQueueOverlay, matchesGlob, createSwarmEventStream, createSwarmLeases, createSwarmPlan, createSwarmProof, createSwarmRun, createSwarmSchedule, recordSwarmEvent, routeSwarmEventToMailboxes } from '@shapeshift-labs/frontier-swarm';
5
6
  export const FRONTIER_SWARM_CODEX_DEFAULT_MODEL = FRONTIER_SWARM_DEFAULT_MODEL;
6
7
  export const FRONTIER_SWARM_CODEX_DEFAULT_REASONING_EFFORT = FRONTIER_SWARM_DEFAULT_REASONING_EFFORT;
7
8
  export const FRONTIER_SWARM_CODEX_WORKSPACE_MANIFEST_KIND = 'frontier.swarm-codex.workspace-manifest';
@@ -12,6 +13,10 @@ export const FRONTIER_SWARM_CODEX_PID_MANIFEST_KIND = 'frontier.swarm-codex.pid-
12
13
  export const FRONTIER_SWARM_CODEX_PID_MANIFEST_VERSION = 1;
13
14
  export const FRONTIER_SWARM_CODEX_COLLECTION_KIND = 'frontier.swarm-codex.collection';
14
15
  export const FRONTIER_SWARM_CODEX_COLLECTION_VERSION = 1;
16
+ export const FRONTIER_SWARM_CODEX_APPLY_LEDGER_KIND = 'frontier.swarm-codex.apply-ledger';
17
+ export const FRONTIER_SWARM_CODEX_APPLY_LEDGER_VERSION = 1;
18
+ export const FRONTIER_SWARM_CODEX_PATCH_SCORE_KIND = 'frontier.swarm-codex.patch-score';
19
+ export const FRONTIER_SWARM_CODEX_PATCH_SCORE_VERSION = 1;
15
20
  const DEFAULT_WORKSPACE_INCLUDES = ['AGENTS.md', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'config'];
16
21
  const DEFAULT_WORKSPACE_EXCLUDES = [
17
22
  '.git',
@@ -23,6 +28,7 @@ const DEFAULT_WORKSPACE_EXCLUDES = [
23
28
  'test/roms',
24
29
  'target'
25
30
  ];
31
+ const pidManifestWriteQueues = new Map();
26
32
  export function createCodexSwarmPlan(input) {
27
33
  return createSwarmPlan(coerceCodexSwarmManifestInput(input.manifest), coerceCodexSwarmTasksInput(input.tasks), input.plan ?? {});
28
34
  }
@@ -160,19 +166,29 @@ export async function runCodexJob(job, options, outDir, lease) {
160
166
  const paths = await createJobPaths(outDir, job, options);
161
167
  const workspace = await prepareCodexWorkspace(job, options);
162
168
  const workspacePlan = createCodexWorkspacePlan(job, options);
169
+ const resourceAllocation = createCodexResourceAllocation(job, {
170
+ cwd: options.cwd ?? process.cwd(),
171
+ outDir,
172
+ workspacePath: workspace,
173
+ lease
174
+ });
175
+ if (resourceAllocation.browser?.profileDir)
176
+ await fs.mkdir(resourceAllocation.browser.profileDir, { recursive: true });
163
177
  const hookInput = {
164
178
  job,
165
179
  cwd: options.cwd ?? process.cwd(),
166
180
  outDir,
167
181
  workspacePath: workspace,
168
182
  workspacePlan,
169
- paths
183
+ paths,
184
+ resourceAllocation
170
185
  };
171
186
  await options.prepareJobWorkspace?.(hookInput);
172
187
  const fileSnapshot = shouldSnapshotWorkspaceChanges(workspacePlan, options)
173
188
  ? await snapshotWorkspaceFiles(workspace)
174
189
  : undefined;
175
- const basePrompt = renderCodexPrompt(job, { workspacePath: workspace, paths });
190
+ await fs.writeFile(paths.resourceAllocationPath, JSON.stringify(resourceAllocation, null, 2) + '\n');
191
+ const basePrompt = renderCodexPrompt(job, { workspacePath: workspace, paths, resourceAllocation });
176
192
  const prompt = options.renderJobPrompt
177
193
  ? await options.renderJobPrompt({ ...hookInput, prompt: basePrompt })
178
194
  : basePrompt;
@@ -184,7 +200,12 @@ export async function runCodexJob(job, options, outDir, lease) {
184
200
  jobId: job.id,
185
201
  taskId: job.taskId,
186
202
  lane: job.lane,
187
- data: { workspace: workspacePlan.path, capabilities: job.capabilities, resourceRequirements: job.resourceRequirements }
203
+ data: {
204
+ workspace: workspacePlan.path,
205
+ capabilities: job.capabilities,
206
+ resourceRequirements: job.resourceRequirements,
207
+ resourceAllocation
208
+ }
188
209
  });
189
210
  const startedAt = Date.now();
190
211
  const execution = options.dryRun
@@ -197,6 +218,8 @@ export async function runCodexJob(job, options, outDir, lease) {
197
218
  workspacePath: workspace,
198
219
  codexPath: options.codexPath ?? 'codex',
199
220
  paths,
221
+ resourceAllocation,
222
+ env: resourceAllocation.env,
200
223
  timeoutMs: job.compute.timeoutMs ?? options.jobTimeoutMs ?? 7200000
201
224
  });
202
225
  const collected = execution.changedPaths
@@ -229,20 +252,23 @@ export async function runCodexJob(job, options, outDir, lease) {
229
252
  changedPaths,
230
253
  changedRegions: job.changedRegions,
231
254
  ownershipViolations: ownership.violations,
232
- evidencePaths: [paths.evidenceDir, paths.workspaceProofPath, paths.mergeBundlePath, ...(patchPath ? [patchPath] : [])],
255
+ evidencePaths: [paths.evidenceDir, paths.resourceAllocationPath, paths.workspaceProofPath, paths.mergeBundlePath, ...(patchPath ? [patchPath] : [])],
233
256
  ...(patchPath ? { patchPath } : {}),
234
257
  queueItemIds: [job.taskId],
235
258
  verification,
236
259
  lastMessage: execution.lastMessage,
237
260
  error: execution.error,
238
- metadata: lease ? { leaseId: lease.id, leaseToken: lease.token, fencingToken: lease.fencingToken } : undefined
261
+ metadata: {
262
+ ...(lease ? { leaseId: lease.id, leaseToken: lease.token, fencingToken: lease.fencingToken } : {}),
263
+ resourceAllocation
264
+ }
239
265
  };
240
266
  const mergeBundle = createSwarmMergeBundle({
241
267
  runId: options.eventStream?.runId,
242
268
  job,
243
269
  result,
244
270
  ...(patchPath ? { patchPath } : {}),
245
- evidencePaths: [paths.evidenceDir, paths.workspaceProofPath],
271
+ evidencePaths: [paths.evidenceDir, paths.resourceAllocationPath, paths.workspaceProofPath],
246
272
  queueItemIds: [job.taskId]
247
273
  });
248
274
  await fs.writeFile(paths.mergeBundlePath, JSON.stringify(mergeBundle, null, 2) + '\n');
@@ -252,7 +278,9 @@ export function buildCodexArgs(job, input) {
252
278
  const model = resolveCodexModelFlag(job, input);
253
279
  const effort = resolveCodexReasoningEffort(job, input);
254
280
  const sandbox = job.compute.sandbox ?? input.sandbox ?? 'workspace-write';
281
+ const approval = normalizeCodexApprovalPolicy(input.approval);
255
282
  const args = [
283
+ ...(approval ? ['--ask-for-approval', approval] : []),
256
284
  'exec',
257
285
  '--cd',
258
286
  input.workspacePath,
@@ -268,9 +296,6 @@ export function buildCodexArgs(job, input) {
268
296
  args.push('--model', model);
269
297
  if (effort)
270
298
  args.push('-c', `model_reasoning_effort="${effort}"`);
271
- const approval = normalizeCodexApprovalPolicy(input.approval);
272
- if (approval)
273
- args.push('--ask-for-approval', approval);
274
299
  if (shouldSkipGitRepoCheck(input))
275
300
  args.push('--skip-git-repo-check');
276
301
  for (const dir of input.addDirs ?? [])
@@ -333,7 +358,48 @@ function resolveCodexReasoningEffort(job, input) {
333
358
  const effort = job.compute.reasoningEffort ?? FRONTIER_SWARM_CODEX_DEFAULT_REASONING_EFFORT;
334
359
  return effort ? String(effort).trim() : undefined;
335
360
  }
361
+ export function createCodexResourceAllocation(job, input) {
362
+ const requirements = job.resourceRequirements;
363
+ const capabilities = uniqueStrings([...(job.capabilities ?? []), ...(requirements?.capabilities ?? [])]);
364
+ const resources = { ...(requirements?.resources ?? {}) };
365
+ const env = {
366
+ FRONTIER_SWARM_JOB_ID: job.id,
367
+ FRONTIER_SWARM_TASK_ID: job.taskId,
368
+ FRONTIER_SWARM_LANE: job.lane,
369
+ FRONTIER_SWARM_CAPABILITIES: capabilities.join(',')
370
+ };
371
+ const browser = requirements?.browser;
372
+ if (!browser)
373
+ return { capabilities, resources, env };
374
+ const portPool = uniqueWorkspacePaths(browser.portPool ?? []);
375
+ const port = portPool.length ? portPool[resourceSlot(job, input.lease, portPool.length)] : undefined;
376
+ const profileDir = resolveBrowserProfileDir(job, browser.profileDir, browser.profileDirPrefix, input.cwd ?? process.cwd());
377
+ const browserAllocation = {
378
+ required: browser.required,
379
+ portPool,
380
+ ...(port ? { port } : {}),
381
+ ...(profileDir ? { profileDir } : {}),
382
+ ...(browser.headless !== undefined ? { headless: browser.headless } : {})
383
+ };
384
+ env.FRONTIER_SWARM_BROWSER_REQUIRED = String(browser.required);
385
+ if (port) {
386
+ env.FRONTIER_SWARM_BROWSER_PORT = port;
387
+ env.PORT = port;
388
+ }
389
+ if (profileDir)
390
+ env.FRONTIER_SWARM_BROWSER_PROFILE_DIR = profileDir;
391
+ if (browser.headless !== undefined)
392
+ env.FRONTIER_SWARM_BROWSER_HEADLESS = String(browser.headless);
393
+ env.FRONTIER_SWARM_RESOURCE_ALLOCATION = JSON.stringify({ capabilities, resources, browser: browserAllocation });
394
+ return {
395
+ capabilities,
396
+ resources,
397
+ env,
398
+ browser: browserAllocation
399
+ };
400
+ }
336
401
  export function renderCodexPrompt(job, input) {
402
+ const resourceAllocation = input.resourceAllocation ?? createCodexResourceAllocation(job, { outDir: input.paths.jobDir, workspacePath: input.workspacePath });
337
403
  return [
338
404
  '# Frontier Swarm Codex Job',
339
405
  '',
@@ -365,6 +431,9 @@ export function renderCodexPrompt(job, input) {
365
431
  'Budget:',
366
432
  ...bullets(formatBudget(job)),
367
433
  '',
434
+ 'Resource allocation:',
435
+ ...bullets(formatResourceAllocation(resourceAllocation)),
436
+ '',
368
437
  'Source refs:',
369
438
  ...bullets(job.task.sourceRefs),
370
439
  '',
@@ -391,7 +460,11 @@ export async function spawnCodexExecutor(input) {
391
460
  await fs.writeFile(input.paths.eventsPath, '');
392
461
  await fs.writeFile(input.paths.stderrPath, '');
393
462
  return new Promise((resolve) => {
394
- const child = spawn(input.codexPath, input.args, { cwd: input.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
463
+ const child = spawn(input.codexPath, input.args, {
464
+ cwd: input.cwd,
465
+ stdio: ['pipe', 'pipe', 'pipe'],
466
+ env: { ...process.env, ...input.env }
467
+ });
395
468
  if (child.pid) {
396
469
  appendCodexPidManifest(input.paths.pidManifestPath, {
397
470
  pid: child.pid,
@@ -428,6 +501,7 @@ async function createJobPaths(outDir, job, options) {
428
501
  stderrPath: path.join(jobDir, 'codex-stderr.log'),
429
502
  lastMessagePath: path.join(jobDir, 'last-message.md'),
430
503
  evidenceDir: path.join(jobDir, 'evidence'),
504
+ resourceAllocationPath: path.join(jobDir, 'evidence', 'resource-allocation.json'),
431
505
  workspaceProofPath: path.join(jobDir, 'evidence', 'workspace-proof.json'),
432
506
  patchPath: path.join(jobDir, 'evidence', 'changes.patch'),
433
507
  mergeBundlePath: path.join(jobDir, 'evidence', 'merge.json'),
@@ -668,6 +742,20 @@ export async function writeSwarmCoordinatorSnapshot(file, input) {
668
742
  await fs.writeFile(file, JSON.stringify(dashboard, null, 2) + '\n');
669
743
  }
670
744
  export async function appendCodexPidManifest(file, entry, runId) {
745
+ const absolute = path.resolve(file);
746
+ const previous = pidManifestWriteQueues.get(absolute) ?? Promise.resolve();
747
+ let next;
748
+ next = previous
749
+ .catch(() => { })
750
+ .then(() => appendCodexPidManifestUnlocked(absolute, entry, runId))
751
+ .finally(() => {
752
+ if (pidManifestWriteQueues.get(absolute) === next)
753
+ pidManifestWriteQueues.delete(absolute);
754
+ });
755
+ pidManifestWriteQueues.set(absolute, next);
756
+ return next;
757
+ }
758
+ async function appendCodexPidManifestUnlocked(file, entry, runId) {
671
759
  const manifest = await readCodexPidManifest(file).catch(() => ({
672
760
  kind: FRONTIER_SWARM_CODEX_PID_MANIFEST_KIND,
673
761
  version: FRONTIER_SWARM_CODEX_PID_MANIFEST_VERSION,
@@ -677,7 +765,7 @@ export async function appendCodexPidManifest(file, entry, runId) {
677
765
  const entries = manifest.entries.filter((existing) => existing.pid !== entry.pid || existing.jobId !== entry.jobId);
678
766
  entries.push(entry);
679
767
  await fs.mkdir(path.dirname(file), { recursive: true });
680
- await fs.writeFile(file, JSON.stringify({ ...manifest, ...(runId ? { runId } : {}), entries }, null, 2) + '\n');
768
+ await writeJsonAtomic(file, { ...manifest, ...(runId ? { runId } : {}), entries });
681
769
  }
682
770
  export async function readCodexPidManifest(file) {
683
771
  return JSON.parse(await fs.readFile(file, 'utf8'));
@@ -715,9 +803,29 @@ export async function collectCodexSwarmRun(input) {
715
803
  'failed-evidence': [],
716
804
  'stale-against-head': []
717
805
  };
718
- const mergePaths = await findFilesByName(runDir, 'merge.json');
806
+ const collectedBundles = [];
807
+ const patchStatuses = {};
808
+ const mergePaths = (await findFilesByName(runDir, 'merge.json'))
809
+ .filter((mergePath) => !pathHasIgnoredSegment(path.relative(runDir, mergePath), [
810
+ 'collected',
811
+ 'patch-scores',
812
+ 'ready-to-apply',
813
+ 'needs-human-port',
814
+ 'failed-evidence',
815
+ 'stale-against-head'
816
+ ]));
817
+ const mergeRecordsByJob = new Map();
719
818
  for (const mergePath of mergePaths.sort()) {
720
- const bundle = JSON.parse(await fs.readFile(mergePath, 'utf8'));
819
+ const bundle = normalizeCollectedMergeBundle(JSON.parse(await fs.readFile(mergePath, 'utf8')), mergePath);
820
+ const existing = mergeRecordsByJob.get(bundle.jobId);
821
+ const next = { mergePath, bundle };
822
+ if (!existing || mergeRecordScore(next) > mergeRecordScore(existing))
823
+ mergeRecordsByJob.set(bundle.jobId, next);
824
+ }
825
+ const mergeRecords = Array.from(mergeRecordsByJob.values()).sort((left, right) => left.bundle.jobId.localeCompare(right.bundle.jobId));
826
+ for (const { mergePath, bundle } of mergeRecords) {
827
+ const patchPath = resolveBundlePatchPath(bundle, mergePath);
828
+ const patchExists = !!patchPath && await pathExists(patchPath);
721
829
  const staleAgainstHead = input.checkStale === false ? false : await bundlePatchIsStale(bundle, mergePath, cwd);
722
830
  const bucket = classifyCodexCollectBucket(bundle, staleAgainstHead);
723
831
  const branchName = input.branchPrefix ? `${input.branchPrefix}/${slug(bundle.jobId)}` : bundle.branchName;
@@ -728,16 +836,26 @@ export async function collectCodexSwarmRun(input) {
728
836
  disposition: staleAgainstHead ? 'stale-against-head' : bundle.disposition,
729
837
  autoMergeable: bucket === 'ready-to-apply' && bundle.autoMergeable
730
838
  };
839
+ collectedBundles.push(nextBundle);
840
+ patchStatuses[nextBundle.jobId] = staleAgainstHead ? 'stale' : patchExists ? input.checkStale === false ? 'unknown' : 'applies' : 'missing';
731
841
  const outputDir = path.join(outDir, bucket, slug(bundle.jobId));
732
842
  await fs.mkdir(outputDir, { recursive: true });
733
843
  await fs.writeFile(path.join(outputDir, 'merge.json'), JSON.stringify(nextBundle, null, 2) + '\n');
734
- const patchPath = resolveBundlePatchPath(nextBundle, mergePath);
735
844
  if (patchPath && await pathExists(patchPath))
736
845
  await fs.copyFile(patchPath, path.join(outputDir, 'changes.patch')).catch(() => { });
737
846
  buckets[bucket].push({ bucket, jobId: bundle.jobId, mergePath, outputDir, bundle: nextBundle });
738
847
  }
848
+ const mergeIndex = createSwarmMergeIndex({
849
+ runId: path.basename(runDir),
850
+ bundles: collectedBundles,
851
+ patchStatuses
852
+ });
853
+ const queueOverlay = createSwarmQueueOverlay({
854
+ runId: path.basename(runDir),
855
+ bundles: collectedBundles
856
+ });
739
857
  const summary = {
740
- total: mergePaths.length,
858
+ total: mergeRecords.length,
741
859
  'ready-to-apply': buckets['ready-to-apply'].length,
742
860
  'needs-human-port': buckets['needs-human-port'].length,
743
861
  'failed-evidence': buckets['failed-evidence'].length,
@@ -751,12 +869,299 @@ export async function collectCodexSwarmRun(input) {
751
869
  outDir,
752
870
  generatedAt,
753
871
  buckets,
872
+ mergeIndex,
873
+ queueOverlay,
754
874
  summary
755
875
  };
756
876
  await fs.mkdir(outDir, { recursive: true });
757
877
  await fs.writeFile(path.join(outDir, 'collection.json'), JSON.stringify(result, null, 2) + '\n');
878
+ await fs.writeFile(path.join(outDir, 'merge-index.json'), JSON.stringify(mergeIndex, null, 2) + '\n');
879
+ await fs.writeFile(path.join(outDir, 'queue-overlay.json'), JSON.stringify(queueOverlay, null, 2) + '\n');
880
+ return result;
881
+ }
882
+ export async function applyCodexSwarmCollection(input) {
883
+ const generatedAt = Date.now();
884
+ const cwd = path.resolve(input.cwd ?? process.cwd());
885
+ const dryRun = input.dryRun ?? true;
886
+ if (!input.collection && !input.run)
887
+ throw new Error('apply requires --collection <dir> or --run <run-dir>');
888
+ const collectionDir = input.collection
889
+ ? path.resolve(cwd, input.collection)
890
+ : (await collectCodexSwarmRun({ run: String(input.run ?? ''), cwd, outDir: input.outDir })).outDir;
891
+ const outDir = path.resolve(cwd, input.outDir ?? path.join(collectionDir, 'apply-ledger'));
892
+ if (!dryRun && !input.allowDirty) {
893
+ const dirty = await gitDirty(cwd);
894
+ if (dirty.length)
895
+ throw new Error(`refusing to apply into dirty worktree; pass allowDirty to override (${dirty.slice(0, 8).join(', ')})`);
896
+ }
897
+ const bucket = input.bucket ?? 'ready-to-apply';
898
+ const roots = bucket === 'all'
899
+ ? ['ready-to-apply', 'needs-human-port', 'failed-evidence', 'stale-against-head'].map((entry) => path.join(collectionDir, entry))
900
+ : [path.join(collectionDir, bucket)];
901
+ const wanted = new Set(input.jobIds ?? []);
902
+ const mergePaths = (await Promise.all(roots.map((root) => findFilesByName(root, 'merge.json')))).flat().sort();
903
+ const entries = [];
904
+ for (const mergePath of mergePaths.slice(0, input.limit ? Math.max(0, Math.floor(input.limit)) : undefined)) {
905
+ const bundle = JSON.parse(await fs.readFile(mergePath, 'utf8'));
906
+ if (wanted.size && !wanted.has(bundle.jobId))
907
+ continue;
908
+ entries.push(await applyCodexMergeBundle({
909
+ cwd,
910
+ bundle,
911
+ mergePath,
912
+ dryRun,
913
+ commit: input.commit ?? false,
914
+ branchPrefix: input.branchPrefix
915
+ }));
916
+ }
917
+ const summary = {
918
+ total: entries.length,
919
+ checked: entries.filter((entry) => entry.status === 'checked').length,
920
+ applied: entries.filter((entry) => entry.status === 'applied').length,
921
+ committed: entries.filter((entry) => entry.status === 'committed').length,
922
+ skipped: entries.filter((entry) => entry.status === 'skipped').length,
923
+ failed: entries.filter((entry) => entry.status === 'failed').length
924
+ };
925
+ const result = {
926
+ kind: FRONTIER_SWARM_CODEX_APPLY_LEDGER_KIND,
927
+ version: FRONTIER_SWARM_CODEX_APPLY_LEDGER_VERSION,
928
+ ok: summary.failed === 0,
929
+ cwd,
930
+ collectionDir,
931
+ outDir,
932
+ generatedAt,
933
+ dryRun,
934
+ entries,
935
+ summary
936
+ };
937
+ await fs.mkdir(outDir, { recursive: true });
938
+ await fs.writeFile(path.join(outDir, 'apply-ledger.json'), JSON.stringify(result, null, 2) + '\n');
939
+ return result;
940
+ }
941
+ export async function scoreCodexSwarmPatches(input) {
942
+ const generatedAt = Date.now();
943
+ const cwd = path.resolve(input.cwd ?? process.cwd());
944
+ if (!input.collection && !input.run)
945
+ throw new Error('score requires --collection <dir> or --run <run-dir>');
946
+ const collectionDir = input.collection
947
+ ? path.resolve(cwd, input.collection)
948
+ : (await collectCodexSwarmRun({ run: String(input.run ?? ''), cwd, outDir: input.outDir })).outDir;
949
+ const outDir = path.resolve(cwd, input.outDir ?? path.join(collectionDir, 'patch-scores'));
950
+ const bucket = input.bucket ?? 'all';
951
+ const roots = bucket === 'all'
952
+ ? ['ready-to-apply', 'needs-human-port', 'failed-evidence', 'stale-against-head'].map((entry) => path.join(collectionDir, entry))
953
+ : [path.join(collectionDir, bucket)];
954
+ const wanted = new Set(input.jobIds ?? []);
955
+ const mergePaths = (await Promise.all(roots.map((root) => findFilesByName(root, 'merge.json')))).flat().sort();
956
+ const entries = [];
957
+ for (const mergePath of mergePaths.slice(0, input.limit ? Math.max(0, Math.floor(input.limit)) : undefined)) {
958
+ const bundle = JSON.parse(await fs.readFile(mergePath, 'utf8'));
959
+ if (wanted.size && !wanted.has(bundle.jobId))
960
+ continue;
961
+ entries.push(await scoreCodexMergeBundle({ cwd, mergePath, bundle, outDir, input }));
962
+ }
963
+ const statuses = ['accepted-clean', 'accepted-needs-port', 'conflict', 'test-fail', 'stale', 'evidence-only'];
964
+ const summary = Object.fromEntries(statuses.map((status) => [status, entries.filter((entry) => entry.status === status).length]));
965
+ const result = {
966
+ kind: FRONTIER_SWARM_CODEX_PATCH_SCORE_KIND,
967
+ version: FRONTIER_SWARM_CODEX_PATCH_SCORE_VERSION,
968
+ ok: entries.every((entry) => entry.status === 'accepted-clean' || entry.status === 'accepted-needs-port' || entry.status === 'evidence-only'),
969
+ cwd,
970
+ collectionDir,
971
+ outDir,
972
+ generatedAt,
973
+ entries: entries.sort((left, right) => right.score - left.score || left.jobId.localeCompare(right.jobId)),
974
+ summary: { ...summary, total: entries.length }
975
+ };
976
+ await fs.mkdir(outDir, { recursive: true });
977
+ await fs.writeFile(path.join(outDir, 'patch-score.json'), JSON.stringify(result, null, 2) + '\n');
758
978
  return result;
759
979
  }
980
+ async function applyCodexMergeBundle(input) {
981
+ const commands = [];
982
+ const patchPath = await resolveApplyPatchPath(input.bundle, input.mergePath);
983
+ const branchName = input.branchPrefix ? `${input.branchPrefix}/${slug(input.bundle.jobId)}` : input.bundle.branchName;
984
+ const base = {
985
+ jobId: input.bundle.jobId,
986
+ bundlePath: input.mergePath,
987
+ ...(patchPath ? { patchPath } : {}),
988
+ ...(branchName ? { branchName } : {}),
989
+ dryRun: input.dryRun,
990
+ commands
991
+ };
992
+ if (!patchPath) {
993
+ return {
994
+ ...base,
995
+ status: input.bundle.disposition === 'discovery-only' ? 'skipped' : 'failed',
996
+ error: 'missing patch'
997
+ };
998
+ }
999
+ const check = await runLoggedProcess('git', ['apply', '--check', patchPath], input.cwd);
1000
+ commands.push(check);
1001
+ if (check.status !== 0)
1002
+ return { ...base, status: 'failed', error: 'git apply --check failed' };
1003
+ if (input.dryRun)
1004
+ return { ...base, status: 'checked' };
1005
+ if (branchName) {
1006
+ const branch = await runLoggedProcess('git', ['switch', '-c', branchName], input.cwd);
1007
+ commands.push(branch);
1008
+ if (branch.status !== 0)
1009
+ return { ...base, status: 'failed', error: 'git switch -c failed' };
1010
+ }
1011
+ const apply = await runLoggedProcess('git', ['apply', patchPath], input.cwd);
1012
+ commands.push(apply);
1013
+ if (apply.status !== 0)
1014
+ return { ...base, status: 'failed', error: 'git apply failed' };
1015
+ if (!input.commit)
1016
+ return { ...base, status: 'applied' };
1017
+ const add = await runLoggedProcess('git', ['add', '--', ...input.bundle.changedPaths], input.cwd);
1018
+ commands.push(add);
1019
+ if (add.status !== 0)
1020
+ return { ...base, status: 'failed', error: 'git add failed' };
1021
+ const commit = await runLoggedProcess('git', ['commit', '-m', `Apply swarm bundle ${input.bundle.jobId}`], input.cwd);
1022
+ commands.push(commit);
1023
+ if (commit.status !== 0)
1024
+ return { ...base, status: 'failed', error: 'git commit failed' };
1025
+ const rev = await runLoggedProcess('git', ['rev-parse', 'HEAD'], input.cwd);
1026
+ commands.push(rev);
1027
+ return {
1028
+ ...base,
1029
+ status: 'committed',
1030
+ commit: rev.stdoutTail[0]
1031
+ };
1032
+ }
1033
+ async function scoreCodexMergeBundle(input) {
1034
+ const commands = [];
1035
+ const patchPath = await resolveApplyPatchPath(input.bundle, input.mergePath);
1036
+ const base = {
1037
+ jobId: input.bundle.jobId,
1038
+ bundlePath: input.mergePath,
1039
+ ...(patchPath ? { patchPath } : {}),
1040
+ changedPaths: [...input.bundle.changedPaths],
1041
+ commands
1042
+ };
1043
+ if (!patchPath || input.bundle.disposition === 'discovery-only' || input.bundle.changedPaths.length === 0) {
1044
+ return { ...base, status: 'evidence-only', score: 20, reasons: ['no patch to apply'] };
1045
+ }
1046
+ if (input.bundle.staleAgainstHead || input.bundle.disposition === 'stale-against-head') {
1047
+ return { ...base, status: 'stale', score: 0, reasons: ['stale-against-head'] };
1048
+ }
1049
+ const workspacePath = await createScoreWorkspace(input.cwd, input.bundle.jobId, input.input);
1050
+ try {
1051
+ const check = await runLoggedProcess('git', ['apply', '--check', patchPath], workspacePath);
1052
+ commands.push(check);
1053
+ if (check.status !== 0)
1054
+ return { ...base, workspacePath, status: 'conflict', score: 0, reasons: ['git apply --check failed'] };
1055
+ const apply = await runLoggedProcess('git', ['apply', patchPath], workspacePath);
1056
+ commands.push(apply);
1057
+ if (apply.status !== 0)
1058
+ return { ...base, workspacePath, status: 'conflict', score: 0, reasons: ['git apply failed'] };
1059
+ const gates = scoreCommands(input.bundle, input.input);
1060
+ for (const gate of gates) {
1061
+ const run = await runLoggedProcess(gate.command, gate.args, gate.cwd ? path.resolve(workspacePath, gate.cwd) : workspacePath);
1062
+ commands.push(run);
1063
+ if (run.status !== 0 && gate.required !== false) {
1064
+ return { ...base, workspacePath, status: 'test-fail', score: 10, reasons: [`gate failed: ${gate.name}`] };
1065
+ }
1066
+ }
1067
+ const clean = input.bundle.disposition === 'auto-mergeable' && input.bundle.autoMergeable;
1068
+ return {
1069
+ ...base,
1070
+ workspacePath,
1071
+ status: clean ? 'accepted-clean' : 'accepted-needs-port',
1072
+ score: clean ? 100 : 70,
1073
+ reasons: clean ? [] : ['patch applies but bundle is not auto-mergeable']
1074
+ };
1075
+ }
1076
+ finally {
1077
+ if (!input.input.keepWorkspaces)
1078
+ await fs.rm(workspacePath, { recursive: true, force: true }).catch(() => { });
1079
+ }
1080
+ }
1081
+ async function createScoreWorkspace(cwd, jobId, input) {
1082
+ const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), `frontier-swarm-score-${slug(jobId)}-`));
1083
+ const excludes = uniqueWorkspacePaths([
1084
+ '.git',
1085
+ 'node_modules',
1086
+ 'dist',
1087
+ 'coverage',
1088
+ 'agent-runs',
1089
+ '.frontier-framework',
1090
+ ...(input.workspaceExcludes ?? [])
1091
+ ]);
1092
+ const includes = uniqueWorkspacePaths(input.workspaceIncludes ?? []);
1093
+ if (includes.length) {
1094
+ for (const include of includes)
1095
+ await copyWorkspacePath(cwd, workspacePath, include, excludes);
1096
+ }
1097
+ else {
1098
+ await fs.cp(cwd, workspacePath, {
1099
+ recursive: true,
1100
+ force: true,
1101
+ filter: (source) => {
1102
+ if (source === cwd)
1103
+ return true;
1104
+ const relative = path.relative(cwd, source).replace(/\\/g, '/');
1105
+ if (!relative)
1106
+ return true;
1107
+ if (pathHasIgnoredSegment(relative, excludes))
1108
+ return false;
1109
+ return !excludes.some((entry) => relative === entry || relative.startsWith(entry.replace(/\/$/, '') + '/'));
1110
+ }
1111
+ });
1112
+ }
1113
+ return workspacePath;
1114
+ }
1115
+ function scoreCommands(bundle, input) {
1116
+ const focused = normalizeScoreCommands(input.focusedCommands ?? []);
1117
+ const global = bundle.changedPaths.some((file) => (input.globalGlobs ?? []).some((glob) => matchesGlob(file, glob)))
1118
+ ? normalizeScoreCommands(input.globalCommands ?? [])
1119
+ : [];
1120
+ return [...focused, ...global];
1121
+ }
1122
+ function normalizeScoreCommands(input) {
1123
+ return input.map((entry) => {
1124
+ if (typeof entry === 'string')
1125
+ return { name: entry, command: 'sh', args: ['-c', entry], required: true };
1126
+ return {
1127
+ name: entry.name,
1128
+ command: entry.command,
1129
+ args: [...entry.args],
1130
+ required: entry.required,
1131
+ ...(entry.cwd ? { cwd: entry.cwd } : {}),
1132
+ ...(entry.metadata ? { metadata: entry.metadata } : {})
1133
+ };
1134
+ }).filter((entry) => entry.command.length > 0);
1135
+ }
1136
+ async function resolveApplyPatchPath(bundle, mergePath) {
1137
+ const sibling = path.join(path.dirname(mergePath), 'changes.patch');
1138
+ if (await pathExists(sibling))
1139
+ return sibling;
1140
+ const patchPath = resolveBundlePatchPath(bundle, mergePath);
1141
+ if (patchPath && await pathExists(patchPath))
1142
+ return patchPath;
1143
+ return undefined;
1144
+ }
1145
+ async function runLoggedProcess(command, args, cwd) {
1146
+ const result = await runProcess(command, args, { cwd, allowFailure: true });
1147
+ return {
1148
+ command: [command, ...args],
1149
+ status: result.status,
1150
+ stdoutTail: tail(result.stdout),
1151
+ stderrTail: tail(result.stderr)
1152
+ };
1153
+ }
1154
+ async function writeJsonAtomic(file, value) {
1155
+ const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
1156
+ await fs.writeFile(tmp, JSON.stringify(value, null, 2) + '\n');
1157
+ await fs.rename(tmp, file);
1158
+ }
1159
+ async function gitDirty(cwd) {
1160
+ const result = await runProcess('git', ['status', '--porcelain'], { cwd, allowFailure: true });
1161
+ if (result.status !== 0)
1162
+ return [];
1163
+ return result.stdout.split(/\r?\n/).filter(Boolean).map((line) => line.slice(3));
1164
+ }
760
1165
  async function copyWorkspacePath(cwd, workspacePath, include, excludes) {
761
1166
  const relative = normalizeWorkspacePath(include);
762
1167
  if (!relative)
@@ -889,6 +1294,8 @@ function filterWorkspaceChangedPaths(paths, plan) {
889
1294
  function isIgnoredWorkspaceChangedPath(file, plan) {
890
1295
  if (plan.mode !== 'copy' && plan.mode !== 'snapshot')
891
1296
  return false;
1297
+ if (pathHasIgnoredSegment(file, ['node_modules', 'dist', 'coverage', '.frontier-framework', 'agent-runs']))
1298
+ return true;
892
1299
  const ignored = [
893
1300
  ...plan.excludes,
894
1301
  ...plan.artifactIncludes,
@@ -901,6 +1308,10 @@ function isIgnoredWorkspaceChangedPath(file, plan) {
901
1308
  ];
902
1309
  return ignored.some((entry) => file === entry || file.startsWith(entry.replace(/\/$/, '') + '/'));
903
1310
  }
1311
+ function pathHasIgnoredSegment(file, segments) {
1312
+ const parts = file.replace(/\\/g, '/').split('/').filter(Boolean);
1313
+ return parts.some((part) => segments.includes(part));
1314
+ }
904
1315
  async function snapshotWorkspaceFiles(root) {
905
1316
  const snapshot = new Map();
906
1317
  await walkWorkspaceFiles(root, root, snapshot);
@@ -1075,6 +1486,45 @@ function formatBudget(job) {
1075
1486
  `maxRetries=${job.budget.maxRetries}`
1076
1487
  ].filter((value) => !!value);
1077
1488
  }
1489
+ function formatResourceAllocation(allocation) {
1490
+ const entries = [
1491
+ allocation.capabilities.length ? `capabilities=${allocation.capabilities.join(',')}` : undefined,
1492
+ Object.keys(allocation.resources).length ? `resources=${JSON.stringify(allocation.resources)}` : undefined,
1493
+ allocation.browser ? `browser.required=${allocation.browser.required}` : undefined,
1494
+ allocation.browser?.port ? `browser.port=${allocation.browser.port}` : undefined,
1495
+ allocation.browser?.profileDir ? `browser.profileDir=${allocation.browser.profileDir}` : undefined,
1496
+ allocation.browser?.headless === undefined ? undefined : `browser.headless=${allocation.browser.headless}`,
1497
+ Object.keys(allocation.env).length ? `env=${Object.keys(allocation.env).sort().join(',')}` : undefined
1498
+ ].filter((value) => !!value);
1499
+ return entries.length ? entries : ['none'];
1500
+ }
1501
+ function resourceSlot(job, lease, count) {
1502
+ if (count <= 1)
1503
+ return 0;
1504
+ const seed = lease ? lease.fencingToken - 1 : Number.parseInt(stableHash(job.id).slice(0, 8), 16);
1505
+ return Math.abs(seed) % count;
1506
+ }
1507
+ function resolveBrowserProfileDir(job, profileDir, profileDirPrefix, cwd) {
1508
+ const raw = profileDir ?? (profileDirPrefix ? path.join(profileDirPrefix, safePathSegment(job.id)) : undefined);
1509
+ if (!raw)
1510
+ return undefined;
1511
+ return path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
1512
+ }
1513
+ function safePathSegment(value) {
1514
+ return value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'job';
1515
+ }
1516
+ function uniqueStrings(values) {
1517
+ const out = [];
1518
+ const seen = new Set();
1519
+ for (const value of values) {
1520
+ const normalized = String(value).trim();
1521
+ if (!normalized || seen.has(normalized))
1522
+ continue;
1523
+ seen.add(normalized);
1524
+ out.push(normalized);
1525
+ }
1526
+ return out;
1527
+ }
1078
1528
  function arrayOfObjects(value) {
1079
1529
  return Array.isArray(value) ? value.filter(isObject) : [];
1080
1530
  }
@@ -1145,6 +1595,60 @@ function resolveBundlePatchPath(bundle, mergePath) {
1145
1595
  return undefined;
1146
1596
  return path.isAbsolute(bundle.patchPath) ? bundle.patchPath : path.resolve(path.dirname(mergePath), bundle.patchPath);
1147
1597
  }
1598
+ function normalizeCollectedMergeBundle(value, mergePath) {
1599
+ const input = typeof value === 'object' && value !== null ? value : {};
1600
+ const jobId = typeof input.jobId === 'string' && input.jobId ? input.jobId : path.basename(path.dirname(mergePath));
1601
+ const changedPaths = stringArray(input.changedPaths);
1602
+ const status = typeof input.status === 'string' ? input.status : 'completed';
1603
+ const autoMergeable = Boolean(input.autoMergeable);
1604
+ const disposition = typeof input.disposition === 'string'
1605
+ ? input.disposition
1606
+ : autoMergeable ? 'auto-mergeable' : status === 'failed' ? 'rejected' : 'needs-port';
1607
+ return {
1608
+ kind: typeof input.kind === 'string' ? input.kind : FRONTIER_SWARM_MERGE_BUNDLE_KIND,
1609
+ version: typeof input.version === 'number' ? input.version : FRONTIER_SWARM_MERGE_BUNDLE_VERSION,
1610
+ id: typeof input.id === 'string' && input.id ? input.id : `swarm-merge-bundle:${jobId}`,
1611
+ ...(typeof input.runId === 'string' ? { runId: input.runId } : {}),
1612
+ ...(typeof input.planId === 'string' ? { planId: input.planId } : {}),
1613
+ jobId,
1614
+ ...(typeof input.taskId === 'string' ? { taskId: input.taskId } : {}),
1615
+ ...(typeof input.lane === 'string' ? { lane: input.lane } : {}),
1616
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
1617
+ generatedAt: typeof input.generatedAt === 'number' ? input.generatedAt : Date.now(),
1618
+ status,
1619
+ mergeReadiness: typeof input.mergeReadiness === 'string'
1620
+ ? input.mergeReadiness
1621
+ : changedPaths.length ? 'patch-candidate' : 'discovery-only',
1622
+ disposition,
1623
+ riskLevel: typeof input.riskLevel === 'string' ? input.riskLevel : 'unknown',
1624
+ autoMergeable,
1625
+ changedPaths,
1626
+ changedRegions: stringArray(input.changedRegions),
1627
+ ownedFilesTouched: stringArray(input.ownedFilesTouched),
1628
+ allowedWrites: stringArray(input.allowedWrites),
1629
+ ownershipViolations: stringArray(input.ownershipViolations),
1630
+ ...(typeof input.patchPath === 'string' ? { patchPath: input.patchPath } : {}),
1631
+ ...(typeof input.patchHash === 'string' ? { patchHash: input.patchHash } : {}),
1632
+ evidencePaths: stringArray(input.evidencePaths),
1633
+ commandsPassed: Array.isArray(input.commandsPassed) ? input.commandsPassed : [],
1634
+ commandsFailed: Array.isArray(input.commandsFailed) ? input.commandsFailed : [],
1635
+ queueItemIds: stringArray(input.queueItemIds),
1636
+ ...(typeof input.branchName === 'string' ? { branchName: input.branchName } : {}),
1637
+ ...(typeof input.commit === 'string' ? { commit: input.commit } : {}),
1638
+ staleAgainstHead: Boolean(input.staleAgainstHead),
1639
+ reasons: stringArray(input.reasons)
1640
+ };
1641
+ }
1642
+ function mergeRecordScore(record) {
1643
+ return (record.mergePath.includes('/evidence/') ? 100 : 0)
1644
+ + record.bundle.changedPaths.length
1645
+ + record.bundle.evidencePaths.length
1646
+ + record.bundle.commandsPassed.length
1647
+ + record.bundle.commandsFailed.length;
1648
+ }
1649
+ function stringArray(value) {
1650
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
1651
+ }
1148
1652
  function classifyCodexCollectBucket(bundle, staleAgainstHead) {
1149
1653
  if (staleAgainstHead || bundle.staleAgainstHead || bundle.disposition === 'stale-against-head')
1150
1654
  return 'stale-against-head';