job-forge 2.14.37 → 2.14.38

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/opencode.json CHANGED
@@ -46,7 +46,8 @@
46
46
  }
47
47
  },
48
48
  "instructions": [
49
- "templates/states.yml"
49
+ "templates/states.yml",
50
+ ".opencode/instructions.md"
50
51
  ],
51
52
  "small_model": "opencode-go/deepseek-v4-flash"
52
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "job-forge",
3
- "version": "2.14.37",
3
+ "version": "2.14.38",
4
4
  "description": "AI-powered job search pipeline built on opencode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -173,6 +173,9 @@
173
173
  "engines": {
174
174
  "node": ">=20.6.0"
175
175
  },
176
+ "overrides": {
177
+ "fast-uri": "3.1.2"
178
+ },
176
179
  "dependencies": {
177
180
  "@razroo/iso-cache": "^0.1.0",
178
181
  "@razroo/iso-canon": "^0.1.0",
@@ -185,21 +188,21 @@
185
188
  "@razroo/iso-ledger": "^0.1.0",
186
189
  "@razroo/iso-lineage": "^0.1.0",
187
190
  "@razroo/iso-migrate": "^0.1.0",
188
- "@razroo/iso-orchestrator": "^0.1.0",
191
+ "@razroo/iso-orchestrator": "^0.2.0",
189
192
  "@razroo/iso-postflight": "^0.1.0",
190
193
  "@razroo/iso-preflight": "^0.1.0",
191
194
  "@razroo/iso-prioritize": "^0.1.0",
192
195
  "@razroo/iso-redact": "^0.1.0",
193
196
  "@razroo/iso-score": "^0.1.0",
194
197
  "@razroo/iso-timeline": "^0.1.0",
195
- "@razroo/iso-trace": "^0.4.0",
198
+ "@razroo/iso-trace": "^0.5.0",
196
199
  "playwright": "^1.58.1"
197
200
  },
198
201
  "devDependencies": {
199
202
  "@razroo/agentmd": "^0.3.0",
200
- "@razroo/iso": "^0.2.5",
203
+ "@razroo/iso": "^0.3.1",
201
204
  "@razroo/iso-eval": "^0.4.0",
202
- "@razroo/iso-harness": "^0.6.1",
205
+ "@razroo/iso-harness": "^0.8.0",
203
206
  "@razroo/iso-route": "^0.5.3"
204
207
  }
205
208
  }
@@ -8,6 +8,7 @@
8
8
  * - idempotent bundle execution keyed by URL + retry count
9
9
  * - bounded fan-out through workflow.forEach(..., { maxParallel })
10
10
  * - mutexed state/report-number writes across parallel workers
11
+ * - renewable leases + heartbeats for worker liveness inspection
11
12
  */
12
13
 
13
14
  import { spawn, spawnSync } from 'node:child_process';
@@ -46,12 +47,13 @@ const DEFAULT_WORKFLOW_ID = 'jobforge-batch';
46
47
  const STATE_HEADER = 'id\turl\tstatus\tstarted_at\tcompleted_at\treport_num\tscore\terror\tretries';
47
48
 
