# Batch evaluation

The `batch/` folder holds the **parallel batch runner** for processing 10+ job URLs with headless AI CLI workers (`opencode run` or `codex exec`). For how batch fits into the rest of JobForge, see [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md).

## What ships in git

| Path | Role |
|------|------|
| `batch-runner.sh` | Compatibility entrypoint; delegates to the durable Node orchestrator by default |
| `batch-prompt.md` | Prompt template passed to each worker (keep evaluation and scoring instructions aligned with the canonical model in [`modes/_shared.md`](../modes/_shared.md) so batch scores match single-offer runs) |
| `README.md` | This file |

## Local-only files (gitignored when present)

Per [`.gitignore`](../.gitignore): `batch-input.tsv`, `batch-state.tsv`, `logs/*`, `tracker-additions/*.tsv`, and `.jobforge-runs/`. Empty dirs (`logs/`, `tracker-additions/`) use `.gitkeep` so the tree exists in a fresh clone.

The default runner uses `@agent-pattern-labs/iso-orchestrator` through
`scripts/batch-orchestrator.mjs`. It persists bundle steps and events in
`.jobforge-runs/`, caps worker fan-out with `workflow.forEach`, serializes
state/report-number writes while parallel bundles run, and records worker
leases/heartbeats for liveness inspection. Use `./batch/batch-runner.sh --runner codex`
to switch the worker CLI, or `JOBFORGE_LEGACY_BATCH_RUNNER=1 ./batch/batch-runner.sh`
only to fall back to the old shell loop.

## Input: `batch-input.tsv`

Tab-separated UTF-8 text, with a **header row** (the runner skips the literal `id` header):

| Column | Required | Description |
|--------|----------|-------------|
| `id` | Yes | Numeric offer id (used for state and ordering) |
| `url` | Yes | Job posting URL |
| `source` | No | Short label (e.g. board or company) |
| `notes` | No | Free text for your own context |

Example:

```text
id	url	source	notes
1	https://jobs.example.com/123	greenhouse	Staff engineer
2	https://boards.example.com/456	ashby	Remote OK
```

Create this file in **`batch/`** next to the runner (see `batch-runner.sh` constants). Then:

```bash
./batch/batch-runner.sh --dry-run   # from repo root
./batch/batch-runner.sh
```

Options and file layout: `./batch/batch-runner.sh --help`.

## After a batch run

Workers write one-line TSV rows under `batch/tracker-additions/`. Merge them into your tracker from the repo root:

```bash
npm run merge
npm run verify   # optional: pipeline health after merge (report links, statuses, pending TSVs)
```

(`npx job-forge merge` — same as `npm run merge`; see [CONTRIBUTING.md](../CONTRIBUTING.md#development).)

After a successful merge, each processed file is moved to **`batch/tracker-additions/merged/`** (created on first merge when the directory does not yet exist). `npm run verify` only looks for `*.tsv` files in the **top level** of `batch/tracker-additions/`, so rows already merged and archived under `merged/` do not trigger the “pending TSVs” warning.

## Live application queue (Codex)

Live submissions use a separate no-retry runner; do not reuse the evaluation
batch runner for them. Create a local queue file containing only authoritative
artifact paths:

```json
{
  "version": 1,
  "facts": "batch/application-facts-2026-07-17.yml",
  "jobs": [
    {
      "id": "021-example-company-role",
      "report": "reports/021-example-company-role.md",
      "score": "batch/score-021.json"
    }
  ]
}
```

Always inspect the deterministic plan first:

```bash
npx job-forge apply:queue --input batch/apply-queue.json --dry-run
npx job-forge apply:queue --input batch/apply-queue.json --parallel 2
```

For a multi-job request, the dry-run input must contain the complete scored
candidate set before it is split into live rounds. The plan canonicalizes
company names, keeps only the highest-fit eligible role per company, and reports
same-company omissions in `companySelection`. Create live one- or two-job queue
files only from `companySelection.selectedCandidateIds`; replace omitted slots
with candidates from other companies.

Each job `id` must exactly match its report filename without `.md`. A live
invocation accepts exactly one round of at most two jobs, waits for both workers,
writes a durable attempt tombstone before each Codex process starts, and has no
automatic retry option. For additional jobs, wait for the current invocation to
settle, run the shared Geometra list-and-explicit-disconnect barrier, then invoke
the queue with the next one- or two-job file. A crashed or invalid worker is quarantined under
`.jobforge-runs/`; only a new, explicitly authorized workflow should revisit
that application.

## Prerequisites

The runner expects the selected worker CLI (`opencode` by default, `codex` when `--runner codex` is used) on `PATH` and a valid `batch-prompt.md`. It creates `reports/` and tracker paths when they do not exist; ensure your usual JobForge setup (`cv.md`, `config/profile.yml`, `portals.yml`) matches what `batch-prompt.md` assumes.
