diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml new file mode 100644 index 0000000..8863866 --- /dev/null +++ b/.github/workflows/loop.yml @@ -0,0 +1,162 @@ +name: Loop + +# Single workflow = scheduler + sandbox (05 §5.1): every event joins one serial +# domain, and the concurrency group provides the platform-level single-writer +# lock (R2 has no concurrent writes). +on: + issues: + types: [opened, reopened, edited] + issue_comment: + types: [created] + pull_request: + types: [opened, synchronize] + pull_request_review: + types: [submitted, dismissed] + pull_request_target: + types: [closed] + release: + types: [published] + schedule: + - cron: '17 3 * * *' # daily sweep; weekly retro cron: scripts/loop/lib/route.mjs WEEKLY_CRON + workflow_dispatch: + inputs: + task: + description: 'Task number (issue/PR). Empty = stage-only run.' + required: false + stage: + description: 'triage | bugfix | feature | pr-review | deps | security | sweep | release' + required: false + default: triage + execute_writes: + description: 'L2 rehearsal: set "true" to allow real GitHub writes (default report-only)' + required: false + default: 'false' + smoke: + description: 'Infrastructure only: exercise checkout/install/R2 pull+push, skip the agent' + required: false + default: 'false' + +concurrency: + group: loop + cancel-in-progress: false + +# Least privilege: GitHub reads use GITHUB_TOKEN; writes (only the +# execute_writes=true rehearsal) go through LOOP_GH_TOKEN and exist only in +# default-branch contexts — fork events carry no secrets (platform guarantee). +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + loop: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + # Note: job-level env can only reference contexts like github / secrets / vars. + # `inputs` is unavailable for non-workflow_dispatch events, and the `runner` + # context exists only inside steps — neither belongs here (this once broke + # workflow parsing, with GitHub only reporting "workflow file issue"). + env: + LOOP_EVENT_NAME: ${{ github.event_name }} + LOOP_EVENT_ACTION: ${{ github.event.action }} + LOOP_EVENT_JSON: ${{ toJSON(github.event) }} + LOOP_REPO: ${{ github.repository }} + # For non-dispatch events github.event.inputs is null → default to report (report-only) + LOOP_EXECUTE_WRITES: ${{ github.event.inputs.execute_writes || 'false' }} + LOOP_SMOKE: ${{ github.event.inputs.smoke || 'false' }} + LOOP_MODEL: deepseek/deepseek-v4-flash + LOOP_RUN_TIMEOUT_MS: '3300000' # 55min, leaving headroom for push (job timeout 60min) + GH_TOKEN: ${{ secrets.LOOP_GH_TOKEN || github.token }} + # Credentials: secrets only + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + # Non-sensitive config: prefer the repository variable, falling back to the + # same-named secret (both setups are supported). ACCOUNT_ID is part of the + # R2 endpoint (https://.r2.cloudflarestorage.com), not a credential. + R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID || secrets.R2_ACCOUNT_ID }} + # The bucket name needs no config: the code derives it from + # `loop-state--` (one bucket per repo). Set it only to point at + # another bucket (migration/troubleshooting); unset expands to an empty + # string → derivation is used. + R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }} + + steps: + # What to check out — decided by "is this code trusted": + # + # workflow_dispatch → github.ref. The human-driven smoke/rehearsal entry; + # dispatch can only target refs in this repo, so fork + # branches are excluded and it stays safe. + # same-repo PR → the PR's head branch. The PRs loop itself opens + # (`loop/-*`) fall here, and pr-review must get the + # PR code to review it — checking out only the default + # branch would leave pr-review blind to the content + # under review. Same-repo branches can only be created + # by OWNER/MEMBER/COLLABORATOR, so the code is trusted. + # everything else → the default branch. **Fork PRs land here**: never + # check out external code, use only trusted scripts + # from this repo's default branch, and read diffs with + # `gh pr diff`. Same for issues / schedule / release / + # pull_request_target. + - uses: actions/checkout@v4 + with: + ref: >- + ${{ + github.event_name == 'workflow_dispatch' && github.ref + || (github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository + && github.event.pull_request.head.ref) + || github.event.repository.default_branch + }} + + - uses: pnpm/action-setup@v4 + with: + version: 11 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install engine (pi) + run: | + npm i -g @earendil-works/pi-coding-agent@0.85.1 + pi --version + echo '--- available DeepSeek models ---' + available=$(pi --list-models deepseek) + echo "$available" + # pi **silently falls back to the default model** for an invalid + # --model rather than erroring — a misspelled model name would quietly + # run every run with the wrong model. Validate right after install. + want="${LOOP_MODEL#*/}" + if ! grep -qE "(^|[[:space:]])${want}([[:space:]]|$)" <<<"$available"; then + echo "::error::LOOP_MODEL=$LOOP_MODEL not in pi's DeepSeek catalog (want '$want')" + exit 1 + fi + echo "model $want is available" + + - name: Pull state from R2 + run: node scripts/loop/r2-sync.mjs pull + + - name: Run stage + env: + LOOP_SESSION_DIR: ${{ runner.temp }}/pi-sessions + run: node scripts/loop/entry.mjs + + # Push the state layer back even on crash/timeout (the task stays in + # processing and is reclaimed by the next sweep via TTL) + - name: Push state to R2 + if: always() + run: node scripts/loop/r2-sync.mjs push + + - name: Upload session transcripts + if: always() + uses: actions/upload-artifact@v4 + with: + name: loop-sessions-${{ github.run_id }} + path: ${{ runner.temp }}/pi-sessions + retention-days: 90 + if-no-files-found: ignore \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index eeaf736..a02500d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,23 @@ tiny-oss is a tiny object storage SDK focused on uploading, with a functional AP - Integration specs (`test/*-integration.spec.ts`) need `npm run serve` first (Hono server on :8080, credentials from `.env`); signature/unit specs don't. - Input data is `Blob | ArrayBuffer | Uint8Array | string`; mini programs pass `ArrayBuffer`. +## Commit messages + +Conventional commits, and the type drives the user-facing `CHANGELOG.md` +(`changelogen`), so it must describe what a **package consumer** can observe: + +- `feat` / `fix` / `perf` — reaches users; only for changes to the published + package's behaviour. +- `docs` — user-facing documentation (`README.md`, `UPGRADING.md`). +- `chore()` — repository maintenance no consumer sees: the loop host + (`scripts/loop/`), CI workflows, the norms layer (this file, `docs/agents/`, + `skills/`), tooling. Name the area: `chore(loop)`, `chore(ci)`, + `chore(norms)`. + +Never use `fix` or `refactor` for maintenance work. Both are rendered in the +CHANGELOG — `refactor` additionally carries a patch semver — so internal +iteration would read to a user as a change to the package. + ## References - Usage and per-provider options: `README.md`; building a custom provider: README "Extension" section and `src/provider.ts`. diff --git a/scripts/loop/README.md b/scripts/loop/README.md index f852ca6..b1f794c 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -1,4 +1,4 @@ -# scripts/loop — local manual host (A0 trial period) +# scripts/loop — loop host (A1: Actions + R2) ## Overall purpose (owner directive, 2026-09-08) @@ -11,55 +11,291 @@ Accordingly, at the end of every trial run, ask yourself: what Loop defect or improvement did this round expose? → record it in the list below or fix it directly in this directory's files. -## Daily commands +## Layout + +``` +.github/workflows/loop.yml # scheduler + sandbox (05 §1/§5.1); one workflow, one serial domain +scripts/loop/ + entry.mjs # runner orchestrator: route event → inbox/metrics/run + run.mjs # local CLI host (manual A0-style runs, `pnpm loop`) + r2-sync.mjs # state-layer pull/push/seed/check against R2 + shared/state.mjs # STATE-LAYER PRIMITIVES — single writer for state/ + shared/route.mjs # event → decision (pure functions, offline-testable) + shared/agent.mjs # pi driver: prompt, spawn, usage, failure classification + shared/r2.mjs # R2 sync via the runner's preinstalled AWS CLI +state/ # ignored; on the runner it is a per-job snapshot of R2 +``` + +**Single-writer rule:** both `run.mjs` and `entry.mjs` mutate the state layer +only through `shared/state.mjs`. Two implementations of the same rule is how +drift starts — the missing `#33` acceptance row was a human-drift instance of +exactly this. + +## Local commands (unchanged; `pnpm loop` = `run.mjs`) + ```bash pnpm loop start --issue # import a real issue + claim (stage defaults to triage) -pnpm loop start --task [--stage ] # claim an existing task (use this to re-run after waiting-info is answered) +pnpm loop start --task [--stage ] # claim an existing task (re-run after waiting-info is answered) pnpm loop start --new --title "..." # local synthetic task -pnpm loop checkpoint --run --note "..." # process checkpoint -pnpm loop end --run --outcome [--comment ".."] [--label ] [--no-github] +pnpm loop checkpoint --run --note "..." +pnpm loop end --run --outcome [--comment ".."] [--label ] [--no-github] pnpm loop summary | view # summary / task list ``` -## Real remote flow (owner expectation, finalized and implemented 2026-09-08) - -When handling a real issue, `end` writes back to GitHub automatically -(`run.mjs` `syncGithub`): - -- **Labels are applied automatically**: outcome → one of the five role labels - (`triaged`→`ready-for-agent`, `needs-info`→`needs-info`, - `needs-triage`/`failed`→`needs-triage`, `pr-opened`→`ready-for-human`; - `closed` may take `--label wontfix` etc.). -- **Info needing human confirmation is commented straight into the issue**: - `end --comment "…body…"` posts a comment; with `outcome=closed` that text is - used as the closing reason (`gh issue close --comment`). -- Local synthetic tasks (no url) skip GitHub writes automatically; - `--no-github` forces a skip; gh write failures only warn and never roll back - local state. - -## Full closed-loop flow (owner finalization, 2026-09-08) - -> After the agent changes code it **reviews its own work**, then commits, pushes -> and opens a PR; human review feedback lands as PR comments; changes requested -> → the agent keeps amending; approved → the human merges. - -Steps inside a feature/bugfix run (maker=agent): - -1. Make the change → `verify` skill until all five gates are green → `review` - skill (dual review; when no second agent is available, self-review + human - final call) -2. `git checkout -b loop/-` → commit (body contains `Closes #`) → push -3. `gh pr create` (body contains `Closes #` + `loop-task: #` + - requirement/changes/verification) -4. `end --outcome pr-opened --pr ` → auto-labels `ready-for-human` + - records task.prs -5. Human comments on the PR; request-changes → claim `--task ` to keep - amending → commit/push (the PR auto-updates) → loop; approve → human merges - → the evaluation chain records accepted - -Write boundary (upgraded 2026-09-08): **opened up to commit/push/open-PR + -labeling/commenting**; merging PRs / npm publishing stay human (the cognitive -guard is unchanged). +### Write boundary (`--write-level` | `LOOP_WRITE_LEVEL`, default `report`) + +| Level | Effect | +| --- | --- | +| `report` | State layer + report only. GitHub write actions are **listed, never executed** (A1 semantics). | +| `auto` | Executes labels/comments/close (the A0 closed-loop boundary; L2 default, and the rehearsal mode). | + +In `report` mode the pending actions are appended to `state/reports/.md` +as a checklist and surfaced in the Actions Step Summary, so a human can execute +them by hand. + +## A1 architecture (Actions + R2) + +``` +GitHub event ─▶ loop.yml (concurrency group `loop` = platform-level single writer) + ├─ setup node/pnpm ▸ install pi ▸ print `pi --list-models deepseek` + ├─ r2-sync pull (state layer → job-local state/) + ├─ entry.mjs (route → inbox | metrics | run) + ├─ r2-sync push (if: always()) + └─ upload pi sessions (artifact, 90d) +``` + +- **Engine `{{engine}}` = pi** (`@earendil-works/pi-coding-agent@0.85.1`). + Verified before adoption: headless `--mode json` JSONL event stream, DeepSeek + built in (`DEEPSEEK_API_KEY`), ~21 MB with no install scripts, Node >= 22.19. + `--approve` is **mandatory**: without it non-interactive runs silently ignore + project-local resources. +- **pi has no built-in permission gating** (verified). The job's `permissions:` + block and the absence of secrets in fork contexts are the whole boundary — + do not add write credentials to a job that touches untrusted input. +- **One writer for closing:** the agent writes `state/reports/.result.json`; + `entry.mjs` performs the transition via `finishRun`. Agents never run + `pnpm loop end` themselves. +- **Sessions:** only the aggregate lands in the state layer (run `end` row: + `tokens`/`durationMs`/`model`/`outcome`); full transcripts go to the + artifact store (90 days). + +### Trigger → action (workflow) + +| Event | Action | +| --- | --- | +| `issues` opened/reopened | run(triage) | +| `issues` edited, `issue_comment` created | inbox first (never silently dropped), then run per task status | +| loop PR (`loop/-*` **and** `loop-task: #`) opened/synchronize | run(pr-review) | +| dependabot PR (author `dependabot[bot]` or head `dependabot/*`) | run(deps) | +| external PR (author_association ∉ OWNER/MEMBER/COLLABORATOR) | run(triage, readonly) | +| `pull_request_review` on a loop PR | run(pr-review) | +| `pull_request_target` closed (loop PR) | metrics: merged / closed-unmerged | +| `release` published | metrics: released | +| `schedule` daily / weekly | system run: sweep / retro-scheduled | +| `workflow_dispatch` | run per inputs (`task`/`stage`); `execute_writes=true` = L2 rehearsal | + +Anything else is a no-op that still exits 0 — event storms cost nothing. + +### Outcomes: two vocabularies + +A run is either task-scoped or system-level, and they do not share an outcome +vocabulary — conflating them corrupts the metrics. + +| | Task-scoped (`issues opened`, loop PR, …) | System-level (`sweep`, `retro-scheduled`, release preflight) | +| --- | --- | --- | +| Outcomes | `triaged`, `needs-info`, `needs-triage`, `pr-opened`, `closed`, `rejected`, `accepted`, `failed`, `retry` | `completed`, `failed`, `retry`, `aborted` | +| Effect | moves the task to its mapped status + label | records the run only; no task moves | +| Whitelist | per-stage (`STAGE_OUTCOMES` in `shared/state.mjs`) | `SYSTEM_OUTCOMES` | + +`closed` is a *task* outcome meaning "closed without a PR, so it never enters +the acceptance denominator". A system-level run recording `closed` would make +retro count sweep/retro executions as closed tasks, so `finishRun` refuses it. +A real sweep initially reported `closed` for exactly this reason; the run now +records `completed`. + +### Failure classification (exit codes) + +The run contract wants 0/1/2/3/137; pi only reports 0/1/143/129. Machine faults +must not reach the human inbox, or the human-intervention metric becomes +noise: + +| Situation | Outcome | Effect on task | +| --- | --- | --- | +| agent produced a valid result | agent's outcome | per `OUTCOME_MAP` | +| provider/network flake, spawn failure, unclassifiable exit | `retry` | back to `ready` (claimable) | +| pi aborted / timeout | `retry` (abort recorded) | back to `ready` | +| agent concluded it cannot complete | `failed` | `needs-triage` (human inbox) | + +`retry` is a host-level outcome (documented in `shared/state.mjs`), not part of +the ops.md outcome table. + +## R2 state layer + +**One bucket per repository** (owner decision, 2026-09-10). Bucket name is +`loop-state--`, derived at runtime (`shared/r2.mjs`): from +`GITHUB_REPOSITORY` in CI, from `package.json`'s `repository` field locally +(string, `github:` shorthand, SSH and https forms all resolve identically). So +tiny-oss writes to **`loop-state-alex1990-tiny-oss`**, and a sibling repo gets +its own bucket with no code change. `R2_BUCKET` overrides the derivation +(migration, triage, pointing at a scratch bucket). + +Why not one bucket for all repos: **an R2 API token can only be scoped to a +bucket, never to a key prefix.** The access-policy resource is +`com.cloudflare.edge.r2.bucket.__` — there +is no prefix dimension. Sharing a bucket therefore makes "one token per repo" +meaningless: every token can read and write the whole bucket, so the blast +radius of one leaked credential (or one fork-context injection) is every +repository using that bucket. Bucket count is not a constraint (limit: 1,000,000) +and KB-scale state is inside the free tier either way, so per-repo buckets buy +real isolation at no cost. + +Why the owner segment: bucket names are a flat, account-wide namespace, so a +name collision is a real collision. Including the owner moves the collision +surface from "repository name" up to "owner + repository name", which keeps +repositories under different GitHub organisations (or different accounts) apart +even when they share a name. Derivation normalises to R2's rules — lowercase +letters, digits and hyphens only, 3–63 characters, no leading/trailing hyphen — +and falls back to a stable content hash if `owner-repo` would exceed 63 (GitHub +allows repository names up to 100 characters). + +Keys inside the bucket mirror the local layout (`state/tasks/…`), since the +bucket name already carries both the owner and repository identity. + +- **No object versioning.** Cloudflare R2 has no object-versioning feature + (verified against the R2 docs and release notes); the nearest capability is + bucket locks (retention, not history). Consequence: **deletes are not + revertible**, so `push` deliberately omits `--delete`. Stale lock files on + the remote are harmless (TTL semantics). +- Sync uses the runner's **preinstalled AWS CLI v2** — no repo dependency, no + install step. R2 region is `auto`; `AWS_REQUEST_CHECKSUM_CALCULATION=when_required` + keeps the CLI's default checksum behaviour out of the way. +- **`pull` failure must abort the job** — an empty local `state/` pushed back + would otherwise destroy the remote state layer. + +### One-time setup (per repository) + +R2 (Cloudflare dashboard): + +1. R2 → **Create bucket** → `loop-state-alex1990-tiny-oss`. +2. R2 → **API → Manage R2 API Tokens → Create API Token** → permission + *Object Read & Write* → **Specify bucket → `loop-state-alex1990-tiny-oss` only**. + Do not use "Apply to all buckets", and do not use the global + *My Profile → API Tokens* page (that is a different system with account-wide + scope). *Object Read & Write* cannot create, delete or configure buckets and + is **not accepted by the Cloudflare REST API at all** — it is purely an S3 + credential. +3. Note **Access Key ID**, **Secret Access Key**, and the **Account ID**. + +GitHub → Settings → Secrets and variables → Actions. + +**Three secrets** (actual credentials — they cannot be inferred from each other): + +| Secret | Value | +| --- | --- | +| `R2_ACCESS_KEY_ID` | R2 token access key id | +| `R2_SECRET_ACCESS_KEY` | R2 token secret | +| `DEEPSEEK_API_KEY` | LLM key (DeepSeek platform) | + +**One variable** (not a secret — the R2 endpoint is `https://.r2.cloudflarestorage.com`, +so the account ID is part of an address, and bucket names are not sensitive): + +| Variable | Value | +| --- | --- | +| `R2_ACCOUNT_ID` | Cloudflare account ID | + +Optional, with sane defaults: `LOOP_GH_TOKEN` (fine-grained PAT; unused while +report-only, since `github.token` covers read access) and `R2_BUCKET` (defaults +to the derived `loop-state--`). Both the `R2_ACCOUNT_ID` and +`R2_BUCKET` lookups fall back to the same-named secret, so either store works. + +```bash +gh secret set R2_ACCESS_KEY_ID -R Alex1990/tiny-oss +gh secret set R2_SECRET_ACCESS_KEY -R Alex1990/tiny-oss +gh secret set DEEPSEEK_API_KEY -R Alex1990/tiny-oss +gh variable set R2_ACCOUNT_ID -R Alex1990/tiny-oss +``` + +### Adding another repository + +Per-repo isolation is structural, so onboarding a repo is copy-and-configure — +no shared infrastructure to extend: + +1. Copy `scripts/loop/` and `.github/workflows/loop.yml` into the new repo + (the loop code lives with the repository it drives). +2. Create bucket `loop-state--` and a token scoped to **that** + bucket. A different GitHub organisation with a same-named repository gets a + different bucket automatically. +3. Set the same four items in the new repo (its own token, its own bucket). + No cross-repo credential is shared. + +`R2_PREFIX` and the bucket derivation are already repo-relative, so nothing in +the copied code needs editing. + +Seeding the existing A0 state layer (13 files: 3 tasks, 8 runs, metrics, +SUMMARY) — needs an AWS CLI on the seeding machine, because the bucket is +private and `state/` is gitignored (so the runner cannot seed it from a checkout): + +```bash +# once, locally +winget install Amazon.AWSCLI # or the macOS/Linux equivalent + +R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... \ + node scripts/loop/r2-sync.mjs seed # then `check` +``` + +Locally the bucket is derived from `package.json`'s `repository` field, so the +name matches what CI will use (`loop-state-alex1990-tiny-oss`); `R2_BUCKET` is +only needed to target a different bucket. A token scoped to one bucket also +proves the scoping worked: `aws s3 ls --endpoint-url ` +should be refused. + +`seed` (like `push`) omits `--delete`, so it can only add or overwrite. + +## A1 acceptance (one week; report boundary ⇒ GitHub stays untouched) + +Successor to 05 §8's checklist, rewritten because the drift criterion is +vacuous when nothing is written to GitHub. Source: this file, plus +`state/SUMMARY.md` and the Actions run pages. + +Checked items cite the run that proves them. Note on the drift criterion: every +GitHub action proposed while A1 runs at the report boundary stays unexecuted by +design, so a task whose file says `needs-triage` and whose GitHub item has no +label is **expected**, not drift. What must be judged is whether each proposal +was *correct*, and whether new events produce proposals that match reality. + +- [x] `workflow_dispatch` smoke (`-f smoke=true`): pipeline green end to end + (checkout → pi install → R2 pull → push) with no agent run — run + `34509932312`, 51s; re-run `34511261607` also confirmed the seeded state + layer round-trips (5 tasks / 10 runs / 2 reports / 3 acceptance rows) +- [x] `workflow_dispatch` smoke: a **new** event → run → task file + state + transition correct, **GitHub unchanged** — run `34511902164` + (`task=36 stage=triage`, 80,289 tokens): task claimed → `processing` → + agent triaged → `needs-triage` → task set to `waiting-human` with the + matching label. Verified against GitHub rather than the run's own report: + PR #36 has no labels and no label timeline events, and no item in the + repository carries a loop label written by this run (the single + `ready-for-human` hit is issue #30, closed on 09-08 during A0). +- [ ] Every route has one real execution on Actions: issue triage / bugfix-feature + / deps / external-PR readonly / release preflight (deps and external-PR + were exercised locally during A0; `sweep` ran for real — run `34511392550`) +- [ ] Serial lock: two consecutive dispatches queue, never run concurrently + (run timestamps prove it) +- [ ] Crash path: cancel a job mid-run → task stays `processing` → next sweep + reclaims it by TTL +- [ ] Metrics: a human merge writes one `acceptance` row, no duplicates; + `released` backfills to the intended task +- [x] Cost readable: `tokens` and `durationMs` land in the end row — real runs + recorded 102,860 and 77,850 tokens; the install step prints + `model deepseek-v4-flash is available` and fails the job on a mismatch +- [x] Reports human-readable: the Step Summary alone tells you what happened + (outcome, exit code, token usage, model, proposed actions) without + downloading anything +- [ ] Engine stability: N consecutive headless pi runs without hanging + (2 successful runs so far); exit-code mapping matches the + failure-classification table +- [ ] No drift **and** no incorrect proposal after a week → human decides on L2 + +## Defect log - [x] D9 Terminal tasks reopened on GitHub could not be claimed: with local `status=closed/rejected`, `start --task` refused outright even though the @@ -84,25 +320,139 @@ guard is unchanged). `closed` was semantically dubious) → optional guard. - [x] D4 Help/guidance text still wrote the long `node scripts/loop/run.mjs` command and never mentioned `pnpm loop`. +- [x] D8 `syncGithub` stacked contradictory role labels (needs-info + + ready-for-agent) → strip the other five roles before labelling. +- [x] D10 (A1) `lib/` was gitignored repo-wide (`.gitignore:4`), so a shared + library under `scripts/loop/lib/` would never have been committed — the + runner checkout would have crashed on a missing import. Renamed to + `scripts/loop/shared/` (existing ignore rule left untouched). +- [x] D11 (A1) System-level stages (sweep / retro-scheduled / release preflight) + have no task file, but the orchestrator demanded a `taskId` → the daily + sweep crashed every day. `beginRun`/`finishRun`/`buildPrompt` now support + task-less runs (end row with `taskId: null`, no state transition). +- [x] D12 (A1) `pi` missing on PATH produced a silent EPIPE crash before + classification; now spawn errors are caught and classified as `retry`. +- [x] D13 (A1) The orchestrator logged nothing about the run outcome, so an + Actions log gave no answer without opening the Step Summary. Outcome, + exit code, token count and task transition are now logged. +- [x] D14 (A1) The workflow did not parse at all: job-level `env` referenced + `runner.temp`, but the `runner` context only exists inside steps. GitHub's + only signal was a zero-second failed run titled with the file path, so + `loop.yml` was never registered under its own name. Fixed by moving the + value to the step that needs it, and by replacing the `inputs.*` + expression with `github.event.inputs.*` (null on non-dispatch events). + Now pre-checked with actionlint, which pinpoints the line GitHub omits. +- [x] D15 (A1) Credential/config failures (missing key → pi prints "No models + available") would have classified as an agent failure and pushed the task + into the human inbox. They now classify as `retry`. +- [x] D16 (A1) GitHub write actions always used the `gh issue` subcommand, so + they would have failed on PR tasks ("PRs are a triage surface" means most + of them). The subcommand is now chosen by `task.kind`. +- [x] D17 (A1) `run.mjs` could not import a PR (`--issue` only), while + `entry.mjs` handled PRs — so the local coverage exercise could not create + the task files the A1 sweep expects to find. Added `--pr ` (kind `pr`, + same number space, `OPEN` validation like D1). +- [x] D18 (A1) The job always checked out the default branch, so the manual + smoke entry could not test this workflow's *own* PR: `main` has no + `scripts/loop/` yet, and every dispatch died with `MODULE_NOT_FOUND`. + Default-branch checkout is right for production paths (issues / schedule / + release / `pull_request_target`, and it is what keeps fork code out of the + runner), so the fix is narrower: `workflow_dispatch` checks out + `github.ref`. Dispatch can only target refs in this repository, so the + safety property is unchanged. Found by a real run before it was + documented. +- [x] D19 (A1) `LOOP_MODEL` was set to `deepseek/deepseek-flash`, which is not + in pi's DeepSeek catalogue — the real ids are `deepseek-v4-flash`, + `deepseek-v4-flash-vision-exp` and `deepseek-v4-pro`. pi **silently falls + back** on an unknown `--model` instead of failing, so every run would have + used an unintended model while appearing healthy (the session log records + `modelId: "deepseek-flash"`, so it was accepted, not corrected). Fixed the + id, and the install step now validates `LOOP_MODEL` against + `pi --list-models deepseek` and fails the job on a mismatch. A misspelled + model name must never be a silent fallback. +- [x] D20 (A1) Three comments still pointed at `scripts/loop/lib/` after the D10 + rename to `shared/`, and `run.mjs` named a `run-stage.mjs` that never + existed (the orchestrator is `entry.mjs`). Spotted by a real run. +- [ ] D21 (proposed by a real run, not yet implemented) The dependabot CI + failure is a *reporting* step, not a test failure: `Comment coverage on + PR` gets `gh: Resource not accessible by integration (HTTP 403)` because + GitHub forces the token read-only for Dependabot-triggered workflows. + `permissions:` cannot lift that. Needs a maintainer decision (guard the + step, `continue-on-error`, or move it to a `pull_request_target` job). +- [x] D22 (A1) `renderSummary` had no section for `status: new`, so a task the + sweep had just created was invisible in the human summary — the one item + most in need of attention. Reported by a real sweep run; a `Unprocessed (new)` + section now lists them. +- [x] D23 (A1) System-level runs had no outcome vocabulary of their own. The + first real sweep recorded `closed`, which in the task vocabulary means + "closed without a PR, excluded from the acceptance denominator" — retro + reading end rows would have counted sweep/retro executions as closed + tasks. System runs now use `completed / failed / retry / aborted`, and + `finishRun` rejects a task-scoped outcome for a task-less run. +- [x] D24 (A1) `D18`'s fix covered `workflow_dispatch` but not `pull_request`, + which still checked out the default branch — so a `pr-review` run could + never see the code it was asked to review, and every `pull_request` run on + the A1 PR itself died with `MODULE_NOT_FOUND`. Checkout is now keyed on + trust: `workflow_dispatch` → `github.ref`; **same-repo** PR → its head + branch (only OWNER/MEMBER/COLLABORATOR can push those, and this is what + makes loop's own `loop/-*` PRs reviewable); everything else → the + default branch, which is where fork PRs land so external code is never + checked out. Verified across seven event shapes. +- [x] D25 (A1) Decisions that produce *no* run were logged only into the Step + Summary, so the Actions log showed a route line and then nothing — a skip + was indistinguishable from a silent failure. Every path (skip, inbox + no-op, inbox-only, duplicate event, no-op route) now logs its reason. +- [ ] D26 (A1, open) A task in `waiting-merge` cannot be re-claimed, so a loop + PR that receives a new commit (`synchronize`) is skipped instead of + re-reviewed, and `review_requested`-style re-entry has no path back. + Re-review after new commits is normal in a PR loop; the fix probably + belongs in the `pr-review` route (reopen `waiting-merge` → `ready` on + `synchronize` for loop PRs) rather than in claimability. Left open + deliberately: A1's week should show how often it actually bites. +- [x] D27 (A1) `run.mjs`'s subcommands did not catch errors thrown by the shared + library, so `pnpm loop start --task ` printed a full Node stack + trace instead of a one-line reason — the library throws (correct for the + orchestrator, which needs the failure) while a CLI should not. `main` now + wraps dispatch, so every subcommand reports the same way. Found while + reviewing the translated error messages. + +## Environment facts -All fixed and regression-tested on 2026-09-08 (synthetic tasks + read-only -trials on #27/#28). Also fixed: D8 (syncGithub strips stale role labels before -labeling, preventing needs-info+ready-for-agent stacking). Added: a -stage→outcome whitelist (`STAGE_OUTCOMES`; unknown stages pass everything). - -## Environment facts (A0 related) -- Phase (2026-09-08): **manual execution period** — no automatic triggers; a - human launches every run; after receiving instructions the agent executes per - ops.md + the relevant skill (this session = the engine). Check - `state/SUMMARY.md` and `state/runs/` daily; run one sweep/retro exercise - weekly to distill rules. - -- Engine = this session (opencode omp); gh is authenticated (Alex1990, - repo+workflow scope). -- Direct GitHub connections are unstable (timeouts happened); the proxy - 127.0.0.1:10809 is not running. -- Open issue is now #28 (PR #29 under review); triage/feature wrap-ups write to - GitHub automatically. -- Write boundary (closed-loop version, 2026-09-08): real issues get automatic - labeling + commenting + commit/push/open-PR; merging PRs and npm publishing - stay human. +- Phase (2026-09-10): **A1 implementation landed; A0 remains the running host + until the 2026-09-15 switchover.** Write boundary in A1 = `report`. +- Engine: pi (verified) for A1; A0 runs were driven by an interactive opencode + session (`omp`). `LOOP_ENGINE_CMD` overrides the engine command. +- **Engine install measured on the runner (2026-09-10)**: `npm i -g + @earendil-works/pi-coding-agent@0.85.1` → `added 132 packages in 6s`, + `pi --version` → `0.85.1`. Well inside the job budget. +- **First real end-to-end run (2026-09-10, run 34509379949, 3m05s, all steps + green)**: R2 pull and push both worked against `loop-state-alex1990-tiny-oss` + (bucket name derived correctly from `GITHUB_REPOSITORY`); the agent completed a + system-level triage, wrote its report and `result.json`, and consumed 102,860 + tokens. + - **Report boundary held.** Verified independently rather than from the run's + own summary: PRs #21/#35 still carry no loop label, #21/#35 have zero + comments, and no item in the repository carries `needs-triage`. Everything + the agent wanted to do arrived as a proposal checklist in the report. + - pi's built-in DeepSeek catalogue (credential-gated, printed per run): + `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-v4-pro`. + There is no `deepseek-flash` — see D19. +- **Infrastructure smoke**: `gh workflow run loop.yml -f smoke=true` exercises + checkout → toolchain → pi install → R2 pull → push and **skips the agent + entirely**, so "is the pipeline healthy?" is answerable without spending + tokens or waiting on an LLM. Use it first when onboarding a repository or when + a credential is suspected to have stopped working. Note that an empty `stage` + does **not** produce a cheaper run: GitHub applies the input's `default:` + (`triage`), which starts a real system-level triage — the first smoke of this + workflow cost 102,860 tokens that way, which is why this switch exists. +- `gh` works from this machine (list/view in seconds); the older note about + direct GitHub timeouts and a stopped proxy is stale. +- AWS CLI **v2 is now installed locally** (`C:\Program Files\Amazon\AWSCLIV2`), + matching the runner. Note that a `pip install awscli` does **not** work for + this: it produces a `.cmd` shim, which Node refuses to spawn on Windows + (`spawnSync aws ENOENT`), whereas v2 ships a real `aws.exe`. Open a new + terminal after installing so PATH picks it up. +- Workflow edits can be pre-checked locally with + [`actionlint`](https://github.com/rhysd/actionlint) (a bad `runner` context in + job-level `env` is a parse failure GitHub only reports as "workflow file + issue", with no line number). diff --git a/scripts/loop/entry.mjs b/scripts/loop/entry.mjs new file mode 100644 index 0000000..1f80f8e --- /dev/null +++ b/scripts/loop/entry.mjs @@ -0,0 +1,378 @@ +#!/usr/bin/env node +/** + * Loop runner orchestration entry — the only script the GitHub Actions job calls. + * + * One invocation = one decision: route the event → write inbox / write metrics / run one run. + * All state layer reads and writes go through `shared/state.mjs`; this file only orchestrates + * and reports, and never edits state fields directly. + * + * Invariants: + * - finish is the **single writer**: the agent only writes + * `state/reports/.result.json`, and this file calls finishRun to persist state + * (05 §5.1 lists finish as a run-script ritual). + * - Under the report boundary no GitHub write happens (write actions only enter the + * report as a checklist). + * - Event idempotence: `eventIds` records consumed event keys, a duplicate delivery → no-op. + * + * Environment contract (injected by the workflow): + * LOOP_EVENT_NAME / LOOP_EVENT_ACTION / LOOP_EVENT_JSON / LOOP_REPO + * LOOP_WRITE_LEVEL (report|auto, default report) + * LOOP_MODEL (pi's --model, e.g. deepseek/deepseek-flash) + * LOOP_RUN_TIMEOUT_MS, LOOP_SESSION_DIR, GITHUB_STEP_SUMMARY + */ + +import os from 'node:os'; +import path from 'node:path'; +import { promises as fs } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +import { + makeState, ensureDirs, readJson, readJsonl, writeJson, saveTask, listTasks, + appendAcceptance, beginRun, finishRun, lockExpired, + nowIso, outcomeAllowed, allowedOutcomes, describeActions, +} from './shared/state.mjs'; +import { route, eventKey } from './shared/route.mjs'; +import { buildPrompt, runPi, parseEvents, summarize, classify } from './shared/agent.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const S = makeState(ROOT); + +const log = (m) => console.log(`[loop] ${m}`); +const warn = (m) => console.warn(`[loop] warn: ${m}`); + +/* ------------------------------------------------------------ GitHub read */ + +function ghJson(args) { + const r = spawnSync('gh', args, { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`gh ${args.join(' ')} failed: ${(r.stderr || '').trim()}`); + return JSON.parse(r.stdout); +} + +const GH_FIELDS = 'number,title,body,state,labels,url'; + +/** + * When the task file is missing, create it from GitHub; if it already exists, mirror the + * labels (GitHub wins). + */ +async function prepareTask({ taskId, kind, repo }) { + const existing = await readJson(S.taskFile(taskId)); + if (existing) { + if (kind === 'pr') existing.kind = 'pr'; + return existing; + } + const data = kind === 'pr' + ? ghJson(['pr', 'view', String(taskId), '-R', repo, '--json', `${GH_FIELDS},headRefName,author`]) + : ghJson(['issue', 'view', String(taskId), '-R', repo, '--json', GH_FIELDS]); + const t = { + id: data.number, kind, title: data.title, url: data.url, body: data.body ?? '', + status: 'new', stage: null, labels: (data.labels ?? []).map((l) => l.name), + createdAt: nowIso(), updatedAt: nowIso(), lockedBy: null, decision: null, + agentPlan: null, prs: [], runs: [], timeline: [], eventInbox: [], eventIds: [], + }; + t.timeline.push({ at: t.createdAt, event: 'created', by: 'workflow', detail: `${kind} from GitHub` }); + await writeJson(S.taskFile(t.id), t); + log(`task #${t.id} created (${kind})`); + return t; +} + +const claimability = (t) => { + if (t.status === 'processing') { + return lockExpired(t.lockedBy) + ? { ok: true, resume: true } + : { ok: false, reason: `held by ${t.lockedBy?.runId}` }; + } + return ['new', 'ready', 'waiting-info'].includes(t.status) + ? { ok: true } + : { ok: false, reason: `status=${t.status}` }; +}; + +/* -------------------------------------------------------------- event inbox */ + +function summarizeEvent(ctx) { + const e = ctx.event ?? {}; + const who = e.comment?.user?.login ?? e.sender?.login ?? '?'; + const body = e.comment?.body ?? e.issue?.body ?? e.pull_request?.body ?? ''; + return `${ctx.eventName}.${ctx.action} by ${who}: ${String(body).replace(/\s+/g, ' ').slice(0, 200)}`; +} + +/** + * Follow-up information events: never silently dropped. First land in eventInbox, then decide + * whether to re-run based on task status (05 §3 decision order). + */ +async function handleInbox({ ctx, repo }) { + const key = eventKey(ctx); + const taskId = ctx.event?.issue?.number ?? ctx.event?.pull_request?.number; + const t = await readJson(S.taskFile(taskId)); + if (!t) { + log(`event not inboxed: no task for #${taskId} yet (lifecycle event creates it first)`); + return { state: 'noop', note: `#${taskId} has no matching task, event not inboxed` }; + } + if ((t.eventIds ?? []).includes(key)) { + log(`event already consumed (idempotent skip): ${key}`); + return { state: 'noop', note: `event already consumed (${key})` }; + } + + t.eventInbox = [...(t.eventInbox ?? []), { + id: key, type: `${ctx.eventName}.${ctx.action}`, at: nowIso(), summary: summarizeEvent(ctx), + }]; + t.eventIds = [...(t.eventIds ?? []), key].slice(-50); + await saveTask(S, t); + + // new/ready re-run the original stage; waiting-info re-runs triage; other states await a consumer + if (['new', 'ready'].includes(t.status)) { + return { state: 'run', taskId: t.id, stage: t.stage ?? 'triage', kind: t.kind, note: 're-run' }; + } + if (t.status === 'waiting-info') { + return { + state: 'run', taskId: t.id, stage: 'triage', kind: t.kind, note: 're-triage on new info', + }; + } + log(`event inboxed, no run produced: task #${t.id} status=${t.status} (awaiting a consumer)`); + return { + state: 'inbox-only', + note: `task status=${t.status}, event inboxed and awaiting consumption`, + }; +} + +/* ---------------------------------------------------------------- metrics */ + +async function handleMetrics({ decision }) { + const row = { at: nowIso(), ...decision.metrics }; + if (row.event === 'released' && !row.taskId) { + const cands = (await listTasks(S)) + .filter((t) => ['waiting-merge', 'accepted'].includes(t.status)) + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); + if (cands.length) { + row.taskId = cands[0].id; + row.backfilledFrom = 'most-recent-waiting-merge'; + } + } + const wrote = await appendAcceptance(S, row); + return { state: 'metrics', wrote, row }; +} + +/* ------------------------------------------------------------------ run */ + +async function doRun({ decision, ctx, repo, writeLevel }) { + const stage = decision.stage; + const isSystem = !decision.taskId; + let task = null; + + if (!isSystem) { + const kind = decision.kind ?? 'issue'; + task = await prepareTask({ taskId: decision.taskId, kind, repo }); + const claim = claimability(task); + if (!claim.ok) { + // A skip must be logged: during an event storm, a human must see at a glance in the + // Actions log that "this event was correctly skipped", not think it silently failed. + log(`skip: task #${task.id} not claimable (${claim.reason}) — event handled idempotently`); + return { state: 'skipped', note: `task #${task.id} not claimable: ${claim.reason}` }; + } + if (claim.resume) warn(`task #${task.id} lock expired, taking over and resuming`); + } + + const rid = await beginRun(S, task, stage, { + sandbox: 'actions-ubuntu-24.04', + trigger: `${ctx.eventName}.${ctx.action || '(none)'}`, + model: process.env.LOOP_MODEL ?? null, + }); + log(`run ${rid} started (` + + `${task ? `task #${task.id}, ` : 'system-level, '}stage=${stage}` + + `${decision.mode === 'readonly' ? ', readonly' : ''})`); + + const prompt = buildPrompt({ + task, stage, runId: rid, writeLevel, mode: decision.mode, repo, + lead: [ + `trigger: ${ctx.eventName}.${ctx.action || '(none)'} — ${decision.reason}`, + decision.loopTask ? `linked issue: #${decision.loopTask}` : null, + ].filter(Boolean), + }); + + const sessionDir = process.env.LOOP_SESSION_DIR ?? path.join(os.tmpdir(), 'loop-pi', rid); + const timeoutMs = Number(process.env.LOOP_RUN_TIMEOUT_MS ?? 55 * 60 * 1000); + + const res = await runPi({ + prompt, cwd: ROOT, sessionDir, + model: process.env.LOOP_MODEL, + timeoutMs, + log, + }); + const events = parseEvents(res.stdout); + const { usage, stopReason, toolCalls, turns } = summarize(events); + + // the agent's result file (the orchestration layer is the single writer for finish) + const result = await readJson(path.join(S.reportsDir, `${rid}.result.json`)); + const cls = classify({ code: res.code, signal: res.signal, stderr: res.stderr, timedOut: res.timedOut, stopReason }); + + let outcome; let note; + if (result?.outcome && outcomeAllowed(stage, result.outcome)) { + outcome = result.outcome; + note = result.note ?? `agent decision (exit=${res.code})`; + } else if (result?.outcome) { + outcome = 'failed'; + note = `agent returned outcome=${result.outcome}, but stage=${stage} does not allow it ` + + `(allowed: ${allowedOutcomes(stage).join(', ')})`; + warn(note); + } else { + outcome = cls.exit === 1 ? 'failed' : 'retry'; + note = `${cls.reason} (no result file)`; + warn(`no ${rid}.result.json found — ${note}`); + } + + const { task: done, actions } = await finishRun(S, { + runId: rid, outcome, note, + comment: result?.comment ?? null, + decision: result?.decision ?? null, + pr: result?.pr ?? null, + tokens: usage?.totalTokens ?? null, + writeLevel, log, warn, + }); + + log(`run ${rid} finished: outcome=${outcome} (exit ${cls.exit})` + + `${usage?.totalTokens ? `, tokens=${usage.totalTokens}` : ''}`); + if (actions.length) { + log(writeLevel === 'auto' + ? `GitHub actions executed: ${actions.length}` + : `GitHub actions not executed (report boundary): ${actions.length} ` + + 'pending human review, see the report and Step Summary'); + } + if (done) log(`task #${done.id} → status=${done.status}${done.labels?.length ? `, label=${done.labels.join(',')}` : ''}`); + + return { + state: 'run', taskId: done?.id ?? decision.taskId ?? null, runId: rid, stage, outcome, + exit: cls.exit, actions, usage, toolCalls, turns, sessionDir, system: isSystem, + reportFile: path.join(S.reportsDir, `${rid}.md`), + }; +} + +/* --------------------------------------------------------- Step Summary */ + +async function writeStepSummary(md) { + const f = process.env.GITHUB_STEP_SUMMARY; + if (!f) { log('(no GITHUB_STEP_SUMMARY, skipping)'); return; } + await fs.appendFile(f, md + '\n', 'utf8'); +} + +async function readTextSafe(file) { + try { return await fs.readFile(file, 'utf8'); } catch { return ''; } +} + +function renderSummary({ decision, result, writeLevel }) { + const L = [`## Loop — ${decision.action}`, '']; + L.push(`- event: \`${decision.reason}\``); + if (result?.runId) { + const who = result.system ? 'system-level' : `task #${result.taskId}`; + L.push(`- run: \`${result.runId}\` · ${who} · stage \`${result.stage}\``); + L.push(`- **outcome: ${result.outcome}** (exit ${result.exit})`); + if (result.usage) { + L.push(`- usage: in ${result.usage.input ?? '-'} / out ${result.usage.output ?? '-'}` + + ` / total ${result.usage.totalTokens ?? '-'} tokens · $${result.usage.cost?.total ?? '-'}` + + ` · ${result.toolCalls} tool calls, ${result.turns} turns`); + } + } else if (result?.note) { + L.push(`- ${result.state}: ${result.note}`); + } + if (result?.actions?.length) { + const head = writeLevel === 'auto' ? '### GitHub actions executed' : '### Proposed GitHub actions (report boundary — NOT executed)'; + L.push('', head, ''); + for (const d of describeActions(result.actions)) L.push(`- [${writeLevel === 'auto' ? 'x' : ' '}] ${d}`); + } + return L.join('\n'); +} + +/* ----------------------------------------------------------------- main */ + +async function main() { + const eventName = process.env.LOOP_EVENT_NAME ?? 'workflow_dispatch'; + const action = process.env.LOOP_EVENT_ACTION ?? ''; + const event = JSON.parse(process.env.LOOP_EVENT_JSON || '{}'); + const repo = process.env.LOOP_REPO ?? 'Alex1990/tiny-oss'; + // Write boundary: the workflow passes LOOP_EXECUTE_WRITES (the workflow_dispatch dry-run + // switch), but an explicit LOOP_WRITE_LEVEL override wins (used for local debugging). + const writeLevel = process.env.LOOP_WRITE_LEVEL + ?? (process.env.LOOP_EXECUTE_WRITES === 'true' ? 'auto' : 'report'); + + await ensureDirs(S); + const ctx = { eventName, action, event }; + const decision = route(ctx); + log(`event=${eventName}.${action || '(none)'} → ${decision.action} ` + + `(${decision.reason}) · writeLevel=${writeLevel}`); + + // Smoke mode: verify infrastructure only (checkout / toolchain / pi install / R2 pull+push) + // and never start an agent. A1's manual smoke entry point must answer two questions without + // spending tokens or waiting for an LLM: is the pipeline alive, and is the freshly pulled + // state layer correct? + if (process.env.LOOP_SMOKE === 'true') { + log('smoke mode: skipping the agent, verifying infrastructure only'); + const tasks = await listTasks(S); + const byStatus = {}; + for (const t of tasks) byStatus[t.status] = (byStatus[t.status] ?? 0) + 1; + const acc = await readJsonl(S.acceptanceFile); + const runFiles = (await fs.readdir(S.runsDir).catch(() => [])).filter((f) => f.endsWith('.jsonl')); + const reports = (await fs.readdir(S.reportsDir).catch(() => [])).filter((f) => f.endsWith('.md')); + const summaryExists = await fs.access(S.summaryFile).then(() => true, () => false); + + const overview = [ + `- tasks: ${tasks.length}${tasks.length ? ` (${Object.entries(byStatus).map(([k, v]) => `${k}=${v}`).join(', ')})` : ''}`, + `- runs: ${runFiles.length}`, + `- reports: ${reports.length}`, + `- acceptance rows: ${acc.length}`, + `- SUMMARY.md: ${summaryExists ? 'present' : 'MISSING'}`, + ]; + for (const l of overview) log(l.replace(/^- /, ' ')); + + const expectsState = process.env.LOOP_EXPECT_TASKS; + if (expectsState && String(tasks.length) !== String(expectsState)) { + await writeStepSummary(`## Loop — smoke FAILED\n\nExpected ${expectsState} tasks in the state layer, found ${tasks.length}. The R2 pull is not returning what was seeded.\n`); + throw new Error(`state layer has ${tasks.length} tasks, expected ${expectsState}`); + } + + await writeStepSummary([ + '## Loop — smoke (infrastructure only)', '', + `- event: \`${eventName}.${action || '(none)'}\``, + `- routing decision (not executed): \`${decision.action}\` — ${decision.reason}`, + `- write level: \`${writeLevel}\``, + '- R2: pulled and pushed by the surrounding workflow steps', + '- agent: **skipped** (no tokens spent)', + '', + '### State layer as pulled', + '', + ...overview, + ].join('\n')); + return 0; + } + + let result = { state: 'noop', note: decision.reason }; + + if (decision.action === 'run') { + result = await doRun({ decision, ctx, repo, writeLevel }); + } else if (decision.action === 'inbox') { + const r = await handleInbox({ ctx, repo }); + if (r.state === 'run') { + result = await doRun({ + decision: { + ...decision, taskId: r.taskId, stage: r.stage, kind: r.kind, + reason: `${decision.reason} (${r.note})`, + }, + ctx, repo, writeLevel, + }); + } else result = r; + } else if (decision.action === 'metrics') { + result = await handleMetrics({ decision }); + log(result.wrote + ? `metrics written: ${JSON.stringify(result.row)}` + : 'metrics already present, skipped (idempotent)'); + } + + await writeStepSummary(renderSummary({ decision, result, writeLevel })); + return 0; // noop / inbox-only / metrics / a completed run all exit successfully +} + +main() + .then((code) => process.exit(code)) + .catch(async (e) => { + console.error(`[loop] error: ${e?.stack ?? e}`); + await writeStepSummary(`## Loop — FAILED\n\n\`\`\`\n${e?.message ?? e}\n\`\`\`\n`).catch(() => {}); + process.exit(1); + }); diff --git a/scripts/loop/r2-sync.mjs b/scripts/loop/r2-sync.mjs new file mode 100644 index 0000000..c35ae73 --- /dev/null +++ b/scripts/loop/r2-sync.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * R2 state layer sync CLI — invoked as a standalone workflow step (independent of + * whether the agent has finished running). + * + * Usage: node scripts/loop/r2-sync.mjs pull|push|seed|check + * pull job start: remote → local state/ (a failure MUST abort, otherwise push + * would overwrite the remote with an empty local state) + * push job end (if: always()): local state/ → remote (no --delete, R2 has no + * versioning) + * seed one-off seed of the state layer already present locally + * check connectivity and credential self-check + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { makeState } from './shared/state.mjs'; +import { pull, push, seed, check } from './shared/r2.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const S = makeState(ROOT); +const cmd = process.argv[2]; + +try { + if (cmd === 'pull') { console.log(`[loop:r2] pull → ${S.root}`); pull(S.root); } + else if (cmd === 'push') { console.log(`[loop:r2] push ← ${S.root}`); push(S.root); } + else if (cmd === 'seed') { console.log(`[loop:r2] seed ← ${S.root}`); seed(S.root); } + else if (cmd === 'check') { check(); } + else { + console.log('usage: node scripts/loop/r2-sync.mjs pull|push|seed|check'); + process.exit(1); + } +} catch (e) { + console.error(`[loop:r2] error: ${e.message}`); + process.exit(1); +} diff --git a/scripts/loop/run.mjs b/scripts/loop/run.mjs index f5a9276..05633cb 100644 --- a/scripts/loop/run.mjs +++ b/scripts/loop/run.mjs @@ -1,444 +1,251 @@ #!/usr/bin/env node /** - * tiny-oss Loop A0 本地手动宿主(自建形态最小适配) + * tiny-oss Loop local host (CLI) * - * 目标:在本机手动试跑 Loop run 契约([[tiny-oss-runtime]] §5),不依赖 - * GitHub Actions / R2。状态层布局 = memory §3.1(tasks/ runs/ locks/ - * metrics/ SUMMARY.md),位于仓库根 `state/`(已 gitignore)。 + * All state layer primitives come from `./shared/state.mjs` (shared with the + * runner orchestrator entry.mjs, so there is a single implementation). This + * file only handles the manually driven command-line ceremony. * - * 用法(Windows 与 POSIX 一致;`pnpm loop` = 本文件,见 package.json): + * Usage (`pnpm loop` = this file, see package.json): * pnpm loop start [--stage ] [--task ] - * [--new --title "..." --body "..."] # 新建本地任务 - * [--issue ] # 导入 GitHub issue(需 gh,只读) + * [--new --title "..." --body "..."] # create a local synthetic task + * [--issue ] # import a GitHub issue (gh, read-only) * pnpm loop checkpoint --run --note "..." * pnpm loop end --run --outcome [--comment "..."] [--label ] - * [--no-github] # 或 --task 反查未收尾 run(D2) + * [--task ] # look up an unfinished run (D2) * pnpm loop summary | view [--run | --task ] * - * outcome 取值与状态转移(ops.md Labels→task state): - * triaged → ready + ready-for-agent (triage 判 bug/feature) + * Write level (--write-level | LOOP_WRITE_LEVEL, default report): + * report only writes the state layer + report: GitHub write actions are + * presented solely as a "pending human action" checklist (A1 semantics) + * auto performs labelling/commenting/closing (A0 delegated semantics; the + * norm once we move to L2) + * + * outcome values and state transitions (ops.md "Labels → task state"): + * triaged → ready + ready-for-agent * needs-info → waiting-info + needs-info - * needs-triage → waiting-human + needs-triage (cannot-handle/低置信/failed) + * needs-triage → waiting-human + needs-triage * pr-opened → waiting-merge + ready-for-human - * D9(GitHub 重开转移):start 领取时若本地任务为终态(closed/rejected)而 GitHub - * issue 已重开为 OPEN,自动重置 ready + 清 decision + timeline 记 reopened 再领取, - * 使 reopened 事件按 ops.md 触发映射回到 triage。 - * 远程写链路:真实 issue(有 url)收尾自动打标/评论/关闭(syncGithub); - * 本地合成任务自动跳过,--no-github 强制跳过。引擎(headless)自动跑是 A1+ 的事, - * 本脚本只负责仪式(开场领取/过程记录/收尾回写)。 + * closed → closed None + * rejected → rejected None + * accepted → accepted None + * failed → waiting-human + needs-triage + * + * D9: at claim time, if the local task is in a terminal state (closed/rejected) + * but the GitHub issue has been reopened as OPEN, automatically reset to ready + + * clear decision + record reopened in the timeline, then claim, back to triage. */ -import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { promises as fs } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; +import { + makeState, ensureDirs, loadTask, saveTask, nextTaskId, listTasks, + readRunLines, appendRunRow, readJson, writeJson, + nowIso, parseArgs, renderSummary, + OUTCOME_MAP, lockExpired, describeActions, + beginRun, finishRun, +} from './shared/state.mjs'; + const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); -const STATE = process.env.STATE_DIR ? path.resolve(process.env.STATE_DIR) : path.join(ROOT, 'state'); -const DIRS = { tasks: 'tasks', runs: 'runs', locks: 'locks', metrics: 'metrics' }; +const S = makeState(ROOT); -const nowIso = () => new Date().toISOString(); -const rand4 = () => Math.random().toString(36).slice(2, 6).padEnd(4, '0'); -const pad = (n) => String(n).padStart(2, '0'); -const runId = () => { - const d = new Date(); - return `r-${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${rand4()}`; -}; - -async function ensureDirs() { - await Promise.all(Object.values(DIRS).map((d) => fs.mkdir(path.join(STATE, d), { recursive: true }))); -} -const dir = (k) => path.join(STATE, DIRS[k]); - -async function readJson(file) { - try { - return JSON.parse(await fs.readFile(file, 'utf8')); - } catch (e) { - if (e.code === 'ENOENT') return null; - throw e; - } -} -async function writeJson(file, obj) { - await fs.writeFile(file, JSON.stringify(obj, null, 2) + '\n', 'utf8'); -} -async function appendRunRow(runIdVal, row) { - const file = path.join(dir('runs'), `${runIdVal}.jsonl`); - await fs.appendFile(file, JSON.stringify(row) + '\n', 'utf8'); -} -async function readRunLines(runIdVal) { - try { - const raw = await fs.readFile(path.join(dir('runs'), `${runIdVal}.jsonl`), 'utf8'); - return raw.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)); - } catch (e) { - if (e.code === 'ENOENT') return []; - throw e; - } -} -const taskFile = (id) => path.join(dir('tasks'), `${id}.json`); +const WRITE_LEVELS = ['report', 'auto']; function fail(msg) { console.error(`[loop] error: ${msg}`); process.exit(1); } -function parseArgs(argv) { - const out = { _: [] }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a.startsWith('--')) { - const eq = a.indexOf('='); - if (eq > 0) { - out[a.slice(2, eq)] = a.slice(eq + 1); - } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { - out[a.slice(2)] = argv[++i]; - } else { - out[a.slice(2)] = true; - } - } else { - out._.push(a); - } - } - return out; -} - -/** 下一本地任务 id:数字文件名最大 +1;空目录从 9000 起(避开 GitHub 编号段)。 */ -async function nextTaskId() { - const files = await fs.readdir(dir('tasks')); - const nums = files - .map((f) => /^(\d+)\.json$/.exec(f)?.[1]) - .filter(Boolean) - .map(Number); - return nums.length ? Math.max(...nums) + 1 : 9000; -} - -async function loadTask(id) { - const t = await readJson(taskFile(id)); - if (!t) fail(`task #${id} 不存在(state/tasks/${id}.json)`); - return t; -} - -async function saveTask(t) { - t.updatedAt = nowIso(); - await writeJson(taskFile(t.id), t); +function resolveWriteLevel(args) { + const wl = args['write-level'] ?? process.env.LOOP_WRITE_LEVEL ?? 'report'; + if (!WRITE_LEVELS.includes(wl)) fail(`--write-level must be ${WRITE_LEVELS.join('|')} ` + + `(got: ${wl})`); + return wl; } /* ---------------------------------------------------------------- start */ async function cmdStart(args) { - const { stage, task, 'new': isNew, title, body, issue, url } = args; - await ensureDirs(); + const { stage, task, 'new': isNew, title, body, issue, pr, url } = args; + await ensureDirs(S); const s = stage ?? 'triage'; - if (!stage) console.log('[loop] --stage 缺省,默认 triage'); + if (!stage) console.log('[loop] --stage omitted, defaulting to triage'); let t; - if (isNew || issue) { - if (isNew && !title) fail('--new 需要 --title'); + if (isNew || issue || pr) { + if (isNew && !title) fail('--new requires --title'); let id; + let kind = 'issue'; let extra = {}; - if (issue) { + if (pr) { + // PRs and issues share the triage surface (issue-tracker.md): kind keeps + // them apart, the numbering space is the same. + const gh = spawnSync('gh', ['pr', 'view', String(pr), '--json', + 'number,title,body,state,labels,url'], { encoding: 'utf8' }); + if (gh.status !== 0) fail(`gh pr view failed (gh missing or unauthenticated?): ` + + `${(gh.stderr || '').trim()}`); + const it = JSON.parse(gh.stdout); + if (it.state !== 'OPEN') fail(`PR #${it.number} is ${it.state}; ` + + `only OPEN PRs can be imported (D1)`); + id = it.number; + kind = 'pr'; + extra = { url: it.url }; + t = { + id, kind, title: it.title, url: it.url, body: it.body, + status: 'new', stage: null, labels: (it.labels || []).map((l) => l.name), + createdAt: nowIso(), updatedAt: nowIso(), lockedBy: null, decision: null, + agentPlan: null, prs: [id], runs: [], timeline: [], eventInbox: [], eventIds: [], + }; + } else if (issue) { const gh = spawnSync('gh', ['issue', 'view', String(issue), '--json', 'number,title,body,state,labels,url'], { encoding: 'utf8' }); - if (gh.status !== 0) fail(`gh issue view 失败(gh 未装或无认证?):${(gh.stderr || '').trim()}`); + if (gh.status !== 0) fail(`gh issue view failed (gh missing or unauthenticated?): ` + + `${(gh.stderr || '').trim()}`); const it = JSON.parse(gh.stdout); - if (it.state !== 'OPEN') fail(`#${it.number} 状态=${it.state},仅 OPEN 的 issue 可导入(D1)`); + if (it.state !== 'OPEN') fail(`#${it.number} is ${it.state}; ` + + `only OPEN issues can be imported (D1)`); id = it.number; extra = { url: it.url }; t = { - id, kind: 'issue', title: it.title, url: it.url, body: it.body, + id, kind, title: it.title, url: it.url, body: it.body, status: 'new', stage: null, labels: (it.labels || []).map((l) => l.name), createdAt: nowIso(), updatedAt: nowIso(), lockedBy: null, decision: null, agentPlan: null, prs: [], runs: [], timeline: [], }; } else { - id = await nextTaskId(); + id = await nextTaskId(S); t = { - id, kind: 'issue', title, url: url ?? null, body: body ?? '', + id, kind, title, url: url ?? null, body: body ?? '', status: 'new', stage: null, labels: [], createdAt: nowIso(), updatedAt: nowIso(), lockedBy: null, decision: null, agentPlan: null, prs: [], runs: [], timeline: [], }; extra = { synthetic: true }; } - const file = taskFile(id); - if (await readJson(file)) fail(`任务 #${id} 已存在,拒绝覆盖(${file});重跑 triage/处理请用 start --task ${id} [--stage ](D3)`); + if (await readJson(S.taskFile(id))) { + fail(`task #${id} already exists, refusing to overwrite (${S.taskFile(id)}); ` + + `rerun: start --task ${id} [--stage ] (D3)`); + } t.timeline.push({ at: t.createdAt, event: 'created', by: 'manual', ...extra }); - await writeJson(file, t); - console.log(`[loop] 任务 #${id} 已创建(${isNew ? '本地合成' : 'GitHub 导入'})`); + await writeJson(S.taskFile(id), t); + console.log(`[loop] task #${id} created (` + + `${isNew ? 'local synthetic' : pr ? 'GitHub PR import' : 'GitHub import'})`); } else { - t = await loadTask(task ?? fail('--task 或 --new/--issue 必填')); + t = await loadTask(S, task ?? fail('--task or --pr/--issue/--new is required')); } - // 领取检查:new/ready/waiting-info 可领;processing 活锁拒领、死锁(TTL>1h)可覆盖; - // 终态(closed/rejected)任务若 GitHub issue 已重开(OPEN)→ 自动重置为 ready 再领取(D9) + // Claim check: new/ready/waiting-info are claimable; a live processing lock + // refuses the claim, an expired one can be overridden; a terminal task whose + // GitHub issue was reopened → reset to ready and claim again (D9) const claimable = ['new', 'ready', 'waiting-info']; if (t.status === 'processing') { - const lk = t.lockedBy; - const dead = lk && Date.now() - Date.parse(lk.since) > (lk.ttl || 3600) * 1000; - if (!dead) fail(`任务 #${t.id} 被 ${lk?.runId ?? '?'} 持有(status=processing),请先结束或等 TTL 过期`); - console.warn(`[loop] warn: 任务 #${t.id} 的锁已过期(${lk?.runId}),本次接管续跑`); + if (!lockExpired(t.lockedBy)) { + fail(`task #${t.id} is held by ${t.lockedBy?.runId ?? '?'} (status=processing), ` + + `finish it or wait for the TTL to expire`); + } + console.warn(`[loop] warn: task #${t.id} lock expired ` + + `(${t.lockedBy?.runId}), taking over to resume`); } else if (!claimable.includes(t.status) && t.url) { - const repo = ghRepoOf(t.url); + const repo = repoOf(t.url); const ghState = repo ? spawnSync('gh', ['issue', 'view', String(t.id), '-R', repo, '--json', 'state', '-q', '.state'], { encoding: 'utf8' }) : null; - const ghOpen = ghState && ghState.status === 0 && ghState.stdout.trim() === 'OPEN'; - if (ghOpen) { - console.warn(`[loop] warn: 任务 #${t.id} 本地 status=${t.status},GitHub issue 已重开(OPEN)→ 重置为 ready 后领取(D9)`); + if (ghState && ghState.status === 0 && ghState.stdout.trim() === 'OPEN') { + console.warn(`[loop] warn: task #${t.id} local status=${t.status}, GitHub issue ` + + `reopened → reset to ready (D9)`); t.status = 'ready'; t.decision = null; - t.timeline.push({ at: nowIso(), event: 'reopened', by: 'github', detail: 'GitHub issue 重开 → 终态重置为 ready(D9)' }); + t.timeline.push({ at: nowIso(), event: 'reopened', by: 'github', + detail: 'GitHub issue reopened → terminal state reset to ready (D9)' }); } } if (!claimable.includes(t.status)) { - fail(`任务 #${t.id} status=${t.status} 不可领取(可领: ${claimable.join('/')};GitHub 已重开的终态任务会自动放行)`); + fail(`task #${t.id} status=${t.status} is not claimable (claimable: ${claimable.join('/')}); ` + + `terminal tasks whose GitHub issue was reopened are let through`); } - const rid = runId(); - const at = nowIso(); - t.status = 'processing'; - t.stage = s; - t.lockedBy = { runId: rid, since: at, ttl: 3600 }; - t.runs.push(rid); - t.timeline.push({ at, event: 'claimed', by: rid, detail: `stage=${s}` }); - await saveTask(t); - await fs.writeFile(path.join(dir('locks'), `${t.id}-${s}.lock`), - JSON.stringify({ runId: rid, taskId: t.id, since: at, ttl: 3600 }, null, 2) + '\n', 'utf8'); - await appendRunRow(rid, { - runId: rid, taskId: t.id, kind: t.kind, stage: s, - event: 'start', at, sandbox: `local:${process.platform}`, model: null, trigger: 'manual', - }); + const rid = await beginRun(S, t, s, { sandbox: `local:${process.platform}`, trigger: 'manual' }); - console.log(`[loop] run 开始: ${rid}`); - console.log(`[loop] task #${t.id}(stage=${s})已领取 → state/tasks/${t.id}.json`); - console.log('[loop] 开场仪式:先读 AGENTS.md → docs/agents/ops.md → 对应 skill:'); + console.log(`[loop] run started: ${rid}`); + console.log(`[loop] task #${t.id} (stage=${s}) claimed → ` + + `${path.relative(ROOT, S.taskFile(t.id))}`); + console.log('[loop] opening ritual: read AGENTS.md → docs/agents/ops.md → the matching skill:'); const skillFile = path.join(ROOT, 'skills', s, 'SKILL.md'); - try { - await fs.access(skillFile); - console.log(`[loop] skills/${s}/SKILL.md`); - } catch { - console.log(`[loop] skills/${s}/SKILL.md 不存在(该 stage 无专属 skill;流程用到 verify/review 时读对应 SKILL.md)`); - } - console.log('[loop] 过程可记 checkpoint;完成后执行:'); - console.log(` pnpm loop end --run ${rid} --outcome [--comment "..."] [--note "..."]`); + const hasSkill = await fs.access(skillFile).then(() => true, () => false); + console.log(hasSkill + ? `[loop] skills/${s}/SKILL.md` + : `[loop] skills/${s}/SKILL.md not found (no skill for this stage; ` + + `read the matching one when you reach verify/review)`); + console.log(`[loop] write level: ${resolveWriteLevel(args)} ` + + `(report = only writes the state layer and report, GitHub write actions are only listed)`); + console.log('[loop] checkpoints can be recorded along the way; when done run:'); + console.log(` pnpm loop end --run ${rid} --outcome <${Object.keys(OUTCOME_MAP).join('|')}> [--comment "..."] [--note "..."]`); } -/* --------------------------------------------------------------- end */ - -const OUTCOME_MAP = { - 'triaged': { status: 'ready', label: 'ready-for-agent' }, - 'needs-info': { status: 'waiting-info', label: 'needs-info' }, - 'needs-triage': { status: 'waiting-human', label: 'needs-triage' }, - 'pr-opened': { status: 'waiting-merge', label: 'ready-for-human' }, - 'closed': { status: 'closed', label: null }, - 'rejected': { status: 'rejected', label: null }, - 'accepted': { status: 'accepted', label: null }, - 'failed': { status: 'waiting-human', label: 'needs-triage' }, -}; - -/* stage → 允许的成功 outcome 白名单(D5 防呆);未列出的 stage 全放行 */ -const COMMON_OUTCOMES = ['failed', 'rejected', 'accepted']; -const STAGE_OUTCOMES = { - triage: ['triaged', 'needs-info', 'needs-triage', 'closed'], - bugfix: ['pr-opened'], - feature: ['pr-opened'], - deps: ['pr-opened', 'needs-triage'], - security: ['pr-opened', 'needs-triage'], -}; -function outcomeAllowed(stage, outcome) { - const extra = STAGE_OUTCOMES[stage]; - if (!extra) return true; - return COMMON_OUTCOMES.includes(outcome) || extra.includes(outcome); -} +/* ------------------------------------------------------------------ end */ async function cmdEnd(args) { - const { run, task, outcome, note, comment, label, decision, pr, 'no-github': noGithub } = args; + const wl = resolveWriteLevel(args); let rid = run; if (!rid) { - // D2:--task 反查该任务未收尾的 run - if (!task) fail('--run 或 --task (反查未收尾 run)必填'); - const t0 = await loadTask(task); + if (!task) fail('--run or --task (look up an unfinished run) is required'); + const t0 = await loadTask(S, task); const open = []; for (const r of [...t0.runs].reverse()) { - const ls = await readRunLines(r); + const ls = await readRunLines(S, r); if (ls.length && !ls.some((l) => l.event === 'end')) open.push(r); } - if (!open.length) fail(`任务 #${task} 无未收尾的 run`); - if (open.length > 1) fail(`任务 #${task} 有多个未收尾 run(${open.join(', ')}),请用 --run 指定`); + if (!open.length) fail(`task #${task} has no unfinished run`); + if (open.length > 1) fail(`task #${task} has multiple unfinished runs ` + + `(${open.join(', ')}); pick one with --run`); rid = open[0]; - console.log(`[loop] 反查到未收尾 run: ${rid}`); - } - if (!outcome || !OUTCOME_MAP[outcome]) { - fail(`--outcome 必填且 ∈ {${Object.keys(OUTCOME_MAP).join(', ')}}`); - } - const lines = await readRunLines(rid); - if (!lines.length) fail(`run ${rid} 不存在(state/runs/${rid}.jsonl)`); - if (lines.some((l) => l.event === 'end')) fail(`run ${rid} 已有 end 行,重复收尾被拒绝(幂等)`); - const start = lines[0]; - if (!outcomeAllowed(start.stage, outcome)) { - fail(`stage=${start.stage} 不允许 outcome=${outcome}(D5;该 stage 允许: ${[...COMMON_OUTCOMES, ...(STAGE_OUTCOMES[start.stage] ?? [])].join(', ')})`); + console.log(`[loop] looked up unfinished run: ${rid}`); } - const t = await loadTask(start.taskId); - const at = nowIso(); - const durationMs = Date.now() - Date.parse(start.at); - const m = OUTCOME_MAP[outcome]; - - await appendRunRow(rid, { runId: rid, event: 'end', at, outcome, durationMs, tokens: null, note: note ?? null, pr: pr ?? undefined }); - - if (decision) { - try { - t.decision = JSON.parse(decision); - } catch { - fail(`--decision 须为 JSON,如 '{"verdict":"feature","confidence":"high","reason":"..."}'`); + try { + const { task: t, actions, applied } = await finishRun(S, { + runId: rid, outcome, note, comment, label, decision, pr, + writeLevel: wl, noGithub, + log: console.log, warn: console.warn, + }); + const m = OUTCOME_MAP[outcome]; + console.log(`[loop] run ${rid} finish: outcome=${outcome}`); + console.log(`[loop] task #${t.id} → status=${t.status}${m.label ? `, label=${m.label}` : ''}`); + if (actions.length) { + if (wl === 'auto') { + console.log(`[loop] GitHub write actions applied ${applied.length}/${actions.length}`); + } else { + console.log('[loop] write level=report: GitHub write actions awaiting human execution'); + for (const d of describeActions(actions)) console.log(` - ${d}`); + console.log(`[loop] appended to report: ` + + `${path.relative(ROOT, path.join(S.reportsDir, `${rid}.md`))}`); + } + } else if (noGithub) { + console.log('[loop] --no-github: skipping GitHub write actions'); } + console.log('[loop] SUMMARY.md refreshed'); + } catch (e) { + fail(e.message); } - if (pr) { - const n = Number(pr); - if (!Number.isInteger(n) || n <= 0) fail(`--pr 须为 PR 编号数字`); - if (!t.prs.includes(n)) t.prs.push(n); - } - t.status = m.status; - t.labels = m.label ? [m.label] : []; - t.timeline.push({ at, event: 'ended', by: rid, detail: `outcome=${outcome}${note ? ` — ${note}` : ''}` }); - await saveTask(t); - const lockFile = path.join(dir('locks'), `${t.id}-${t.stage}.lock`); - await fs.rm(lockFile, { force: true }); - console.log(`[loop] run ${rid} 收尾: outcome=${outcome}`); - console.log(`[loop] task #${t.id} → status=${t.status}${m.label ? `, label=${m.label}` : ''}`); - - if (!noGithub) await syncGithub(t, outcome, { label: label ?? m.label, comment }); - await renderSummary(); - console.log('[loop] SUMMARY.md 已刷新'); } -/* GitHub 写同步:真实 issue 任务收尾时打标/评论/关闭(自动写链路)。 - 本地合成任务(url 为空)自动跳过;gh 写失败只 warn,不回滚本地状态。 */ - -function ghRepoOf(url) { - const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+/.exec(url ?? ''); +function repoOf(url) { + const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/\d+/.exec(url ?? ''); return m ? `${m[1]}/${m[2]}` : null; } -async function syncGithub(t, outcome, { label, comment }) { - if (!t.url) return; // 本地合成任务 - const repo = ghRepoOf(t.url); - if (!repo) { - console.warn(`[loop] warn: 无法从 url 解析 repo,跳过 GitHub 同步: ${t.url}`); - return; - } - const gh = (a) => spawnSync('gh', a, { encoding: 'utf8' }); - if (label) { - // 先移除其它五角色标签(triage-labels:禁止叠加矛盾角色),再打新标 - const ROLE_LABELS = ['needs-triage', 'needs-info', 'ready-for-agent', 'ready-for-human', 'wontfix']; - const cur = gh(['issue', 'view', String(t.id), '-R', repo, '--json', 'labels', '-q', '.labels[].name']); - const stale = cur.status === 0 - ? cur.stdout.trim().split('\n').filter(Boolean).filter((l) => ROLE_LABELS.includes(l) && l !== label) - : []; - const a = ['issue', 'edit', String(t.id), '-R', repo, '--add-label', label]; - for (const s of stale) a.push('--remove-label', s); - const r = gh(a); - if (r.status !== 0) console.warn(`[loop] warn: 打标 ${label} 失败: ${(r.stderr || '').trim()}`); - else console.log(`[loop] gh: #${t.id} +label ${label}${stale.length ? `, -label ${stale.join(', ')}` : ''}`); - } - if (outcome === 'closed') { - const body = comment ?? note ?? 'Closed by the loop.'; - const r = gh(['issue', 'close', String(t.id), '-R', repo, '--comment', body]); - if (r.status !== 0) console.warn(`[loop] warn: close #${t.id} 失败: ${(r.stderr || '').trim()}`); - else console.log(`[loop] gh: #${t.id} closed with comment`); - } else if (comment) { - const r = gh(['issue', 'comment', String(t.id), '-R', repo, '--body', comment]); - if (r.status !== 0) console.warn(`[loop] warn: 评论 #${t.id} 失败: ${(r.stderr || '').trim()}`); - else console.log(`[loop] gh: #${t.id} commented`); - } -} - -/* ---------------------------------------------------------- checkpoint */ +/* ----------------------------------------------------------- checkpoint */ async function cmdCheckpoint(args) { const { run: rid, note } = args; - if (!rid || !note) fail('--run --note "..." 必填'); - const lines = await readRunLines(rid); - if (!lines.length) fail(`run ${rid} 不存在`); - await appendRunRow(rid, { runId: rid, event: 'checkpoint', at: nowIso(), note }); - console.log(`[loop] run ${rid} checkpoint 已记录`); -} - -/* ------------------------------------------------------------- summary */ - -async function renderSummary() { - const files = (await fs.readdir(dir('tasks'))).filter((f) => f.endsWith('.json')); - const tasks = []; - for (const f of files) { - const t = await readJson(path.join(dir('tasks'), f)); - if (t) tasks.push(t); - } - const L = []; - L.push(`# Loop 状态摘要 (updated ${nowIso()})`); - L.push(''); - const sec = (title, rows) => { - if (!rows.length) return; - L.push(`## ${title}`, ''); - for (const r of rows) L.push(`- ${r}`); - L.push(''); - }; - const tag = (t) => (t.labels?.length ? ` [${t.labels.join(',')}]` : ''); - const when = (t) => t.updatedAt.slice(0, 16).replace('T', ' '); - - const processing = tasks.filter((t) => t.status === 'processing'); - sec('运行中', processing.map((t) => { - const lk = t.lockedBy ? ` (locked ${t.lockedBy.runId}, TTL ${new Date(Date.parse(t.lockedBy.since) + t.lockedBy.ttl * 1000).toISOString().slice(11, 16)}Z)` : ''; - return `#${t.id} ${t.stage} — ${t.title}${lk}`; - })); - - const wm = tasks.filter((t) => t.status === 'waiting-merge'); - sec('待人工放行 (waiting-merge)', wm.map((t) => { - const prs = t.prs?.length ? ` PR #${t.prs.join(', #')}` : ''; - return `#${t.id}${prs} — ${t.title}${tag(t)} (${when(t)})`; - })); - - const inbox = tasks.filter((t) => ['waiting-human', 'waiting-info'].includes(t.status)); - sec('收件箱 (waiting-human / waiting-info)', inbox.map((t) => - `#${t.id} — ${t.title}${tag(t)} (${when(t)})`)); - - const ready = tasks.filter((t) => t.status === 'ready'); - sec('待领取 (ready)', ready.map((t) => { - const d = t.decision ? ` decision=${t.decision.verdict}/${t.decision.confidence}` : ''; - return `#${t.id} — ${t.title}${d} (${when(t)})`; - })); - - const terminal = tasks.filter((t) => ['closed', 'accepted', 'rejected'].includes(t.status)); - if (terminal.length) { - const c = (s) => terminal.filter((t) => t.status === s).length; - L.push('## 终态计数', ''); - L.push(`closed=${c('closed')} accepted=${c('accepted')} rejected=${c('rejected')}`, ''); - } - - // 最近评测(acceptance.jsonl,agent 不写;sweep/workflow 写) - try { - const raw = await fs.readFile(path.join(dir('metrics'), 'acceptance.jsonl'), 'utf8'); - const rows = raw.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)).slice(-5); - if (rows.length) { - L.push('## 最近评测', ''); - for (const r of rows) L.push(`- ${r.event} task #${r.taskId}${r.pr ? ` pr #${r.pr}` : ''} accepted=${r.accepted ?? '-'} (${r.writer ?? '?'})`); - L.push(''); - } - } catch { /* 文件不存在=尚无评测,省略 */ } - - // 近期 run 尾部(≤5 行,防超长) - const runFiles = (await fs.readdir(dir('runs'))).filter((f) => f.endsWith('.jsonl')).sort().slice(-5); - if (runFiles.length) { - L.push('## 近期 run', ''); - for (const f of runFiles) { - const lines = (await readRunLines(f.slice(0, -6))).filter((r) => ['start', 'end'].includes(r.event)); - const s = lines.find((r) => r.event === 'start'); - const e = lines.find((r) => r.event === 'end'); - if (s) L.push(`- ${f.slice(0, -6)} task #${s.taskId} ${s.stage}${e ? ` → ${e.outcome}` : ' (未收尾)'}`); - } - L.push(''); - } - - const summary = L.join('\n').split('\n').slice(0, 60).join('\n'); - await fs.writeFile(path.join(STATE, 'SUMMARY.md'), summary.trimEnd() + '\n', 'utf8'); + if (!rid || !note) fail('--run --note "..." is required'); + const rows = await readRunLines(S, rid); + if (!rows.length) fail(`run ${rid} does not exist`); + await appendRunRow(S, rid, { runId: rid, event: 'checkpoint', at: nowIso(), note }); + console.log(`[loop] run ${rid} checkpoint recorded`); } /* ---------------------------------------------------------------- view */ @@ -446,48 +253,55 @@ async function renderSummary() { async function cmdView(args) { const { run: rid, task } = args; if (rid) { - const lines = await readRunLines(rid); - if (!lines.length) fail(`run ${rid} 不存在`); - for (const l of lines) console.log(JSON.stringify(l, null, 2)); + const rows = await readRunLines(S, rid); + if (!rows.length) fail(`run ${rid} does not exist`); + for (const l of rows) console.log(JSON.stringify(l, null, 2)); return; } if (task) { - console.log(JSON.stringify(await loadTask(task), null, 2)); + console.log(JSON.stringify(await loadTask(S, task), null, 2)); return; } - const files = (await fs.readdir(dir('tasks'))).filter((f) => f.endsWith('.json')).sort(); - for (const f of files) { - const t = await readJson(path.join(dir('tasks'), f)); - if (t) console.log(`#${t.id}\t${t.status}\t${t.stage ?? '-'}\t${(t.labels || []).join(',')}\t${t.title}`); + for (const t of (await listTasks(S)).sort((a, b) => a.id - b.id)) { + console.log(`#${t.id}\t${t.status}\t${t.stage ?? '-'}\t${(t.labels || []).join(',')}\t${t.title}`); } } -/* ----------------------------------------------------------------- main */ +/* ---------------------------------------------------------------- main */ async function main() { const argv = process.argv.slice(2); const cmd = argv[0]; if (!cmd || cmd === '--help' || cmd === '-h') { - console.log(`用法: - pnpm loop start [--stage ] --task # stage 缺省 triage - [--new --title ".." [--body ".."] | --issue ] + console.log(`usage: + pnpm loop start [--stage ] --task # stage defaults to triage + [--pr | --issue | --new --title ".." [--body ".."]] pnpm loop checkpoint --run --note ".." pnpm loop end --run --outcome [--note ".."] [--comment ".."] - [--label ] [--no-github] # 真实 issue 收尾自动打标;closed/comment 写入 issue - # end 可省 --run:--task 反查未收尾 run + [--label ] [--no-github] [--task ] + [--write-level report|auto] # default report: GitHub write actions are only listed pnpm loop summary | view [--run |--task ] -outcome: ${Object.keys(OUTCOME_MAP).join(' | ')} -state 根: ${STATE}(env STATE_DIR 可覆盖)`); +outcome (task): ${Object.keys(OUTCOME_MAP).join(' | ')} +outcome (system-level run): completed | failed | retry | aborted +state root: ${S.root} (env STATE_DIR overrides) +write level: LOOP_WRITE_LEVEL=report|auto (default report)`); return; } const args = parseArgs(argv.slice(1)); - await ensureDirs(); - if (cmd === 'start') return cmdStart(args); - if (cmd === 'end') return cmdEnd(args); - if (cmd === 'checkpoint') return cmdCheckpoint(args); - if (cmd === 'summary') return renderSummary(); - if (cmd === 'view') return cmdView(args); - fail(`未知子命令 ${cmd}(--help 查看用法)`); + await ensureDirs(S); + // The shared library throws (it is used by the orchestrator too, where a throw is + // right); a CLI should print a one-line reason instead of a stack trace. Catch it + // here so every subcommand behaves the same way. + try { + if (cmd === 'start') return await cmdStart(args); + if (cmd === 'end') return await cmdEnd(args); + if (cmd === 'checkpoint') return await cmdCheckpoint(args); + if (cmd === 'summary') return await renderSummary(S); + if (cmd === 'view') return await cmdView(args); + } catch (e) { + fail(e.message); + } + fail(`unknown subcommand ${cmd} (see --help for usage)`); } await main(); diff --git a/scripts/loop/shared/agent.mjs b/scripts/loop/shared/agent.mjs new file mode 100644 index 0000000..812b992 --- /dev/null +++ b/scripts/loop/shared/agent.mjs @@ -0,0 +1,211 @@ +/** + * Drive pi headlessly to execute one loop run. + * + * Division of labour: opening/execution/closing are done by the agent per the prompt + * (matching the skill convention in A0); this module only builds the prompt, spawns, + * collects usage and exit signals, and classifies failures. + * State writes are still triggered by the agent calling `pnpm loop end` (reusing + * run.mjs's whitelist and idempotency); the orchestration layer only falls back when + * that call is missing. + * + * pi facts (verified first-hand 2026-09): + * - `--mode json` → JSONL event stream (session / agent_start / turn_* / message_* / + * tool_execution_* / agent_end); the last message_end carries the authoritative + * usage and stopReason. + * - `--approve` is required: non-interactive mode shows no trust prompt, and without + * it project-local resources are ignored. + * - exit codes: normal 0; last-turn stopReason=error|aborted → 1; SIGTERM 143 / SIGHUP 129. + * - print mode merges piped stdin into the initial prompt (large prompts go over stdin + * to avoid the argv limit). + */ + +import { spawn } from 'node:child_process'; + +/** + * Prompt language matches skills/AGENTS.md (English) so the agent does not read + * Chinese instructions inside English norms. + */ +export function buildPrompt({ task, stage, runId, writeLevel, mode, repo, lead = [] }) { + const L = []; + L.push(`You are one automated run of the tiny-oss loop. Stage: ${stage}.`); + L.push(''); + L.push('## Opening ritual (read in order, do not skip)'); + L.push('1. `AGENTS.md`'); + L.push('2. `docs/agents/ops.md`'); + L.push(`3. \`skills/${stage.replace(/-scheduled$/, '')}/SKILL.md\` (if missing, use the skills the stage calls for, e.g. verify/review)`); + if (task) L.push(`4. \`state/tasks/${task.id}.json\``); + L.push(''); + L.push('## This run'); + L.push(`- runId: \`${runId}\``); + if (task) { + L.push(`- task: #${task.id} (${task.kind}) — ${task.title}`); + L.push(task.url ? `- GitHub: ${task.url}` : '- local synthetic task (no GitHub target)'); + if (task.body) L.push(`- body:\n\n${String(task.body).slice(0, 4000)}`); + } else { + L.push(`- system-level stage \`${stage}\`: no single task. Survey the whole repository`); + L.push(` (open issues/PRs vs \`state/tasks/*.json\`) and do what the skill prescribes for \`${stage}\`.`); + } + if (lead.length) { + L.push(''); + L.push('## Event context'); + for (const l of lead) L.push(`- ${l}`); + } + L.push(''); + L.push(`## Write boundary: ${writeLevel}`); + if (writeLevel === 'report') { + L.push('**Read-only with respect to GitHub.** Do NOT run any of:'); + L.push('`gh issue edit` / `gh issue comment` / `gh issue close` / `gh pr create` /'); + L.push('`gh pr merge` / `git push` / any other command that mutates GitHub.'); + L.push('Allowed: reading GitHub, editing the working tree, running the repo gates,'); + L.push('reading and writing `state/`. Any GitHub action you conclude is needed'); + L.push('(labels, comments, closing, opening a PR) must be written into the report'); + L.push('as a proposal for a human to execute.'); + } else { + L.push('Write actions are permitted per ops.md (labels/comments/PR as the stage requires).'); + } + if (mode === 'readonly') { + L.push(''); + L.push('**Untrusted input**: this task comes from an external contributor. Analyse only.'); + L.push('Do not execute code from the incoming change (no `pnpm install`/`pnpm test` on it).'); + } + L.push(''); + L.push('## Closing ritual (mandatory)'); + L.push(`1. Write your human-readable report to \`state/reports/${runId}.md\``); + L.push(' (findings, evidence, and — under report boundary — the GitHub actions you propose).'); + L.push(`2. Write your machine-readable result to \`state/reports/${runId}.result.json\`:`); + L.push(' ```json'); + L.push(' { "outcome": "", "note": "",'); + L.push(' "comment": "",'); + L.push(' "decision": { "verdict": "...", "confidence": "...", "reason": "..." },'); + L.push(' "pr": null }'); + L.push(' ```'); + L.push(' The orchestrator reads this file and performs the state transition — one writer'); + L.push(' keeps the state layer consistent. Do NOT run `pnpm loop end` yourself.'); + if (task) { + L.push(` Allowed outcomes for stage \`${stage}\` are declared in \`scripts/loop/shared/state.mjs\``); + L.push(' (STAGE_OUTCOMES). Never invent one.'); + } else { + L.push(` This is a **system-level run** (no single task): the outcome only records how the`); + L.push(' run went and never moves a task. Use exactly one of `completed` (stage finished),'); + L.push(' `failed` (it could not be completed) or `retry` (transient/environmental problem).'); + } + L.push('3. Record checkpoints while working so a crash can resume meaningfully.'); + return L.join('\n'); +} + +/** JSONL → event array; non-JSON lines (progress notices etc.) are ignored. */ +export function parseEvents(text) { + const events = []; + for (const line of String(text ?? '').split('\n')) { + const s = line.trim(); + if (!s || s[0] !== '{') continue; + try { events.push(JSON.parse(s)); } catch { /* ignore non-event lines */ } + } + return events; +} + +/** + * Extract usage and the last-turn stop reason from the event stream + * (usage is authoritative only on message_end). + */ +export function summarize(events) { + const ends = events.filter((e) => e.type === 'message_end' && e?.message?.role === 'assistant'); + const last = ends[ends.length - 1] ?? null; + return { + usage: last?.message?.usage ?? null, + stopReason: last?.message?.stopReason ?? null, + toolCalls: events.filter((e) => e.type === 'tool_execution_end').length, + turns: events.filter((e) => e.type === 'turn_end').length, + }; +} + +/** + * Sample pi's session files (for the artifact): return the newest .jsonl in the + * given directory. + */ +export const SESSION_HINT = 'session files land under --session-dir (uploaded as an artifact)'; + +export function runPi({ prompt, cwd, sessionDir, model, timeoutMs = 3600000, log = () => {} }) { + return new Promise((resolve) => { + const args = [ + '--mode', 'json', + '--approve', + '--skill', 'skills', + '--session-dir', sessionDir, + '-p', 'Follow the loop run instructions provided on stdin.', + ]; + if (model) args.push('--model', model); + + // The engine is the template layer's instance variable {{engine}} (05 §4.2): defaults + // to pi, overridable with LOOP_ENGINE_CMD (e.g. run the same contract locally through + // another CLI or wrapper script). + const engine = (process.env.LOOP_ENGINE_CMD || 'pi').split(/\s+/).filter(Boolean); + const child = spawn(engine[0], [...engine.slice(1), ...args], { + cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + log(`[loop] run timed out (${Math.round(timeoutMs / 60000)}min), sending SIGTERM`); + child.kill('SIGTERM'); + setTimeout(() => child.kill('SIGKILL'), 30000).unref?.(); + }, timeoutMs); + + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + child.on('error', (e) => { + clearTimeout(timer); + resolve({ code: -1, stdout, stderr: `${stderr}\nspawn error: ${e.message}`, timedOut }); + }); + child.on('close', (code, signal) => { + clearTimeout(timer); + resolve({ code: code ?? (signal ? 1 : 0), signal, stdout, stderr, timedOut }); + }); + + // EPIPE when spawn fails or the process exits early must not crash the flow. + child.stdin.on('error', () => {}); + child.stdin.write(prompt); + child.stdin.end(); + }); +} + +/** + * Failure classification (Q6 decision): machine failure → retry; agent-judged failure → + * inbox; unclear → retry conservatively. + * The inbox is for humans and must not be flooded by provider flakiness (otherwise the + * human-intervention rate loses meaning). + * Exit codes follow the run contract: 0 done / 1 failed→inbox / 2 abort / 3 retry suggested. + */ +export function classify({ code, signal, stderr, timedOut, stopReason }) { + if (timedOut) return { exit: 2, kind: 'abort', reason: 'run timed out and was aborted' }; + if (code === 0) return { exit: 0, kind: 'ok', reason: 'pi exited normally' }; + if (code === 143 || code === 129 || signal) { + return { exit: 2, kind: 'abort', reason: `terminated by signal (${signal ?? code})` }; + } + if (code === -1) { + return { exit: 3, kind: 'retry', reason: 'pi failed to start (install/path problem)' }; + } + const s = String(stderr ?? ''); + // Credential/config failures (missing key, invalid key, pi resolved no model): these are + // host configuration problems, not task problems, and must never push a task into the + // human inbox — observed: pi prints "No models available" when there is no key. + if (/no models? available|no model resolved|use \/login|api key|unauthorized|forbidden|401|403|invalid.*(token|key)|not authenticated/i.test(s)) { + return { + exit: 3, kind: 'retry', + reason: 'engine credential/config not ready → retry (check secrets)', + }; + } + if (/rate.?limit|429|timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|socket hang up|502|503|504|overloaded|capacity/i.test(s)) { + return { exit: 3, kind: 'retry', reason: 'suspected transient provider/network flake → retry' }; + } + if (stopReason === 'error' || stopReason === 'aborted') { + return { exit: 1, kind: 'failed', reason: `agent stopReason=${stopReason} → to inbox` }; + } + return { + exit: 3, kind: 'retry', + reason: `exit code ${code} unclassifiable → retry conservatively`, + }; +} diff --git a/scripts/loop/shared/r2.mjs b/scripts/loop/shared/r2.mjs new file mode 100644 index 0000000..7fffdf4 --- /dev/null +++ b/scripts/loop/shared/r2.mjs @@ -0,0 +1,194 @@ +/** + * R2 state layer sync (pull / push / seed). + * + * Tool choice: the runner image (ubuntu-24.04) preinstalls AWS CLI v2 (2.36.x), + * so we talk to R2's S3-compatible endpoint directly with zero install and zero + * new repo dependencies (no @aws-sdk/client-s3). + * + * Key settings: + * - pass `auto` as the region: R2 requires a non-empty value but does not use + * it (official AWS CLI docs). + * - `AWS_REQUEST_CHECKSUM_CALCULATION=when_required`: AWS CLI >= 2.23.0 computes + * a CRC32 checksum for uploads by default, which is unfriendly to some + * S3-compatible services; on-demand calculation is the most conservative + * compatibility choice. + * - `AWS_EC2_METADATA_DISABLED=true`: avoid instance-metadata probing slowing + * things down / erroring out in non-EC2 environments. + * + * Failure semantics (important): a failed pull MUST abort the whole chain — + * otherwise the local state/ is empty, and what push then uploads is not a + * complete state layer. + */ + +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +/** + * Path prefix inside the bucket. **One bucket per repo** (the owner's decision); + * the bucket name already carries the repo identity, so the prefix no longer + * repeats the repo segment — remote keys map 1:1 to the local `state/`. + */ +export const R2_PREFIX = 'state/'; + +/** + * One bucket per repo, named `loop-state--`. + * + * Why not one bucket for all repos: R2 API token permissions can only be scoped + * to a **bucket**, never to a prefix (an Access Policy resource is + * `...r2.bucket.__`, with no key/prefix + * dimension). With a shared bucket, "an independent token per repo" is + * meaningless — every token can read and write the whole bucket, so a single + * credential leak or fork injection would affect every onboarded repo. + * + * Why include the owner: bucket names are a flat namespace unique per account, + * so a name collision is a real collision. Including the owner raises the + * collision surface from "repo name" to "owner + repo name", so managing + * multiple GitHub organizations in the future will not collide either. + * `R2_BUCKET` can override this (migration, troubleshooting, temporarily + * pointing at another bucket). + */ +let ownerRepoCache = null; + +/** Resolve owner/repo: GITHUB_REPOSITORY in CI, repository.url from package.json locally. */ +function ownerRepo() { + if (ownerRepoCache) return ownerRepoCache; + + const fromCi = (process.env.GITHUB_REPOSITORY ?? '').trim(); + if (fromCi.includes('/')) { + const [owner, repo] = fromCi.split('/'); + return (ownerRepoCache = { owner, repo }); + } + + let pkg; + try { + pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + } catch (e) { + throw new Error(`Cannot determine repo identity: GITHUB_REPOSITORY is unset and ${path.join(ROOT, 'package.json')} cannot be read (${e.message}). Please set R2_BUCKET explicitly.`); + } + // npm allows repository to be a string (`"owner/repo"`, `"github:owner/repo"`) + // or an object (`{ url }`) + const raw = typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url ?? ''); + const url = String(raw).replace(/^git\+/, '').replace(/\.git$/, ''); + const m = /github\.com[/:]([^/]+)\/(.+)$/.exec(url) + ?? /^(?:github:)?([\w.-]+)\/([\w.-]+)$/.exec(url); + if (!m) { + throw new Error(`Cannot derive owner/repo from package.json (repository = ${raw || 'missing'}). Please set R2_BUCKET explicitly.`); + } + return (ownerRepoCache = { owner: m[1], repo: m[2] }); +} + +/** R2 bucket names allow only lowercase letters/digits/hyphens, and never + * start or end with a hyphen (official constraint). */ +const sanitize = (seg) => String(seg).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + +const BUCKET_MAX = 63; + +function derivedBucketName() { + const { owner, repo } = ownerRepo(); + const base = `loop-state-${sanitize(owner)}-${sanitize(repo)}`; + if (base.length <= BUCKET_MAX) return base; + // GitHub repo names are capped at 100 characters, so the joined `owner-repo` + // can in theory exceed 63. Truncate + stable hash: the same repo derives the + // same bucket name every time, and different repos will not collide by truncation. + const h = createHash('sha256').update(base).digest('hex').slice(0, 8); + return `${base.slice(0, BUCKET_MAX - h.length - 1).replace(/-+$/, '')}-${h}`; +} + +/** This repo's R2 bucket name. */ +export const bucket = () => process.env.R2_BUCKET || derivedBucketName(); + +const REQUIRED = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY']; + +export function r2Env() { + const missing = REQUIRED.filter((k) => !process.env[k]); + if (missing.length) { + throw new Error(`Missing R2 environment variables: ${missing.join(', ')} (configure as Actions secret/variable, never write into repo files)`); + } + return { + ...process.env, + AWS_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY, + AWS_DEFAULT_REGION: 'auto', + AWS_REQUEST_CHECKSUM_CALCULATION: 'when_required', + AWS_EC2_METADATA_DISABLED: 'true', + // AWS CLI v2 hands results to a pager (`less`). Under scripted invocation this + // looks like a "hung command" — the cursor stops, there is no output, yet it + // is actually waiting for a keypress. Never use a pager for programmatic calls. + AWS_PAGER: '', + // A cross-border handshake to R2 takes about 0.4s but degrades on slow links. + // Set explicit bounds so failures surface quickly as errors instead of long + // silent retries. + AWS_MAX_ATTEMPTS: process.env.AWS_MAX_ATTEMPTS ?? '3', + }; +} + +/** Global CLI bounds: connect/read timeouts, to avoid "looking hung". */ +const CLI_LIMITS = ['--cli-connect-timeout', '15', '--cli-read-timeout', '60']; + +const endpoint = () => `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; +const s3url = (prefix) => `s3://${bucket()}/${prefix}`; + +/** + * Invoke the aws CLI. + * + * `stream: true` lets the child process write to the terminal directly — sync + * operations can take seconds to tens of seconds over cross-border links, and + * spawnSync buffering would leave that window with no output ("looking hung"). + * Only capture output when it must be parsed (check counting lines). + */ +function aws(args, { label, stream = false }) { + const r = spawnSync('aws', [...args, ...CLI_LIMITS], { + env: r2Env(), + encoding: stream ? undefined : 'utf8', + stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'], + }); + if (r.error) throw new Error(`[r2:${label}] failed to run aws cli: ${r.error.message}`); + if (stream) { + if (r.status !== 0) throw new Error(`[r2:${label}] aws cli exited with code ${r.status}`); + return ''; + } + const out = (r.stdout ?? '').trim(); + const err = (r.stderr ?? '').trim(); + if (r.status !== 0) throw new Error(`[r2:${label}] aws cli exited with code ${r.status}\n${err || out}`); + if (out) console.log(out); + if (err) console.error(err); + return out; +} + +/** Remote → local. No --delete: state/ is fresh on the runner, so no stale files. */ +export function pull(dir, { prefix = R2_PREFIX } = {}) { + return aws(['s3', 'sync', s3url(prefix), dir, '--endpoint-url', endpoint()], { label: 'pull', stream: true }); +} + +/** + * Local → remote. **No `--delete`**: R2 has no object versioning (Cloudflare's + * docs offer no such capability, only bucket lock retention policies), so + * deletes are not revertible — hence "overwrite + add" only. The cost is that + * expired lock files removed during a run linger on the remote, absorbed by the + * TTL semantics (lockExpired). + */ +export function push(dir, { prefix = R2_PREFIX } = {}) { + return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'push', stream: true }); +} + +/** + * First seed: local → remote, **no --delete** (add only, never remove) — + * when the remote already has objects, prefer being conservative over risking + * a wipe. + */ +export function seed(dir, { prefix = R2_PREFIX } = {}) { + return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'seed', stream: true }); +} + +/** Connectivity/credential self-check: count objects under the prefix (empty bucket passes). */ +export function check({ prefix = R2_PREFIX } = {}) { + const out = aws(['s3', 'ls', s3url(prefix), '--endpoint-url', endpoint(), '--recursive'], { label: 'check' }); + const n = out ? out.split('\n').filter(Boolean).length : 0; + console.log(`[loop:r2] connectivity OK, prefix ${prefix} currently has ${n} objects`); + return n; +} diff --git a/scripts/loop/shared/route.mjs b/scripts/loop/shared/route.mjs new file mode 100644 index 0000000..9e761c6 --- /dev/null +++ b/scripts/loop/shared/route.mjs @@ -0,0 +1,169 @@ +/** + * GitHub event → Loop decision (pure-function router). + * + * Authority: 05 host implementation spec §3 routing table / docs/agents/ops.md + * "Trigger → loop map". + * This module does no IO: the input is the event context injected by the workflow and + * the output is a Decision, executed by entry.mjs. + * Pure function = offline regression on fixed event samples, no need to fire real + * GitHub events. + */ + +/** + * author_association considered "this repo's own people" + * (everything else = external contributor). + */ +export const OWNER_ROLES = ['OWNER', 'MEMBER', 'COLLABORATOR']; + +/** Cron for the weekly retro (kept in sync with loop.yml's schedule). */ +export const WEEKLY_CRON = '23 3 * * 1'; + +const noop = (reason) => ({ action: 'noop', reason }); +const run = (o) => ({ action: 'run', ...o }); +const metrics = (o) => ({ action: 'metrics', ...o }); +const inbox = (o) => ({ action: 'inbox', ...o }); + +/** + * Loop PR test: head branch `loop/-*` and body containing `loop-task: #` + * (both must agree). + */ +export function loopTaskOf(pr) { + const m = /^loop\/(\d+)-/.exec(pr?.head?.ref ?? ''); + const b = /loop-task:\s*#(\d+)/.exec(pr?.body ?? ''); + if (!m || !b) return null; + return Number(m[1]) === Number(b[1]) ? Number(m[1]) : null; +} + +export const isDependabotPr = (pr) => + (pr?.user?.login ?? '') === 'dependabot[bot]' + || (pr?.head?.ref ?? '').startsWith('dependabot/'); + +export const isExternalPr = (pr) => !OWNER_ROLES.includes(pr?.author_association ?? ''); + +/** + * Idempotency key: skip an event whose key has already been consumed. + * Prefer immutable identifiers (PR head sha / comment id), falling back to the + * issue's updated_at revision. + */ +export function eventKey({ eventName, action, event = {} }) { + const node = event.pull_request ?? event.issue ?? {}; + const ver = event.pull_request?.head?.sha + ?? event.comment?.id + ?? node.updated_at + ?? ''; + return `${eventName}:${action}:${node.number ?? ''}:${ver}`; +} + +/** + * @param {{eventName: string, action?: string, event?: object}} ctx + * @returns {{action: 'noop'|'run'|'metrics'|'inbox', reason: string, taskId?: number, + * kind?: 'issue'|'pr', stage?: string, mode?: 'normal'|'readonly', + * loopTask?: number, metrics?: object}} + */ +export function route({ eventName, action, event = {} }) { + switch (eventName) { + case 'issues': { + const n = event.issue?.number; + if (action === 'opened' || action === 'reopened') { + return run({ taskId: n, kind: 'issue', stage: 'triage', reason: `issues.${action}` }); + } + // Info supplement: never silently drop; land in inbox first, then decide from the + // task state whether a catch-up run is needed (05 §3 decision order) + if (action === 'edited') { + return inbox({ taskId: n, kind: 'issue', stage: 'triage', reason: 'issues.edited' }); + } + return noop(`issues.${action} has no Loop action`); + } + + case 'issue_comment': { + if (action !== 'created') return noop(`issue_comment.${action} has no Loop action`); + const issue = event.issue ?? {}; + return inbox({ + taskId: issue.number, + kind: issue.pull_request ? 'pr' : 'issue', + reason: 'issue_comment.created', + commentId: event.comment?.id, + }); + } + + case 'pull_request': { + const pr = event.pull_request ?? {}; + if (action !== 'opened' && action !== 'synchronize') { + return noop(`pull_request.${action} routed elsewhere`); + } + const loopTask = loopTaskOf(pr); + if (loopTask) { + return run({ taskId: pr.number, kind: 'pr', stage: 'pr-review', loopTask, reason: `loop PR ${action}` }); + } + if (isDependabotPr(pr)) { + return run({ taskId: pr.number, kind: 'pr', stage: 'deps', reason: 'dependabot PR' }); + } + if (isExternalPr(pr)) { + return run({ + taskId: pr.number, kind: 'pr', stage: 'triage', mode: 'readonly', + reason: 'external PR analysed read-only', + }); + } + // PRs and issues share the triage surface (issue-tracker.md: PRs ARE a triage surface) + return run({ taskId: pr.number, kind: 'pr', stage: 'triage', reason: `repo PR ${action}` }); + } + + case 'pull_request_review': { + const pr = event.pull_request ?? {}; + if (action !== 'submitted' && action !== 'dismissed') { + return noop(`pull_request_review.${action} has no Loop action`); + } + const loopTask = loopTaskOf(pr); + if (!loopTask) return noop('review of a non-loop PR does not trigger a run'); + return run({ taskId: pr.number, kind: 'pr', stage: 'pr-review', loopTask, reason: `review ${action}` }); + } + + case 'pull_request_target': { + const pr = event.pull_request ?? {}; + if (action !== 'closed') return noop(`pull_request_target.${action} has no Loop action`); + const loopTask = loopTaskOf(pr); + if (!loopTask) return noop('closing a non-loop PR is not counted for auto-acceptance'); + const merged = Boolean(pr.merged); + return metrics({ + reason: `loop PR ${merged ? 'merged' : 'closed-unmerged'}`, + metrics: { + event: merged ? 'merged' : 'closed-unmerged', + taskId: loopTask, + pr: pr.number, + accepted: merged, + writer: 'workflow', + }, + }); + } + + case 'release': { + if (action !== 'published') return noop(`release.${action} has no Loop action`); + return metrics({ + reason: 'release published', + metrics: { event: 'released', version: event.release?.tag_name ?? null, accepted: true, writer: 'workflow' }, + }); + } + + case 'schedule': { + const cron = event.schedule ?? ''; + if (cron === WEEKLY_CRON) return run({ stage: 'retro-scheduled', reason: 'weekly schedule' }); + return run({ stage: 'sweep', reason: `schedule ${cron || '(daily)'}` }); + } + + case 'workflow_dispatch': { + const inputs = event.inputs ?? {}; + if (inputs.task) { + return run({ + taskId: Number(inputs.task), + stage: inputs.stage || 'triage', + reason: 'manual dispatch', + }); + } + if (inputs.stage) return run({ stage: inputs.stage, reason: 'manual dispatch (no task)' }); + return noop('workflow_dispatch provided no task/stage'); + } + + default: + return noop(`unhandled event ${eventName}`); + } +} diff --git a/scripts/loop/shared/state.mjs b/scripts/loop/shared/state.mjs new file mode 100644 index 0000000..8a3d6ca --- /dev/null +++ b/scripts/loop/shared/state.mjs @@ -0,0 +1,504 @@ +/** + * tiny-oss Loop state-layer primitives — shared by the local host `run.mjs` and + * the Actions orchestrator `run-stage.mjs`. + * Single-writer principle: both entry points touch `state/` only through this module, + * so the two implementations cannot drift (#33 during A0 was manual drift). + * + * schema: task files / run rows / locks / SUMMARY — see scripts/loop/README.md; + * state machine + dual metrics: docs/agents/ops.md, docs/agents/triage-labels.md. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +/* ------------------------------------------------------------ domain constants */ + +/** Five canonical role labels (triage-labels.md): mutually exclusive, never stacked. */ +export const ROLE_LABELS = ['needs-triage', 'needs-info', 'ready-for-agent', 'ready-for-human', 'wontfix']; + +/** outcome → task terminal status + label to apply (ops.md "Labels → task state"). */ +export const OUTCOME_MAP = { + 'triaged': { status: 'ready', label: 'ready-for-agent' }, + 'needs-info': { status: 'waiting-info', label: 'needs-info' }, + 'needs-triage': { status: 'waiting-human', label: 'needs-triage' }, + 'pr-opened': { status: 'waiting-merge', label: 'ready-for-human' }, + 'closed': { status: 'closed', label: null }, + 'rejected': { status: 'rejected', label: null }, + 'accepted': { status: 'accepted', label: null }, + 'failed': { status: 'waiting-human', label: 'needs-triage' }, + // Host extension (not in the ops.md outcome table): transient provider/network + // failures return the task to claimable, not the inbox (the inbox is for humans; exit 3). + 'retry': { status: 'ready', label: 'ready-for-agent' }, +}; + +/** stage → whitelist of allowed successful outcomes (D5 guard); others allow all. */ +const COMMON_OUTCOMES = ['failed', 'rejected', 'accepted', 'retry']; + +/** + * System-level run (sweep / retro / release precheck) outcomes — they only record + * "how this round went" and map to no task status. + * + * Why task outcomes cannot be reused: `closed` means "closed outright, not part + * of the acceptance denominator". A system-level run recording `closed` would + * make retro count sweep/retro runs as closed tasks, skewing the dual-metric. + */ +export const SYSTEM_OUTCOMES = ['completed', 'failed', 'retry', 'aborted']; + +export const STAGE_OUTCOMES = { + triage: ['triaged', 'needs-info', 'needs-triage', 'closed'], + bugfix: ['pr-opened'], + feature: ['pr-opened'], + deps: ['pr-opened', 'needs-triage'], + security: ['pr-opened', 'needs-triage'], +}; + +export function outcomeAllowed(stage, outcome) { + const extra = STAGE_OUTCOMES[stage]; + if (!extra) return true; + return COMMON_OUTCOMES.includes(outcome) || extra.includes(outcome); +} + +export function allowedOutcomes(stage) { + return [...COMMON_OUTCOMES, ...(STAGE_OUTCOMES[stage] ?? [])]; +} + +/* ---------------------------------------------------------------- clock */ + +const pad = (n) => String(n).padStart(2, '0'); + +export const nowIso = () => new Date().toISOString(); +export const rand4 = () => Math.random().toString(36).slice(2, 6).padEnd(4, '0'); + +export const newRunId = () => { + const d = new Date(); + return `r-${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + + `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${rand4()}`; +}; + +/* ------------------------------------------------------------ state-layer paths */ + +/** + * Build a state-layer handle. `stateRoot` defaults to `/state`; override + * it with `STATE_DIR` (job workdir on the runner, repo root locally). + */ +export function makeState(repoRoot) { + const root = process.env.STATE_DIR ? path.resolve(process.env.STATE_DIR) : path.join(repoRoot, 'state'); + const d = (k) => path.join(root, k); + return { + root, + tasksDir: d('tasks'), + runsDir: d('runs'), + locksDir: d('locks'), + metricsDir: d('metrics'), + reportsDir: d('reports'), + summaryFile: path.join(root, 'SUMMARY.md'), + acceptanceFile: path.join(d('metrics'), 'acceptance.jsonl'), + taskFile: (id) => path.join(d('tasks'), `${id}.json`), + runFile: (rid) => path.join(d('runs'), `${rid}.jsonl`), + lockFile: (id, stage) => path.join(d('locks'), `${id}-${stage}.lock`), + }; +} + +export async function ensureDirs(s) { + await Promise.all( + [s.tasksDir, s.runsDir, s.locksDir, s.metricsDir, s.reportsDir] + .map((p) => fs.mkdir(p, { recursive: true })), + ); +} + +/* --------------------------------------------------------------- file IO */ + +export async function readJson(file) { + try { + return JSON.parse(await fs.readFile(file, 'utf8')); + } catch (e) { + if (e.code === 'ENOENT') return null; + throw e; + } +} + +export async function writeJson(file, obj) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(obj, null, 2) + '\n', 'utf8'); +} + +export async function readJsonl(file) { + try { + const raw = await fs.readFile(file, 'utf8'); + return raw.trim() ? raw.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []; + } catch (e) { + if (e.code === 'ENOENT') return []; + throw e; + } +} + +async function appendJsonl(file, row) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.appendFile(file, JSON.stringify(row) + '\n', 'utf8'); +} + +export const appendRunRow = (s, rid, row) => appendJsonl(s.runFile(rid), row); +export const readRunLines = (s, rid) => readJsonl(s.runFile(rid)); + +/* ------------------------------------------------------------ task operations */ + +export async function listTasks(s) { + const files = (await fs.readdir(s.tasksDir).catch(() => [])).filter((f) => f.endsWith('.json')); + const out = []; + for (const f of files) { + const t = await readJson(path.join(s.tasksDir, f)); + if (t) out.push(t); + } + return out; +} + +export async function loadTask(s, id) { + const t = await readJson(s.taskFile(id)); + if (!t) throw new Error(`task #${id} not found (${s.taskFile(id)})`); + return t; +} + +export async function saveTask(s, t) { + t.updatedAt = nowIso(); + await writeJson(s.taskFile(t.id), t); +} + +/** Next local task id: max numeric filename + 1; empty dir starts at 9000 (skips GH ids). */ +export async function nextTaskId(s) { + const files = await fs.readdir(s.tasksDir).catch(() => []); + const nums = files.map((f) => /^(\d+)\.json$/.exec(f)?.[1]).filter(Boolean).map(Number); + return nums.length ? Math.max(...nums) + 1 : 9000; +} + +/* ------------------------------------------------------------------ locks */ + +/** + * In the fully-hosted form, mutual exclusion comes from the workflow `concurrency`; + * the lock file degrades to a record and a guard. + */ +export const lockExpired = (lockedBy) => + !lockedBy || Date.now() - Date.parse(lockedBy.since) > (lockedBy.ttl || 3600) * 1000; + +export async function acquireLock(s, t, stage, rid) { + const at = nowIso(); + const lock = { runId: rid, taskId: t.id, since: at, ttl: 3600 }; + t.status = 'processing'; + t.stage = stage; + t.lockedBy = lock; + t.runs.push(rid); + t.timeline.push({ at, event: 'claimed', by: rid, detail: `stage=${stage}` }); + await saveTask(s, t); + await writeJson(s.lockFile(t.id, stage), lock); +} + +export async function releaseLock(s, t, stage) { + await fs.rm(s.lockFile(t.id, stage), { force: true }); +} + +/* -------------------------------------------------------------- acceptance writes */ + +const acceptanceKey = (r) => `${r.taskId}|${r.event}|${r.pr ?? ''}`; + +/** + * Append an acceptance event; skipped when the same `taskId+event+pr` already + * exists (idempotent). Writer = the workflow job (agents never write); sweep + * corrections carry `sweep-corrected`. Returns true when a row was actually written. + */ +export async function appendAcceptance(s, row) { + const existing = await readJsonl(s.acceptanceFile); + if (existing.some((r) => acceptanceKey(r) === acceptanceKey(row))) return false; + await appendJsonl(s.acceptanceFile, row); + return true; +} + +/* -------------------------------------------------------------- SUMMARY */ + +export async function renderSummary(s) { + const tasks = await listTasks(s); + const L = []; + L.push(`# Loop state summary (updated ${nowIso()})`); + L.push(''); + const sec = (title, rows) => { + if (!rows.length) return; + L.push(`## ${title}`, ''); + for (const r of rows) L.push(`- ${r}`); + L.push(''); + }; + const tag = (t) => (t.labels?.length ? ` [${t.labels.join(',')}]` : ''); + const when = (t) => (t.updatedAt ?? '').slice(0, 16).replace('T', ' '); + + sec('In progress', tasks.filter((t) => t.status === 'processing').map((t) => { + const lk = t.lockedBy + ? ` (locked ${t.lockedBy.runId}, TTL ${new Date(Date.parse(t.lockedBy.since) + t.lockedBy.ttl * 1000).toISOString().slice(11, 16)}Z)` + : ''; + return `#${t.id} ${t.stage} — ${t.title}${lk}`; + })); + + sec('Awaiting human merge (waiting-merge)', + tasks.filter((t) => t.status === 'waiting-merge').map((t) => { + const prs = t.prs?.length ? ` PR #${t.prs.join(', #')}` : ''; + return `#${t.id}${prs} — ${t.title}${tag(t)} (${when(t)})`; + })); + + sec('Inbox (waiting-human / waiting-info)', + tasks.filter((t) => ['waiting-human', 'waiting-info'].includes(t.status)) + .map((t) => `#${t.id} — ${t.title}${tag(t)} (${when(t)})`)); + + sec('Ready to claim (ready)', tasks.filter((t) => t.status === 'ready').map((t) => { + const d = t.decision ? ` decision=${t.decision.verdict}/${t.decision.confidence}` : ''; + return `#${t.id} — ${t.title}${d} (${when(t)})`; + })); + + // `new` = the event is stored but not yet triaged. Easiest for humans to miss, + // so it must appear in the summary (tasks just filed by sweep are this kind). + sec('Unprocessed (new)', tasks.filter((t) => t.status === 'new').map((t) => { + const kind = t.kind === 'pr' ? 'PR' : 'issue'; + return `#${t.id} (${kind}) — ${t.title} (${when(t)})`; + })); + + const terminal = tasks.filter((t) => ['closed', 'accepted', 'rejected'].includes(t.status)); + if (terminal.length) { + const c = (st) => terminal.filter((t) => t.status === st).length; + L.push('## Terminal counts', ''); + L.push(`closed=${c('closed')} accepted=${c('accepted')} rejected=${c('rejected')}`, ''); + } + + // Recent acceptance (metrics/acceptance.jsonl: written by the workflow, not agents) + const acc = (await readJsonl(s.acceptanceFile)).slice(-5); + if (acc.length) { + L.push('## Recent acceptance', ''); + for (const r of acc) { + L.push(`- ${r.event} task #${r.taskId}${r.pr ? ` pr #${r.pr}` : ''} accepted=${r.accepted ?? '-'} (${r.writer ?? '?'})`); + } + L.push(''); + } + + // Tail of recent runs (<= 5, to keep the file short) + const runFiles = (await fs.readdir(s.runsDir).catch(() => [])) + .filter((f) => f.endsWith('.jsonl')).sort().slice(-5); + if (runFiles.length) { + L.push('## Recent runs', ''); + for (const f of runFiles) { + const rid = f.slice(0, -'.jsonl'.length); + const rows = (await readRunLines(s, rid)).filter((r) => ['start', 'end'].includes(r.event)); + const st = rows.find((r) => r.event === 'start'); + const en = rows.find((r) => r.event === 'end'); + // System-level run (sweep / retro / release precheck) has no taskId + if (st) { + const what = st.taskId ? `task #${st.taskId}` : 'system'; + L.push(`- ${rid} ${what} ${st.stage}${en ? ` → ${en.outcome}` : ' (unfinished)'}`); + } + } + L.push(''); + } + + const summary = L.join('\n').split('\n').slice(0, 60).join('\n'); + await fs.writeFile(s.summaryFile, summary.trimEnd() + '\n', 'utf8'); +} + +/* ------------------------------------------------------------ GitHub actions */ + +export function ghRepoOf(url) { + const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/\d+/.exec(url ?? ''); + return m ? `${m[1]}/${m[2]}` : null; +} + +/** + * Derive the GitHub write-action list from the outcome — **pure function, never + * touches the network**. + * report mode only calls this function and writes `describeActions` output into the + * report; auto mode (L2+ / dry run) hands it to `applyActions` — the one "report + * only" switch: no apply, no GitHub writes. + */ +export function planActions(t, outcome, { label, comment, note } = {}) { + if (!t.url) return []; // a local synthetic task has no GitHub target + if (outcome === 'retry') return []; // nothing actually ran (failure/draft), so no write actions + const repo = ghRepoOf(t.url); + if (!repo) return []; + // PR and issue are different gh subcommand surfaces: `gh issue edit` on a PR number fails. + const gh = t.kind === 'pr' ? 'pr' : 'issue'; + const acts = []; + const l = label ?? OUTCOME_MAP[outcome]?.label ?? null; + if (l) acts.push({ kind: 'label', gh, repo, number: t.id, label: l, stripRoles: true }); + if (outcome === 'closed') { + acts.push({ kind: 'close', gh, repo, number: t.id, body: comment ?? note ?? 'Closed by the loop.' }); + } else if (comment) { + acts.push({ kind: 'comment', gh, repo, number: t.id, body: comment }); + } + return acts; +} + +/** Human-readable description, used by report-mode reports and the Step Summary. */ +export function describeActions(actions) { + return actions.map((a) => { + if (a.kind === 'label') { + return `label #${a.number}: +${a.label}${a.stripRoles ? ' (remove other role labels)' : ''}`; + } + if (a.kind === 'close') return `close #${a.number} (comment: ${firstLine(a.body)})`; + return `comment #${a.number}: ${firstLine(a.body)}`; + }); +} + +const firstLine = (s) => String(s ?? '').split('\n')[0].slice(0, 120); + +/** + * Execute write actions (only called on the auto / dry-run path). Failures only + * warn; local state is never rolled back. + */ +export function applyActions(actions, { log = console.log, warn = console.warn } = {}) { + const applied = []; + for (const a of actions) { + const gh = (args) => spawnSync('gh', args, { encoding: 'utf8' }); + const sub = a.gh ?? 'issue'; // PRs go to gh pr, issues to gh issue + if (a.kind === 'label') { + let stale = []; + if (a.stripRoles) { + const cur = gh([sub, 'view', String(a.number), '-R', a.repo, '--json', 'labels', '-q', '.labels[].name']); + stale = cur.status === 0 + ? cur.stdout.trim().split('\n').filter(Boolean).filter((l) => ROLE_LABELS.includes(l) && l !== a.label) + : []; + } + const args = [sub, 'edit', String(a.number), '-R', a.repo, '--add-label', a.label]; + for (const st of stale) args.push('--remove-label', st); + const r = gh(args); + if (r.status !== 0) warn(`[loop] warn: label ${a.label} failed: ${(r.stderr || '').trim()}`); + else { log(`[loop] gh: #${a.number} +label ${a.label}${stale.length ? `, -label ${stale.join(', ')}` : ''}`); applied.push(a); } + } else if (a.kind === 'close') { + const r = gh([sub, 'close', String(a.number), '-R', a.repo, '--comment', a.body]); + if (r.status !== 0) warn(`[loop] warn: close #${a.number} failed: ` + + `${(r.stderr || '').trim()}`); + else { log(`[loop] gh: #${a.number} closed with comment`); applied.push(a); } + } else if (a.kind === 'comment') { + const r = gh([sub, 'comment', String(a.number), '-R', a.repo, '--body', a.body]); + if (r.status !== 0) warn(`[loop] warn: comment #${a.number} failed: ` + + `${(r.stderr || '').trim()}`); + else { log(`[loop] gh: #${a.number} commented`); applied.push(a); } + } + } + return applied; +} + +/* ---------------------------------------------------------- run lifecycle */ + +/** + * Claim a task and write the start row, returning the runId. `task` = null means a + * system-level run (sweep / retro / release precheck). + */ +export async function beginRun(s, t, stage, { sandbox, trigger = 'manual', model = null } = {}) { + const rid = newRunId(); + if (t) await acquireLock(s, t, stage, rid); + await appendRunRow(s, rid, { + runId: rid, taskId: t?.id ?? null, kind: t?.kind ?? null, stage, + event: 'start', at: nowIso(), sandbox, model, trigger, + }); + return rid; +} + +/** report mode: append pending human write actions to this run's report file. */ +export async function appendActionBlock(s, runId, actions) { + const file = path.join(s.reportsDir, `${runId}.md`); + await fs.mkdir(s.reportsDir, { recursive: true }); + const block = [ + '', '## Pending GitHub actions (write level=report)', '', + ...describeActions(actions).map((d) => `- [ ] ${d}`), '', + ].join('\n'); + await fs.appendFile(file, block, 'utf8'); + return file; +} + +/** + * Finish a run: write the end row → update the task → release the lock → GitHub + * actions (report lists only, auto executes) → refresh SUMMARY. Shared by the local + * CLI and the runner orchestrator, so idempotency and the whitelist have one home. + */ +export async function finishRun(s, { + runId, outcome, note = null, comment = null, label = null, decision = null, pr = null, + tokens = null, writeLevel = 'report', noGithub = false, log = () => {}, warn = () => {}, +}) { + if (!outcome) throw new Error('outcome is required'); + const rows = await readRunLines(s, runId); + if (!rows.length) throw new Error(`run ${runId} not found (${s.runFile(runId)})`); + if (rows.some((r) => r.event === 'end')) throw new Error( + `run ${runId} already has an end row; duplicate finish is rejected (idempotent)`, + ); + const start = rows[0]; + // A system-level run (sweep/retro/release precheck) has no task file: it only + // records the run row, no status transition, and no task outcome semantics (e.g. closed). + const t = start.taskId ? await loadTask(s, start.taskId) : null; + if (!t) { + if (!SYSTEM_OUTCOMES.includes(outcome)) { + throw new Error(`system-level run (no task) outcome must be in ` + + `{${SYSTEM_OUTCOMES.join(', ')}}, got ${outcome}; task outcome semantics ` + + `(e.g. closed) must not appear on a system-level run (dual-metric pollution)`); + } + } else { + if (!OUTCOME_MAP[outcome]) { + throw new Error(`outcome must be one of ` + + `{${Object.keys(OUTCOME_MAP).join(', ')}}, got ${outcome}`); + } + if (!outcomeAllowed(start.stage, outcome)) { + throw new Error(`stage=${start.stage} disallows outcome=${outcome} (D5; ` + + `allowed: ${allowedOutcomes(start.stage).join(', ')})`); + } + } + const at = nowIso(); + + await appendRunRow(s, runId, { + runId, event: 'end', at, outcome, + durationMs: Date.now() - Date.parse(start.at), + tokens: tokens ?? start.tokens ?? null, note, pr: pr ?? undefined, writeLevel, + }); + + if (!t) { + await renderSummary(s); + return { task: null, actions: [], applied: [] }; + } + + if (decision) { + t.decision = typeof decision === 'string' ? JSON.parse(decision) : decision; + } + if (pr) { + const n = Number(pr); + if (!Number.isInteger(n) || n <= 0) throw new Error('pr must be a positive integer'); + if (!t.prs.includes(n)) t.prs.push(n); + } + const m = OUTCOME_MAP[outcome]; + t.status = m.status; + t.labels = m.label ? [m.label] : []; + t.timeline.push({ at, event: 'ended', by: runId, detail: `outcome=${outcome}${note ? ` — ${note}` : ''}` }); + await saveTask(s, t); + await releaseLock(s, t, t.stage); + + const actions = noGithub ? [] : planActions(t, outcome, { label: label ?? m.label, comment, note }); + let applied = []; + if (actions.length) { + if (writeLevel === 'auto') applied = applyActions(actions, { log, warn }); + else await appendActionBlock(s, runId, actions); + } + + await renderSummary(s); + return { task: t, actions, applied }; +} + +/* ------------------------------------------------------------------ CLI */ + +export function parseArgs(argv) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const eq = a.indexOf('='); + if (eq > 0) { + out[a.slice(2, eq)] = a.slice(eq + 1); + } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { + out[a.slice(2)] = argv[++i]; + } else { + out[a.slice(2)] = true; + } + } else { + out._.push(a); + } + } + return out; +}