48
49
  function usage() {
49
- console.log(`job-forge batch runner - process job offers in batch via opencode run workers
50
- Uses your default opencode model.
50
+ console.log(`job-forge batch runner - process job offers in batch via AI CLI workers
51
+ Uses the selected runner's default project configuration.
51
52
 
52
53
  Usage: batch-runner.sh [OPTIONS]
53
54
 
54
55
  Options:
56
+ --runner NAME Worker CLI: opencode or codex (default: ${process.env.JOBFORGE_BATCH_RUNNER || 'opencode'})
55
57
  --parallel N Number of parallel workers (default: 1)
56
58
  --bundle-size N Offers per worker invocation (default: 5, use 1 for
57
59
  legacy per-offer mode). Each worker processes N
@@ -74,6 +76,7 @@ Files:
74
76
 
75
77
  function parseArgs(argv) {
76
78
  const options = {
79
+ runner: parseRunner(process.env.JOBFORGE_BATCH_RUNNER || 'opencode'),
77
80
  parallel: 1,
78
81
  dryRun: false,
79
82
  retryFailed: false,
@@ -94,6 +97,9 @@ function parseArgs(argv) {
94
97
  };
95
98
 
96
99
  switch (arg) {
100
+ case '--runner':
101
+ options.runner = parseRunner(next());
102
+ break;
97
103
  case '--parallel':
98
104
  options.parallel = parsePositiveInt(next(), '--parallel');
99
105
  break;
@@ -128,6 +134,12 @@ function parseArgs(argv) {
128
134
  return options;
129
135
  }
130
136
 
137
+ function parseRunner(value) {
138
+ const runner = String(value || '').trim().toLowerCase();
139
+ if (runner === 'opencode' || runner === 'codex') return runner;
140
+ throw new Error(`--runner must be one of: opencode, codex`);
141
+ }
142
+
131
143
  function parsePositiveInt(value, label) {
132
144
  const n = Number.parseInt(value, 10);
133
145
  if (!Number.isInteger(n) || n < 1) {
@@ -180,7 +192,7 @@ async function readTextIfExists(path) {
180
192
  return readFile(path, 'utf8');
181
193
  }
182
194
 
183
- async function checkPrerequisites({ dryRun }) {
195
+ async function checkPrerequisites({ dryRun, runner }) {
184
196
  if (!existsSync(INPUT_FILE)) {
185
197
  throw new Error(`${INPUT_FILE} not found. Add offers first.`);
186
198
  }
@@ -188,9 +200,10 @@ async function checkPrerequisites({ dryRun }) {
188
200
  throw new Error(`${PROMPT_FILE} not found.`);
189
201
  }
190
202
  if (!dryRun) {
191
- const result = spawnSync('opencode', ['--help'], { stdio: 'ignore' });
203
+ const command = workerCommandName(runner);
204
+ const result = spawnSync(command, ['--help'], { stdio: 'ignore' });
192
205
  if (result.error?.code === 'ENOENT') {
193
- throw new Error("'opencode' CLI not found in PATH.");
206
+ throw new Error(`'${command}' CLI not found in PATH.`);
194
207
  }
195
208
  }
196
209
 
@@ -531,6 +544,70 @@ async function runOpencode(prompt, logFile) {
531
544
  });
532
545
  }
533
546
 
547
+ let batchTemplateCache;
548
+
549
+ async function batchTemplateText() {
550
+ if (batchTemplateCache === undefined) {
551
+ batchTemplateCache = await readFile(PROMPT_FILE, 'utf8');
552
+ }
553
+ return batchTemplateCache;
554
+ }
555
+
556
+ function workerCommandName(runner) {
557
+ return runner === 'codex' ? 'codex' : 'opencode';
558
+ }
559
+
560
+ async function runCodex(prompt, logFile) {
561
+ await ensureDir(dirname(logFile));
562
+ const finalMessageFile = `${logFile}.last-message.txt`;
563
+ const combinedPrompt = `${(await batchTemplateText()).trim()}\n\n${prompt}`;
564
+
565
+ return new Promise((resolveRun) => {
566
+ const child = spawn('codex', [
567
+ 'exec',
568
+ '--dangerously-bypass-approvals-and-sandbox',
569
+ '-C',
570
+ PROJECT_DIR,
571
+ '--output-last-message',
572
+ finalMessageFile,
573
+ combinedPrompt,
574
+ ], {
575
+ cwd: PROJECT_DIR,
576
+ env: {
577
+ ...process.env,
578
+ JOB_FORGE_PROJECT: PROJECT_DIR,
579
+ },
580
+ stdio: ['ignore', 'pipe', 'pipe'],
581
+ });
582
+
583
+ const chunks = [];
584
+ child.stdout.on('data', (chunk) => chunks.push(chunk));
585
+ child.stderr.on('data', (chunk) => chunks.push(chunk));
586
+
587
+ child.on('error', async (error) => {
588
+ chunks.push(Buffer.from(`\n${error.stack || error.message}\n`));
589
+ });
590
+
591
+ child.on('close', async (code) => {
592
+ const output = Buffer.concat(chunks).toString('utf8');
593
+ const finalMessage = await readTextIfExists(finalMessageFile);
594
+ const logOutput = finalMessage
595
+ ? `${output}\n\n--- FINAL MESSAGE ---\n${finalMessage}`
596
+ : output;
597
+ await writeFile(logFile, logOutput, 'utf8');
598
+ resolveRun({
599
+ exitCode: code ?? 1,
600
+ output: finalMessage || output,
601
+ });
602
+ });
603
+ });
604
+ }
605
+
606
+ async function runWorker(runner, prompt, logFile) {
607
+ if (runner === 'codex') return runCodex(prompt, logFile);
608
+ return runOpencode(prompt, logFile);
609
+ }
610
+
534
611
  function parseStatusLines(output) {
535
612
  const seen = new Map();
536
613
  for (const line of output.split('\n')) {
@@ -550,7 +627,57 @@ function parseStatusLines(output) {
550
627
  return seen;
551
628
  }
552
629
 
553
- async function processBundle(workflow, bundle) {
630
+ async function withWorkerLiveness(workflow, { runner, tag, ids, logFile }, run) {
631
+ const leaseKey = `worker:${tag}`;
632
+ const holder = `${runner}:${process.pid}:${tag}`;
633
+ const detail = {
634
+ runner,
635
+ ids,
636
+ log: relativeProjectPath(logFile),
637
+ };
638
+
639
+ await workflow.touchLease(leaseKey, {
640
+ holder,
641
+ ttlMs: 120_000,
642
+ detail: {
643
+ ...detail,
644
+ phase: 'starting',
645
+ },
646
+ });
647
+ await workflow.heartbeat(leaseKey, {
648
+ ...detail,
649
+ phase: 'starting',
650
+ });
651
+
652
+ const timer = setInterval(() => {
653
+ workflow.touchLease(leaseKey, {
654
+ holder,
655
+ ttlMs: 120_000,
656
+ detail: {
657
+ ...detail,
658
+ phase: 'running',
659
+ },
660
+ }).catch(() => {});
661
+ workflow.heartbeat(leaseKey, {
662
+ ...detail,
663
+ phase: 'running',
664
+ }).catch(() => {});
665
+ }, 30_000);
666
+ timer.unref?.();
667
+
668
+ try {
669
+ return await run();
670
+ } finally {
671
+ clearInterval(timer);
672
+ await workflow.heartbeat(leaseKey, {
673
+ ...detail,
674
+ phase: 'finished',
675
+ }).catch(() => {});
676
+ await workflow.releaseLease(leaseKey, holder).catch(() => {});
677
+ }
678
+ }
679
+
680
+ async function processBundle(workflow, bundle, options) {
554
681
  const startedAt = nowIso();
555
682
  const specs = await reserveBundle(workflow, bundle, startedAt);
556
683
  const tag = bundleTag(bundle);
@@ -561,11 +688,17 @@ async function processBundle(workflow, bundle) {
561
688
  type: 'batch.bundle.started',
562
689
  detail: {
563
690
  ids: bundle.map((offer) => offer.id),
691
+ runner: options.runner,
564
692
  log: relativeProjectPath(logFile),
565
693
  },
566
694
  });
567
695
 
568
- const { exitCode, output } = await runOpencode(buildBundlePrompt(specs), logFile);
696
+ const { exitCode, output } = await withWorkerLiveness(workflow, {
697
+ runner: options.runner,
698
+ tag,
699
+ ids: bundle.map((offer) => offer.id),
700
+ logFile,
701
+ }, () => runWorker(options.runner, buildBundlePrompt(specs), logFile));
569
702
  const completedAt = nowIso();
570
703
  const statuses = parseStatusLines(output);
571
704
  const outcomes = [];
@@ -598,6 +731,13 @@ async function processBundle(workflow, bundle) {
598
731
  score: sanitizeCell(score),
599
732
  report_num: sanitizeCell(parsed.report_num, spec.report_num),
600
733
  });
734
+ await workflow.heartbeat(`worker:${tag}`, {
735
+ runner: options.runner,
736
+ ids: bundle.map((candidate) => candidate.id),
737
+ offerId: spec.id,
738
+ phase: 'settling',
739
+ status,
740
+ }).catch(() => {});
601
741
  console.log(` ${status === 'completed' ? 'OK' : 'FAIL'} #${spec.id} (status=${status}, score=${sanitizeCell(score)}, report=${sanitizeCell(parsed.report_num, spec.report_num)})`);
602
742
  continue;
603
743
  }
@@ -622,6 +762,13 @@ async function processBundle(workflow, bundle) {
622
762
  score: '-',
623
763
  report_num: spec.report_num,
624
764
  });
765
+ await workflow.heartbeat(`worker:${tag}`, {
766
+ runner: options.runner,
767
+ ids: bundle.map((candidate) => candidate.id),
768
+ offerId: spec.id,
769
+ phase: 'settling',
770
+ status: 'failed',
771
+ }).catch(() => {});
625
772
  console.log(` FAIL #${spec.id} (no status emitted; see ${relativeProjectPath(logFile)})`);
626
773
  }
627
774
 
@@ -633,6 +780,7 @@ async function processBundle(workflow, bundle) {
633
780
  type: 'batch.bundle.completed',
634
781
  detail: {
635
782
  ids: bundle.map((offer) => offer.id),
783
+ runner: options.runner,
636
784
  exitCode,
637
785
  log: relativeProjectPath(logFile),
638
786
  outcomes,
@@ -766,7 +914,7 @@ async function run(options) {
766
914
  const pending = selectPendingOffers(offers, stateRows, options);
767
915
 
768
916
  console.log('=== job-forge batch runner ===');
769
- console.log(`Parallel: ${options.parallel} | Bundle size: ${options.bundleSize} | Max retries: ${options.maxRetries}`);
917
+ console.log(`Runner: ${options.runner} | Parallel: ${options.parallel} | Bundle size: ${options.bundleSize} | Max retries: ${options.maxRetries}`);
770
918
  console.log(`Workflow: ${options.workflowId} (${relativeProjectPath(WORKFLOW_DIR)})`);
771
919
  console.log(`Input: ${totalInput} offers`);
772
920
  console.log('');
@@ -812,6 +960,7 @@ async function run(options) {
812
960
  totalInput,
813
961
  pending: pending.length,
814
962
  bundles: bundles.length,
963
+ runner: options.runner,
815
964
  parallel: options.parallel,
816
965
  bundleSize: options.bundleSize,
817
966
  },
@@ -824,7 +973,7 @@ async function run(options) {
824
973
  const stepName = bundleStepName(bundle, rowsBeforeRun);
825
974
  return workflow.step(
826
975
  stepName,
827
- async () => processBundle(workflow, bundle),
976
+ async () => processBundle(workflow, bundle, options),
828
977
  {
829
978
  idempotencyKey: stepName,
830
979
  },
@@ -5,6 +5,7 @@ import { resolve } from "node:path";
5
5
  const root = resolve(process.argv[2] ?? ".");
6
6
  const files = {
7
7
  instructions: readFileSync(resolve(root, "iso/instructions.md"), "utf8"),
8
+ instructionsOpencode: readFileSync(resolve(root, "iso/instructions.opencode.md"), "utf8"),
8
9
  helpers: readFileSync(resolve(root, "modes/reference-local-helpers.md"), "utf8"),
9
10
  apply: readFileSync(resolve(root, "modes/apply.md"), "utf8"),
10
11
  models: readFileSync(resolve(root, "models.yaml"), "utf8"),
@@ -21,6 +22,7 @@ const checks = [
21
22
  ["H6 requires merge and verify", () => every(files.instructions, ["batch/tracker-additions/*.tsv", "npx job-forge merge", "npx job-forge verify"])],
22
23
  ["H7 distrusts subagent prose", () => every(files.instructions, ["must originate from a file", "not from prior subagent prose"])],
23
24
  ["H8 keeps proxy secret and requires stealth", () => every(files.instructions, ["[H8]", "Do not transcribe `server`, `username`, `password`, or `bypass`", "`stealth: true`"])],
25
+ ["OpenCode addendum exists for task semantics", () => every(files.instructionsOpencode, ["OpenCode", "`task`", "launch acknowledgement", "Do not use `task` to poll status"])],
24
26
  ["root points to consolidated helper reference", () => every(files.instructions, ["[D8]", "modes/reference-local-helpers.md", "deterministic local helpers"])],
25
27
  ["helper reference covers score/timeline/prioritize/lineage", () => every(files.helpers, ["templates/score.json", "npx job-forge score:*", "templates/timeline.json", "npx job-forge timeline:*", "templates/prioritize.json", "npx job-forge prioritize:*", ".jobforge-lineage.json", "npx job-forge lineage:*"])],
26
28
  ["root helper defaults are consolidated", () => !/\[D(?:9|1\d|2[0-9])\]/.test(files.instructions)],