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/instructions.md +8 -0
- package/README.md +2 -2
- package/batch/README.md +7 -6
- package/batch/batch-runner.sh +2 -2
- package/bin/create-job-forge.mjs +4 -1
- package/bin/sync.mjs +35 -1
- package/docs/ARCHITECTURE.md +9 -8
- package/docs/CUSTOMIZATION.md +6 -6
- package/iso/instructions.opencode.md +8 -0
- package/lib/jobforge-observability.mjs +847 -0
- package/opencode.json +2 -1
- package/package.json +8 -5
- package/scripts/batch-orchestrator.mjs +158 -9
- package/scripts/check-iso-smoke.mjs +2 -0
- package/scripts/guard.mjs +114 -190
- package/scripts/telemetry.mjs +214 -450
- package/scripts/trace.mjs +103 -232
package/opencode.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-forge",
|
|
3
|
-
"version": "2.14.
|
|
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.
|
|
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.
|
|
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.
|
|
203
|
+
"@razroo/iso": "^0.3.1",
|
|
201
204
|
"@razroo/iso-eval": "^0.4.0",
|
|
202
|
-
"@razroo/iso-harness": "^0.
|
|
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
|
|
50
|
-
Uses
|
|
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
|
|
203
|
+
const command = workerCommandName(runner);
|
|
204
|
+
const result = spawnSync(command, ['--help'], { stdio: 'ignore' });
|
|
192
205
|
if (result.error?.code === 'ENOENT') {
|
|
193
|
-
throw new Error(
|
|
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
|
|
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
|
|
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)],
|