From f5562b049314854b3e9e3a29ffea960ddd15de04 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 00:30:44 +0800 Subject: [PATCH 01/18] chore(loop): add Actions + R2 host for stage A1 (report-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 05 §7 stage A1 for tiny-oss: the event-triggered, headless host that runs the loop on GitHub Actions with the state layer in R2. - .github/workflows/loop.yml: single serial workflow (concurrency group `loop`) = scheduler + sandbox; read-only GITHUB_TOKEN, secrets only, `workflow_dispatch` with `execute_writes` for the L2 rehearsal. - scripts/loop/entry.mjs: orchestrator; routes events, writes inbox and acceptance, runs one stage, and is the single writer for closing runs. - scripts/loop/shared/: state-layer primitives, event routing (pure functions), pi driver, R2 sync — shared by the local CLI and the runner. - run.mjs now consumes the shared library; adds `--write-level` (report default = GitHub writes are listed, never executed). - .gitignore already ignored `lib` repo-wide, which would have silently excluded the shared library; the directory is `shared/` instead (D10). Engine = pi (verified: headless JSON event stream, built-in DeepSeek, ~21 MB, no install scripts, `--approve` required). Failure classification keeps provider/network flakes as `retry` instead of filling the human inbox. R2 has no object versioning, so sync never deletes (D-note in scripts/loop/README.md, which also carries the A1 acceptance list). Verified locally: CLI report boundary writes no GitHub actions, run closing is idempotent, stage→outcome whitelist enforced, event routing 17/17, acceptance write is deduplicated, system-level sweep/retro runs work, and a stubbed engine exercises prompt → result.json → state transition → Step Summary with token accounting. --- .github/workflows/loop.yml | 109 ++++++++ scripts/loop/README.md | 266 +++++++++++++++----- scripts/loop/entry.mjs | 296 ++++++++++++++++++++++ scripts/loop/r2-sync.mjs | 34 +++ scripts/loop/run.mjs | 442 ++++++++------------------------- scripts/loop/shared/agent.mjs | 178 ++++++++++++++ scripts/loop/shared/r2.mjs | 80 ++++++ scripts/loop/shared/route.mjs | 155 ++++++++++++ scripts/loop/shared/state.mjs | 451 ++++++++++++++++++++++++++++++++++ 9 files changed, 1607 insertions(+), 404 deletions(-) create mode 100644 .github/workflows/loop.yml create mode 100644 scripts/loop/entry.mjs create mode 100644 scripts/loop/r2-sync.mjs create mode 100644 scripts/loop/shared/agent.mjs create mode 100644 scripts/loop/shared/r2.mjs create mode 100644 scripts/loop/shared/route.mjs create mode 100644 scripts/loop/shared/state.mjs diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml new file mode 100644 index 0000000..6eae919 --- /dev/null +++ b/.github/workflows/loop.yml @@ -0,0 +1,109 @@ +name: Loop + +# 单一 workflow = 调度器 + 沙盒(05 §5.1):所有事件并入同一串行域, +# concurrency 组提供平台级单写者锁(R2 无并发写)。 +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(每周 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' + +concurrency: + group: loop + cancel-in-progress: false + +# 最小权限:读 GitHub 用 GITHUB_TOKEN;写操作(仅 execute_writes=true 预演)走 +# LOOP_GH_TOKEN,且只在默认分支上下文存在 —— fork 事件无 secrets(平台保证)。 +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + loop: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + LOOP_EVENT_NAME: ${{ github.event_name }} + LOOP_EVENT_ACTION: ${{ github.event.action }} + LOOP_EVENT_JSON: ${{ toJSON(github.event) }} + LOOP_REPO: ${{ github.repository }} + LOOP_WRITE_LEVEL: ${{ inputs.execute_writes == 'true' && 'auto' || 'report' }} + LOOP_MODEL: deepseek/deepseek-flash + LOOP_SESSION_DIR: ${{ runner.temp }}/pi-sessions + LOOP_RUN_TIMEOUT_MS: '3300000' # 55min,留出 push 余量(job timeout 60min) + GH_TOKEN: ${{ secrets.LOOP_GH_TOKEN || github.token }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + + steps: + # checkout 默认分支:loop 需要本仓的 skills/ 与 scripts/loop/, + # 且**绝不检出 fork 代码**(外部 PR 的 diff 用 gh 读取)。 + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch || github.ref }} + + - 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 ---' + pi --list-models deepseek || true + + - name: Pull state from R2 + run: node scripts/loop/r2-sync.mjs pull + + - name: Run stage + run: node scripts/loop/entry.mjs + + # 崩溃/超时也要把状态层推回(任务留在 processing,由下次 sweep 按 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 diff --git a/scripts/loop/README.md b/scripts/loop/README.md index f852ca6..f13adc3 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,180 @@ 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. + +### 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 + +- Bucket `loop-state-alex1990`, prefix `state/tiny-oss/`; keys mirror `state/`. +- **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 + +R2 (Cloudflare dashboard): + +1. R2 → **Create bucket** → `loop-state-alex1990`. +2. R2 → **API → Manage R2 API Tokens → Create API Token** → permission + *Object Read & Write* → **specify bucket `loop-state-alex1990` only**. +3. Note **Access Key ID**, **Secret Access Key**, and the **Account ID**. + +GitHub → Settings → Secrets and variables → Actions: + +| Secret | Value | +| --- | --- | +| `R2_ACCOUNT_ID` | Cloudflare account ID | +| `R2_ACCESS_KEY_ID` | token access key id | +| `R2_SECRET_ACCESS_KEY` | token secret | +| `R2_BUCKET` | `loop-state-alex1990` | +| `DEEPSEEK_API_KEY` | LLM key (DeepSeek platform) | +| `LOOP_GH_TOKEN` | fine-grained PAT (contents/issues/PRs write, this repo only); optional while report-only | + +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=... R2_BUCKET=loop-state-alex1990 \ + node scripts/loop/r2-sync.mjs seed # then `check` +``` + +`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. + +- [ ] `workflow_dispatch` smoke: real issue → triage run → task file + state + transition correct, **GitHub unchanged** +- [ ] Every route has one real execution: issue triage / bugfix-feature / deps / + external-PR readonly / sweep / release preflight +- [ ] 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 +- [ ] Engine stability: N consecutive headless pi runs without hanging; exit-code + mapping matches the failure-classification table +- [ ] Cost readable: every run's `tokens`/`durationMs` land in the end row; the + `pi --list-models deepseek` line confirms the real model id +- [ ] Reports human-readable: the Step Summary alone tells you what happened, + without downloading anything +- [ ] State layer and GitHub show no drift 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 +209,34 @@ 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`. - -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. +- [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. + +## Environment facts + +- 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. +- `gh` works from this machine (list/view in seconds); the older note about + direct GitHub timeouts and a stopped proxy is stale. +- No `rclone` and no `aws` CLI on this Windows box — R2 sync is runner-side only; + local seeding needs an AWS CLI installed first. +- Model id: `LOOP_MODEL=deepseek/deepseek-flash` is **unconfirmed** against pi's + built-in DeepSeek catalogue; the workflow prints `pi --list-models deepseek` + on every run so the first dispatch settles it. If the id does not appear, + declare it explicitly in `~/.pi/agent/models.json` or switch to the official + DeepSeek model id. diff --git a/scripts/loop/entry.mjs b/scripts/loop/entry.mjs new file mode 100644 index 0000000..c067484 --- /dev/null +++ b/scripts/loop/entry.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node +/** + * Loop runner 编排入口 —— GitHub Actions job 调用的唯一脚本。 + * + * 一次调用 = 一个决策:路由事件 → 落 inbox / 写评测 / 跑一个 run。 + * 状态层读写全部经 `lib/state.mjs`;本文件只做编排与报告,不直接改状态字段。 + * + * 不变量: + * - 收尾是**唯一写入者**:agent 只写 `state/reports/.result.json`, + * 由本文件调 finishRun 落状态(05 §5.1 把收尾列为 run 脚本仪式)。 + * - report 边界下不产生任何 GitHub 写操作(写动作只作为清单进报告)。 + * - 事件幂等:`eventIds` 记已消费事件键,重复投递 → no-op。 + * + * 环境契约(workflow 注入): + * LOOP_EVENT_NAME / LOOP_EVENT_ACTION / LOOP_EVENT_JSON / LOOP_REPO + * LOOP_WRITE_LEVEL(report|auto,默认 report) + * LOOP_MODEL(pi 的 --model,如 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, 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 读取 */ + +function ghJson(args) { + const r = spawnSync('gh', args, { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`gh ${args.join(' ')} 失败: ${(r.stderr || '').trim()}`); + return JSON.parse(r.stdout); +} + +const GH_FIELDS = 'number,title,body,state,labels,url'; + +/** 任务文件不存在时从 GitHub 拉取建档;已存在则镜像标签(GitHub 为准)。 */ +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(`任务 #${t.id} 已建档(${kind})`); + return t; +} + +const claimability = (t) => { + if (t.status === 'processing') { + return lockExpired(t.lockedBy) + ? { ok: true, resume: true } + : { ok: false, reason: `被 ${t.lockedBy?.runId} 持有` }; + } + return ['new', 'ready', 'waiting-info'].includes(t.status) + ? { ok: true } + : { ok: false, reason: `status=${t.status}` }; +}; + +/* -------------------------------------------------------------- 事件入箱 */ + +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)}`; +} + +/** + * 信息补足事件:永不静默丢弃。先落 eventInbox,再按任务状态决定是否补跑(05 §3 判定顺序)。 + */ +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) return { state: 'noop', note: `#${taskId} 无对应任务,事件不入箱` }; + if ((t.eventIds ?? []).includes(key)) return { state: 'noop', note: `事件已消费(${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 补跑原 stage;waiting-info 重新 triage;其余状态留待消费方接手 + if (['new', 'ready'].includes(t.status)) { + return { state: 'run', taskId: t.id, stage: t.stage ?? 'triage', kind: t.kind, note: '补跑' }; + } + if (t.status === 'waiting-info') { + return { state: 'run', taskId: t.id, stage: 'triage', kind: t.kind, note: '补充信息后重新 triage' }; + } + return { state: 'inbox-only', note: `任务 status=${t.status},事件已入箱待消费` }; +} + +/* ---------------------------------------------------------------- 评测 */ + +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) return { state: 'skipped', note: `任务 #${task.id} 不可领取:${claim.reason}` }; + if (claim.resume) warn(`任务 #${task.id} 锁已过期,接管续跑`); + } + + 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} 开始(${task ? `task #${task.id}, ` : '系统级, '}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); + + // agent 的结论文件(编排层是收尾的唯一写入者) + 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 判定(exit=${res.code})`; + } else if (result?.outcome) { + outcome = 'failed'; + note = `agent 给出 outcome=${result.outcome},但 stage=${stage} 不允许(允许: ${allowedOutcomes(stage).join(', ')})`; + warn(note); + } else { + outcome = cls.exit === 1 ? 'failed' : 'retry'; + note = `${cls.reason}(无结果文件)`; + warn(`未取到 ${rid}.result.json —— ${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} 收尾: outcome=${outcome} (exit ${cls.exit})${usage?.totalTokens ? `, tokens=${usage.totalTokens}` : ''}`); + if (actions.length) { + log(writeLevel === 'auto' + ? `GitHub 动作已执行 ${actions.length} 项` + : `GitHub 动作未执行(report 边界):${actions.length} 项待人工,见报告与 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('(无 GITHUB_STEP_SUMMARY,跳过)'); 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'; + const writeLevel = process.env.LOOP_WRITE_LEVEL ?? 'report'; + + await ensureDirs(S); + const ctx = { eventName, action, event }; + const decision = route(ctx); + log(`event=${eventName}.${action || '(none)'} → ${decision.action}(${decision.reason})· writeLevel=${writeLevel}`); + + 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 ? `评测已写入:${JSON.stringify(result.row)}` : `评测已存在,跳过(幂等)`); + } + + await writeStepSummary(renderSummary({ decision, result, writeLevel })); + return 0; // noop / inbox-only / metrics / 已完成的 run 都是成功退出 +} + +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..0b6f39a --- /dev/null +++ b/scripts/loop/r2-sync.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * R2 状态层同步 CLI —— workflow 的独立步骤调用(不依赖 agent 是否跑完)。 + * + * 用法: node scripts/loop/r2-sync.mjs pull|push|seed|check + * pull job 开始:远端 → 本地 state/(失败必须中止,否则 push 会覆盖远端为本地空态) + * push job 结束(if: always()):本地 state/ → 远端(不带 --delete,R2 无版本化) + * seed 一次性播种本地已有状态层 + * 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('用法: 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..b436dd3 100644 --- a/scripts/loop/run.mjs +++ b/scripts/loop/run.mjs @@ -1,132 +1,71 @@ #!/usr/bin/env node /** - * tiny-oss Loop A0 本地手动宿主(自建形态最小适配) + * tiny-oss Loop 本地宿主(CLI) * - * 目标:在本机手动试跑 Loop run 契约([[tiny-oss-runtime]] §5),不依赖 - * GitHub Actions / R2。状态层布局 = memory §3.1(tasks/ runs/ locks/ - * metrics/ SUMMARY.md),位于仓库根 `state/`(已 gitignore)。 + * 状态层原语全部来自 `./lib/state.mjs`(与 runner 编排 run-stage.mjs 共用, + * 保证单一实现)。本文件只负责人工驱动的命令行仪式。 * - * 用法(Windows 与 POSIX 一致;`pnpm loop` = 本文件,见 package.json): + * 用法(`pnpm loop` = 本文件,见 package.json): * pnpm loop start [--stage ] [--task ] - * [--new --title "..." --body "..."] # 新建本地任务 + * [--new --title "..." --body "..."] # 新建本地合成任务 * [--issue ] # 导入 GitHub issue(需 gh,只读) * pnpm loop checkpoint --run --note "..." * pnpm loop end --run --outcome [--comment "..."] [--label ] - * [--no-github] # 或 --task 反查未收尾 run(D2) + * [--task ] # 反查未收尾 run(D2) * pnpm loop summary | view [--run | --task ] * - * outcome 取值与状态转移(ops.md Labels→task state): - * triaged → ready + ready-for-agent (triage 判 bug/feature) + * 写边界(--write-level | LOOP_WRITE_LEVEL,默认 report): + * report 只写状态层 + 报告:GitHub 写动作仅以"待人工执行"清单呈现(A1 语义) + * auto 执行打标/评论/关闭(A0 已放权语义;升 L2 后为常态) + * + * outcome 取值与状态转移(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:start 领取时若本地任务为终态(closed/rejected)而 GitHub issue 已重开为 OPEN, + * 自动重置 ready + 清 decision + timeline 记 reopened 再领取,回 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'; -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' }; +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 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]); +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const S = makeState(ROOT); -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 须为 ${WRITE_LEVELS.join('|')}(当前: ${wl})`); + return wl; } /* ---------------------------------------------------------------- start */ async function cmdStart(args) { const { stage, task, 'new': isNew, title, body, issue, url } = args; - await ensureDirs(); + await ensureDirs(S); const s = stage ?? 'triage'; if (!stage) console.log('[loop] --stage 缺省,默认 triage'); @@ -150,7 +89,7 @@ async function cmdStart(args) { agentPlan: null, prs: [], runs: [], timeline: [], }; } else { - id = await nextTaskId(); + id = await nextTaskId(S); t = { id, kind: 'issue', title, url: url ?? null, body: body ?? '', status: 'new', stage: null, labels: [], @@ -159,31 +98,31 @@ async function cmdStart(args) { }; 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(`任务 #${id} 已存在,拒绝覆盖(${S.taskFile(id)});重跑请用 start --task ${id} [--stage ](D3)`); + } t.timeline.push({ at: t.createdAt, event: 'created', by: 'manual', ...extra }); - await writeJson(file, t); + await writeJson(S.taskFile(id), t); console.log(`[loop] 任务 #${id} 已创建(${isNew ? '本地合成' : 'GitHub 导入'})`); } else { - t = await loadTask(task ?? fail('--task 或 --new/--issue 必填')); + t = await loadTask(S, task ?? fail('--task 或 --new/--issue 必填')); } - // 领取检查:new/ready/waiting-info 可领;processing 活锁拒领、死锁(TTL>1h)可覆盖; - // 终态(closed/rejected)任务若 GitHub issue 已重开(OPEN)→ 自动重置为 ready 再领取(D9) + // 领取检查:new/ready/waiting-info 可领;processing 活锁拒领、死锁可覆盖; + // 终态任务而 GitHub issue 已重开 → 自动重置 ready 再领取(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(`任务 #${t.id} 被 ${t.lockedBy?.runId ?? '?'} 持有(status=processing),请先结束或等 TTL 过期`); + } + console.warn(`[loop] warn: 任务 #${t.id} 的锁已过期(${t.lockedBy?.runId}),本次接管续跑`); } 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: 任务 #${t.id} 本地 status=${t.status},GitHub issue 已重开 → 重置为 ready(D9)`); t.status = 'ready'; t.decision = null; t.timeline.push({ at: nowIso(), event: 'reopened', by: 'github', detail: 'GitHub issue 重开 → 终态重置为 ready(D9)' }); @@ -193,74 +132,33 @@ async function cmdStart(args) { fail(`任务 #${t.id} status=${t.status} 不可领取(可领: ${claimable.join('/')};GitHub 已重开的终态任务会自动放行)`); } - 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] task #${t.id}(stage=${s})已领取 → ${path.relative(ROOT, S.taskFile(t.id))}`); console.log('[loop] 开场仪式:先读 AGENTS.md → docs/agents/ops.md → 对应 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)`); - } + const hasSkill = await fs.access(skillFile).then(() => true, () => false); + console.log(hasSkill + ? `[loop] skills/${s}/SKILL.md` + : `[loop] skills/${s}/SKILL.md 不存在(该 stage 无专属 skill;用到 verify/review 时读对应 SKILL.md)`); + console.log(`[loop] 写边界: ${resolveWriteLevel(args)}(report = 只写状态层与报告,GitHub 写动作仅列出)`); console.log('[loop] 过程可记 checkpoint;完成后执行:'); - console.log(` pnpm loop end --run ${rid} --outcome [--comment "..."] [--note "..."]`); + 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); + 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`); @@ -268,201 +166,68 @@ async function cmdEnd(args) { 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(', ')})`); - } - 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} 收尾: 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 写动作已执行 ${applied.length}/${actions.length} 项`); + } else { + console.log('[loop] 写边界=report:以下 GitHub 动作待人工执行'); + for (const d of describeActions(actions)) console.log(` - ${d}`); + console.log(`[loop] 已追加到报告: ${path.relative(ROOT, path.join(S.reportsDir, `${rid}.md`))}`); + } + } else if (noGithub) { + console.log('[loop] --no-github:跳过 GitHub 写动作'); } + console.log('[loop] SUMMARY.md 已刷新'); + } 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 }); + const rows = await readRunLines(S, rid); + if (!rows.length) fail(`run ${rid} 不存在`); + await appendRunRow(S, 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'); -} - /* ---------------------------------------------------------------- view */ 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} 不存在`); + 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); @@ -473,19 +238,20 @@ async function main() { [--new --title ".." [--body ".."] | --issue ] 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] # 默认 report:GitHub 写动作仅列出 pnpm loop summary | view [--run |--task ] outcome: ${Object.keys(OUTCOME_MAP).join(' | ')} -state 根: ${STATE}(env STATE_DIR 可覆盖)`); +state 根: ${S.root}(env STATE_DIR 可覆盖) +写边界: LOOP_WRITE_LEVEL=report|auto(默认 report)`); return; } const args = parseArgs(argv.slice(1)); - await ensureDirs(); + await ensureDirs(S); 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 === 'summary') return renderSummary(S); if (cmd === 'view') return cmdView(args); fail(`未知子命令 ${cmd}(--help 查看用法)`); } diff --git a/scripts/loop/shared/agent.mjs b/scripts/loop/shared/agent.mjs new file mode 100644 index 0000000..0d62ff0 --- /dev/null +++ b/scripts/loop/shared/agent.mjs @@ -0,0 +1,178 @@ +/** + * 驱动 pi 无头执行一个 loop run。 + * + * 分工:开场/执行/收尾由 agent 按 prompt 完成(与 A0 的 skill 约定一致); + * 本模块只负责构造 prompt、spawn、采集用量与退出信号、分类失败。 + * 状态写入仍由 agent 调 `pnpm loop end` 触发(复用 run.mjs 的白名单与幂等), + * 编排层只在该调用缺失时兜底。 + * + * pi 事实(2026-09 一手验证): + * - `--mode json` → JSONL 事件流(session / agent_start / turn_* / message_* / + * tool_execution_* / agent_end);末轮 message_end 带权威 usage 与 stopReason。 + * - `--approve` 必需:非交互模式不弹 trust prompt,缺它就忽略项目本地资源。 + * - 退出码:正常 0;末轮 stopReason=error|aborted → 1;SIGTERM 143 / SIGHUP 129。 + * - print 模式合并管道 stdin 到初始 prompt(大 prompt 走 stdin,避开 argv 上限)。 + */ + +import { spawn } from 'node:child_process'; + +/** 提示词语言与 skills/AGENTS.md 保持一致(英文),避免 agent 在英文规范里读中文指令。 */ +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: \`outcome\` is recorded on the end row only (no task`); + L.push(' transition). Use the outcome the skill prescribes for the sweep/retro it performed.'); + } + L.push('3. Record checkpoints while working so a crash can resume meaningfully.'); + return L.join('\n'); +} + +/** JSONL → 事件数组;非 JSON 行(进度提示等)忽略。 */ +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 { /* 忽略非事件行 */ } + } + return events; +} + +/** 从事件流提取用量与末轮停止原因(usage 只在 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, + }; +} + +/** 采样 pi 的会话文件(artifact 用):返回指定目录下最新的 .jsonl。 */ +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); + + // 引擎是模板层的实例变量 {{engine}}(05 §4.2):默认 pi,可用 LOOP_ENGINE_CMD 覆盖 + // (如本地用别的 CLI/包装脚本跑同一契约)。 + 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 超时(${Math.round(timeoutMs / 60000)}min),发送 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 }); + }); + + child.stdin.on('error', () => { /* spawn 失败或提前退出时的 EPIPE 不应炸掉流程 */ }); + child.stdin.write(prompt); + child.stdin.end(); + }); +} + +/** + * 失败分类(Q6 决策):机器故障 → 重试;agent 判定失败 → 收件箱;分不清 → 保守重试。 + * 收件箱是给人看的,不该被 provider 抖动灌满(否则人工介入率失去意义)。 + * 退出码沿用 run 契约:0 完成 / 1 失败→收件箱 / 2 卡死中止 / 3 建议重试。 + */ +export function classify({ code, signal, stderr, timedOut, stopReason }) { + if (timedOut) return { exit: 2, kind: 'abort', reason: 'run 超时中止' }; + if (code === 0) return { exit: 0, kind: 'ok', reason: 'pi 正常退出' }; + if (code === 143 || code === 129 || signal) { + return { exit: 2, kind: 'abort', reason: `被信号终止(${signal ?? code})` }; + } + if (code === -1) return { exit: 3, kind: 'retry', reason: 'pi 无法启动(安装/路径问题)' }; + const s = String(stderr ?? ''); + 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: '疑似 provider/网络瞬时故障 → 重试' }; + } + if (stopReason === 'error' || stopReason === 'aborted') { + return { exit: 1, kind: 'failed', reason: `agent 停止原因=${stopReason} → 转收件箱` }; + } + return { exit: 3, kind: 'retry', reason: `退出码 ${code} 无法归因 → 保守重试` }; +} diff --git a/scripts/loop/shared/r2.mjs b/scripts/loop/shared/r2.mjs new file mode 100644 index 0000000..fc58b7b --- /dev/null +++ b/scripts/loop/shared/r2.mjs @@ -0,0 +1,80 @@ +/** + * R2 状态层同步(pull / push / seed)。 + * + * 工具选择:runner 镜像(ubuntu-24.04)预装 AWS CLI v2(2.36.x)→ 直接走 R2 的 + * S3 兼容端点,零安装、零新增仓库依赖(不引 @aws-sdk/client-s3)。 + * + * 取值要点: + * - region 传 `auto`:R2 要求非空但不使用该值(官方 aws CLI 文档)。 + * - `AWS_REQUEST_CHECKSUM_CALCULATION=when_required`:AWS CLI ≥ 2.23.0 默认对上传 + * 计算 CRC32 校验和,对部分 S3 兼容服务不友好;按需计算是兼容性最保守的一档。 + * - `AWS_EC2_METADATA_DISABLED=true`:避免在非 EC2 环境探测实例元数据拖慢/报错。 + * + * 失败语义(重要):pull 失败必须中止整条链 —— 否则本地 state/ 为空, + * 随后的 push --delete 会把远端状态层清空。 + */ + +import { spawnSync } from 'node:child_process'; + +export const R2_PREFIX = 'state/tiny-oss/'; + +const REQUIRED = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET']; + +export function r2Env() { + const missing = REQUIRED.filter((k) => !process.env[k]); + if (missing.length) { + throw new Error(`缺少 R2 环境变量: ${missing.join(', ')}(应配为 Actions secrets,勿写进仓库文件)`); + } + 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', + }; +} + +const endpoint = () => `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; +const s3url = (prefix) => `s3://${process.env.R2_BUCKET}/${prefix}`; + +function aws(args, { label }) { + const r = spawnSync('aws', args, { env: r2Env(), encoding: 'utf8' }); + const out = (r.stdout ?? '').trim(); + const err = (r.stderr ?? '').trim(); + if (r.error) throw new Error(`[r2:${label}] 无法执行 aws cli:${r.error.message}`); + if (r.status !== 0) throw new Error(`[r2:${label}] aws cli 退出码 ${r.status}\n${err || out}`); + if (out) console.log(out); + if (err) console.error(err); + return out; +} + +/** 远端 → 本地。不做 --delete:runner 上 state/ 全新,陈旧文件问题不存在。 */ +export function pull(dir, { prefix = R2_PREFIX } = {}) { + return aws(['s3', 'sync', s3url(prefix), dir, '--endpoint-url', endpoint()], { label: 'pull' }); +} + +/** + * 本地 → 远端。**不带 `--delete`**:R2 没有对象版本化(Cloudflare 文档无此能力, + * 仅有 bucket lock 保留策略),删除不可回滚 —— 因此只做"覆盖 + 新增"。 + * 代价是 run 期间删掉的过期锁文件会残留在远端,由 TTL 语义消化(lockExpired)。 + */ +export function push(dir, { prefix = R2_PREFIX } = {}) { + return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'push' }); +} + +/** + * 首次播种:本地 → 远端,**不带 --delete**(只增不删)—— + * 远端已有对象时宁可保守,不冒清空风险。 + */ +export function seed(dir, { prefix = R2_PREFIX } = {}) { + return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'seed' }); +} + +/** 连通性与凭据自检:列出前缀下对象数(空桶同样算通过)。 */ +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] 连通 OK,前缀 ${prefix} 现有 ${n} 个对象`); + return n; +} diff --git a/scripts/loop/shared/route.mjs b/scripts/loop/shared/route.mjs new file mode 100644 index 0000000..c682c83 --- /dev/null +++ b/scripts/loop/shared/route.mjs @@ -0,0 +1,155 @@ +/** + * GitHub 事件 → Loop 决策(纯函数路由)。 + * + * 权威:05 宿主实现规格 §3 路由表 / docs/agents/ops.md "Trigger → loop map"。 + * 本模块不做任何 IO:输入是 workflow 注入的事件上下文,输出 Decision,由 entry.mjs 执行。 + * 纯函数 = 可用固定事件样本离线回归,不必触发真实 GitHub 事件。 + */ + +/** 视为"本仓自己人"的 author_association(其余 = 外部贡献者)。 */ +export const OWNER_ROLES = ['OWNER', 'MEMBER', 'COLLABORATOR']; + +/** 每周 retro 的 cron(与 loop.yml 的 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 判定:head 分支 `loop/-*` 且 body 含 `loop-task: #`(两处一致才认)。 */ +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 ?? ''); + +/** + * 幂等键:同键事件已消费过则跳过。 + * 优先用不可变标识(PR head sha / comment id),退回 issue 的 updated_at 版本号。 + */ +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}` }); + } + // 信息补足:永不静默丢弃,先落 inbox 再按任务状态决定是否补跑(05 §3 判定顺序) + if (action === 'edited') { + return inbox({ taskId: n, kind: 'issue', stage: 'triage', reason: 'issues.edited' }); + } + return noop(`issues.${action} 无 Loop 动作`); + } + + case 'issue_comment': { + if (action !== 'created') return noop(`issue_comment.${action} 无 Loop 动作`); + 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} 走其他路由`); + } + 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: '外部 PR 只读分析' }); + } + // PR 与 issue 共用 triage 面(issue-tracker.md: PRs ARE a triage surface) + return run({ taskId: pr.number, kind: 'pr', stage: 'triage', reason: `本仓 PR ${action}` }); + } + + case 'pull_request_review': { + const pr = event.pull_request ?? {}; + if (action !== 'submitted' && action !== 'dismissed') { + return noop(`pull_request_review.${action} 无 Loop 动作`); + } + const loopTask = loopTaskOf(pr); + if (!loopTask) return noop('非 loop PR 的 review 不触发 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} 无 Loop 动作`); + const loopTask = loopTaskOf(pr); + if (!loopTask) return noop('非 loop PR 的关闭不计入自动接受率'); + 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} 无 Loop 动作`); + 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 未提供 task/stage'); + } + + default: + return noop(`未监听的事件 ${eventName}`); + } +} diff --git a/scripts/loop/shared/state.mjs b/scripts/loop/shared/state.mjs new file mode 100644 index 0000000..a70731f --- /dev/null +++ b/scripts/loop/shared/state.mjs @@ -0,0 +1,451 @@ +/** + * tiny-oss Loop 状态层原语 —— 本地宿主 `run.mjs` 与 Actions 编排 `run-stage.mjs` 共用。 + * + * 唯一写入者原则:两个入口都只经本模块读写 `state/`,避免两份实现漂移 + * (A0 期 #33 的 acceptance 漏行就是人手漂移的实例)。 + * + * schema:任务文件 / run 行 / 锁 / SUMMARY 见 scripts/loop/README.md; + * 状态机与双口径定义见 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'; + +/* ------------------------------------------------------------ 领域常量 */ + +/** 五角色标签(triage-labels.md):互斥,禁止叠加矛盾角色。 */ +export const ROLE_LABELS = ['needs-triage', 'needs-info', 'ready-for-agent', 'ready-for-human', 'wontfix']; + +/** outcome → 任务终态 + 应打标签(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' }, + // 宿主扩展(不在 ops.md 的 outcome 表内):provider/网络瞬时故障, + // 任务应回到可领取态而不是灌进收件箱 —— 收件箱是给人看的(run 契约退出码 3)。 + 'retry': { status: 'ready', label: 'ready-for-agent' }, +}; + +/** stage → 允许的成功 outcome 白名单(D5 防呆);未列出的 stage 全放行。 */ +const COMMON_OUTCOMES = ['failed', 'rejected', 'accepted', 'retry']; +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] ?? [])]; +} + +/* ---------------------------------------------------------------- 时钟 */ + +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()}`; +}; + +/* ------------------------------------------------------------ 状态层路径 */ + +/** + * 构造状态层句柄。`stateRoot` 默认 `/state`,`STATE_DIR` 环境变量可覆盖 + * (runner 上锚定到 job 工作目录,本地锚定到仓库根)。 + */ +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 })), + ); +} + +/* --------------------------------------------------------------- 文件 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)); + +/* ------------------------------------------------------------ 任务操作 */ + +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} 不存在(${s.taskFile(id)})`); + return t; +} + +export async function saveTask(s, t) { + t.updatedAt = nowIso(); + await writeJson(s.taskFile(t.id), t); +} + +/** 下一本地任务 id:数字文件名最大 +1;空目录从 9000 起(避开 GitHub 编号段)。 */ +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; +} + +/* ------------------------------------------------------------------ 锁 */ + +/** 全托管形态下互斥由 workflow `concurrency` 承担;锁文件退化为记录与防呆。 */ +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 }); +} + +/* -------------------------------------------------------------- 评测写入 */ + +const acceptanceKey = (r) => `${r.taskId}|${r.event}|${r.pr ?? ''}`; + +/** + * 追加一条评测事件;同 `taskId+event+pr` 已存在则跳过(幂等)。 + * 写入者 = workflow job(agent 不写);sweep 补写带 `sweep-corrected`。 + * 返回 true 表示本次确实写入。 + */ +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 状态摘要 (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('运行中', 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('待人工放行 (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('收件箱 (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)', 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)})`; + })); + + 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('## 终态计数', ''); + L.push(`closed=${c('closed')} accepted=${c('accepted')} rejected=${c('rejected')}`, ''); + } + + // 最近评测(metrics/acceptance.jsonl:workflow 写,agent 不写) + const acc = (await readJsonl(s.acceptanceFile)).slice(-5); + if (acc.length) { + L.push('## 最近评测', ''); + for (const r of acc) { + L.push(`- ${r.event} task #${r.taskId}${r.pr ? ` pr #${r.pr}` : ''} accepted=${r.accepted ?? '-'} (${r.writer ?? '?'})`); + } + L.push(''); + } + + // 近期 run 尾部(≤5 条,防超长) + const runFiles = (await fs.readdir(s.runsDir).catch(() => [])) + .filter((f) => f.endsWith('.jsonl')).sort().slice(-5); + if (runFiles.length) { + L.push('## 近期 run', ''); + 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'); + if (st) L.push(`- ${rid} task #${st.taskId} ${st.stage}${en ? ` → ${en.outcome}` : ' (未收尾)'}`); + } + L.push(''); + } + + const summary = L.join('\n').split('\n').slice(0, 60).join('\n'); + await fs.writeFile(s.summaryFile, summary.trimEnd() + '\n', 'utf8'); +} + +/* ------------------------------------------------------------ GitHub 动作 */ + +export function ghRepoOf(url) { + const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/\d+/.exec(url ?? ''); + return m ? `${m[1]}/${m[2]}` : null; +} + +/** + * 由 outcome 推导 GitHub 写动作清单 —— **纯函数,不触网**。 + * + * report 模式只调用本函数并把 `describeActions` 的输出写进报告; + * auto 模式(L2+ / 预演)才交给 `applyActions` 执行。 + * 这是"严格只报告"的唯一开关点:不调 apply 即不写 GitHub。 + */ +export function planActions(t, outcome, { label, comment, note } = {}) { + if (!t.url) return []; // 本地合成任务无 GitHub 目标 + const repo = ghRepoOf(t.url); + if (!repo) return []; + const acts = []; + const l = label ?? OUTCOME_MAP[outcome]?.label ?? null; + if (l) acts.push({ kind: 'label', repo, number: t.id, label: l, stripRoles: true }); + if (outcome === 'closed') { + acts.push({ kind: 'close', repo, number: t.id, body: comment ?? note ?? 'Closed by the loop.' }); + } else if (comment) { + acts.push({ kind: 'comment', repo, number: t.id, body: comment }); + } + return acts; +} + +/** 人读描述,供 report 模式的报告与 Step Summary 使用。 */ +export function describeActions(actions) { + return actions.map((a) => { + if (a.kind === 'label') { + return `label #${a.number}: +${a.label}${a.stripRoles ? ' (先移除其它五角色标签)' : ''}`; + } + 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); + +/** 执行写动作(仅 auto / 预演路径调用)。失败只 warn,不回滚本地状态。 */ +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' }); + if (a.kind === 'label') { + let stale = []; + if (a.stripRoles) { + const cur = gh(['issue', '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 = ['issue', '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: 打标 ${a.label} 失败: ${(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(['issue', 'close', String(a.number), '-R', a.repo, '--comment', a.body]); + if (r.status !== 0) warn(`[loop] warn: close #${a.number} 失败: ${(r.stderr || '').trim()}`); + else { log(`[loop] gh: #${a.number} closed with comment`); applied.push(a); } + } else if (a.kind === 'comment') { + const r = gh(['issue', 'comment', String(a.number), '-R', a.repo, '--body', a.body]); + if (r.status !== 0) warn(`[loop] warn: 评论 #${a.number} 失败: ${(r.stderr || '').trim()}`); + else { log(`[loop] gh: #${a.number} commented`); applied.push(a); } + } + } + return applied; +} + +/* ---------------------------------------------------------- run 生命周期 */ + +/** 领取任务并写 start 行,返回 runId。`task` 为 null 表示系统级 run(sweep/retro/release 预检)。 */ +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 模式:把待人工执行的写动作追加到该 run 的报告文件。 */ +export async function appendActionBlock(s, runId, actions) { + const file = path.join(s.reportsDir, `${runId}.md`); + await fs.mkdir(s.reportsDir, { recursive: true }); + const block = [ + '', '## 待人工执行的 GitHub 动作(写边界=report)', '', + ...describeActions(actions).map((d) => `- [ ] ${d}`), '', + ].join('\n'); + await fs.appendFile(file, block, 'utf8'); + return file; +} + +/** + * 收尾一个 run:写 end 行 → 更新任务 → 释放锁 → GitHub 动作(report 只列出,auto 才执行) + * → 刷新 SUMMARY。本地 CLI 与 runner 编排共用,保证幂等与白名单只有一份实现。 + */ +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 || !OUTCOME_MAP[outcome]) { + throw new Error(`outcome 必填且 ∈ {${Object.keys(OUTCOME_MAP).join(', ')}}`); + } + const rows = await readRunLines(s, runId); + if (!rows.length) throw new Error(`run ${runId} 不存在(${s.runFile(runId)})`); + if (rows.some((r) => r.event === 'end')) throw new Error(`run ${runId} 已有 end 行,重复收尾被拒绝(幂等)`); + const start = rows[0]; + // 系统级 run(sweep/retro/release 预检)无任务文件:只记 run 行,不做状态转移。 + const t = start.taskId ? await loadTask(s, start.taskId) : null; + if (t && !outcomeAllowed(start.stage, outcome)) { + throw new Error(`stage=${start.stage} 不允许 outcome=${outcome}(D5;允许: ${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 须为正整数'); + 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; +} From bc39614412a601135b1f65e8cdc4fe9aeb8c755d Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 00:32:59 +0800 Subject: [PATCH 02/18] chore(loop): repair workflow contexts that broke parsing GitHub could not parse loop.yml at all: the job-level `env` referenced `runner.temp`, and the `runner` context only exists inside steps. The only signal was a zero-second failed run titled with the file path, so the workflow was never registered under its own name. - Drop `LOOP_SESSION_DIR` from job env; set it on the step that needs it. - Replace the `inputs.*` write-level expression with `github.event.inputs.execute_writes` (null on non-dispatch events, hence the `|| 'false'` fallback), which keeps the default report-only. - entry.mjs derives the write level from that flag. Verified with actionlint: the previous revision fails with `context "runner" is not allowed here` (line 56), the fixed file passes. --- .github/workflows/loop.yml | 11 ++++++++--- scripts/loop/entry.mjs | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index 6eae919..bcc21cf 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -46,14 +46,17 @@ jobs: loop: runs-on: ubuntu-24.04 timeout-minutes: 60 + # 注意:job 级 env 只能引用 github / secrets / vars 等上下文。 + # `inputs` 在非 workflow_dispatch 事件下不可用,`runner` 上下文只存在于 step 内 —— + # 两者都不放在这里(曾经导致 workflow 解析失败,GitHub 只报 "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 }} - LOOP_WRITE_LEVEL: ${{ inputs.execute_writes == 'true' && 'auto' || 'report' }} + # 非 dispatch 事件下 github.event.inputs 为 null → 取默认 report(严格只报告) + LOOP_EXECUTE_WRITES: ${{ github.event.inputs.execute_writes || 'false' }} LOOP_MODEL: deepseek/deepseek-flash - LOOP_SESSION_DIR: ${{ runner.temp }}/pi-sessions LOOP_RUN_TIMEOUT_MS: '3300000' # 55min,留出 push 余量(job timeout 60min) GH_TOKEN: ${{ secrets.LOOP_GH_TOKEN || github.token }} DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} @@ -92,6 +95,8 @@ jobs: 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 # 崩溃/超时也要把状态层推回(任务留在 processing,由下次 sweep 按 TTL 回收) @@ -106,4 +111,4 @@ jobs: name: loop-sessions-${{ github.run_id }} path: ${{ runner.temp }}/pi-sessions retention-days: 90 - if-no-files-found: ignore + if-no-files-found: ignore \ No newline at end of file diff --git a/scripts/loop/entry.mjs b/scripts/loop/entry.mjs index c067484..8078824 100644 --- a/scripts/loop/entry.mjs +++ b/scripts/loop/entry.mjs @@ -259,7 +259,10 @@ async function main() { 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'; - const writeLevel = process.env.LOOP_WRITE_LEVEL ?? 'report'; + // 写边界:workflow 传 LOOP_EXECUTE_WRITES(workflow_dispatch 的预演开关), + // 也接受显式 LOOP_WRITE_LEVEL 覆盖(本地调试用)。 + const writeLevel = process.env.LOOP_WRITE_LEVEL + ?? (process.env.LOOP_EXECUTE_WRITES === 'true' ? 'auto' : 'report'); await ensureDirs(S); const ctx = { eventName, action, event }; From 5b91b9ebd43d5d01d1c36219bc6ad30b573eaf26 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 00:35:05 +0800 Subject: [PATCH 03/18] chore(loop): keep credential failures out of the human inbox, log outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the runner: pi installs in 6s (132 packages, v0.85.1), so the engine choice holds on Actions. That run also showed `pi --list-models deepseek` printing "No models available. Use /login ..." when no key is present — the catalogue is credential-gated, and a missing key would have been classified as an agent failure, pushing tasks into the human inbox. Credential/config errors now classify as `retry`. Also: a `retry` outcome proposed a `ready-for-agent` label even though the run never did any work — such runs now propose no GitHub actions at all. Env facts updated with the measured install cost/version and the credential-gated catalogue finding. --- scripts/loop/README.md | 27 ++++++++++++++++++++++----- scripts/loop/shared/agent.mjs | 5 +++++ scripts/loop/shared/state.mjs | 1 + 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index f13adc3..a1492f3 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -224,6 +224,16 @@ vacuous when nothing is written to GitHub. Source: this file, plus - [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`. ## Environment facts @@ -231,12 +241,19 @@ vacuous when nothing is written to GitHub. Source: this file, plus 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. +- `pi --list-models deepseek` prints `No models available. Use /login ...` when + no key is configured — the model catalogue is credential-gated, so the + `LOOP_MODEL` question is settled by the first run that has + `DEEPSEEK_API_KEY` set. Credential/config failures classify as `retry`, not + as a task failure, so a missing key never fills the human inbox. - `gh` works from this machine (list/view in seconds); the older note about direct GitHub timeouts and a stopped proxy is stale. - No `rclone` and no `aws` CLI on this Windows box — R2 sync is runner-side only; local seeding needs an AWS CLI installed first. -- Model id: `LOOP_MODEL=deepseek/deepseek-flash` is **unconfirmed** against pi's - built-in DeepSeek catalogue; the workflow prints `pi --list-models deepseek` - on every run so the first dispatch settles it. If the id does not appear, - declare it explicitly in `~/.pi/agent/models.json` or switch to the official - DeepSeek model id. +- 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/shared/agent.mjs b/scripts/loop/shared/agent.mjs index 0d62ff0..a2ae71a 100644 --- a/scripts/loop/shared/agent.mjs +++ b/scripts/loop/shared/agent.mjs @@ -168,6 +168,11 @@ export function classify({ code, signal, stderr, timedOut, stopReason }) { } if (code === -1) return { exit: 3, kind: 'retry', reason: 'pi 无法启动(安装/路径问题)' }; const s = String(stderr ?? ''); + // 凭据/配置类故障(缺 key、key 无效、pi 未解析到模型):属宿主配置问题,不是任务问题, + // 绝不能因此把任务推进人工收件箱 —— 实测 pi 在无 key 时输出 "No models available"。 + 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: '引擎凭据/配置未就绪 → 重试(需检查 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: '疑似 provider/网络瞬时故障 → 重试' }; } diff --git a/scripts/loop/shared/state.mjs b/scripts/loop/shared/state.mjs index a70731f..282f137 100644 --- a/scripts/loop/shared/state.mjs +++ b/scripts/loop/shared/state.mjs @@ -287,6 +287,7 @@ export function ghRepoOf(url) { */ export function planActions(t, outcome, { label, comment, note } = {}) { if (!t.url) return []; // 本地合成任务无 GitHub 目标 + if (outcome === 'retry') return []; // 未实际执行(故障/草稿),不该产生写动作建议 const repo = ghRepoOf(t.url); if (!repo) return []; const acts = []; From eb5c131250c606966282cde7a2203e0e229c7937 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 00:39:05 +0800 Subject: [PATCH 04/18] chore(loop): target the right gh subcommand, import PRs locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects surfaced while exercising the `deps` and external-PR routes on real GitHub items (#35, #21): - write actions always used `gh issue …`, which fails on a PR task — and "PRs are a triage surface" means most tasks are PRs. The subcommand now follows `task.kind`. - `run.mjs` could only import issues, so the local coverage exercise could not create the task files the A1 sweep is meant to find. Added `--pr ` with the same OPEN validation as the issue path. Also: a `retry` outcome no longer proposes a label, since such a run never did any work. --- scripts/loop/README.md | 7 +++++++ scripts/loop/run.mjs | 33 +++++++++++++++++++++++++-------- scripts/loop/shared/state.mjs | 17 ++++++++++------- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index a1492f3..fa1d968 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -234,6 +234,13 @@ vacuous when nothing is written to GitHub. Source: this file, plus - [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). ## Environment facts diff --git a/scripts/loop/run.mjs b/scripts/loop/run.mjs index b436dd3..90ae717 100644 --- a/scripts/loop/run.mjs +++ b/scripts/loop/run.mjs @@ -64,17 +64,34 @@ function resolveWriteLevel(args) { /* ---------------------------------------------------------------- start */ async function cmdStart(args) { - const { stage, task, 'new': isNew, title, body, issue, url } = args; + 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'); let t; - if (isNew || issue) { + if (isNew || issue || pr) { if (isNew && !title) fail('--new 需要 --title'); let id; + let kind = 'issue'; let extra = {}; - if (issue) { + if (pr) { + // PR 与 issue 共用 triage 面(issue-tracker.md);kind 区分,编号空间相同。 + 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 失败(gh 未装或无认证?):${(gh.stderr || '').trim()}`); + const it = JSON.parse(gh.stdout); + if (it.state !== 'OPEN') fail(`PR #${it.number} 状态=${it.state},仅 OPEN 的 PR 可导入(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()}`); @@ -83,7 +100,7 @@ async function cmdStart(args) { 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: [], @@ -91,7 +108,7 @@ async function cmdStart(args) { } else { 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: [], @@ -103,9 +120,9 @@ async function cmdStart(args) { } t.timeline.push({ at: t.createdAt, event: 'created', by: 'manual', ...extra }); await writeJson(S.taskFile(id), t); - console.log(`[loop] 任务 #${id} 已创建(${isNew ? '本地合成' : 'GitHub 导入'})`); + console.log(`[loop] 任务 #${id} 已创建(${isNew ? '本地合成' : pr ? 'GitHub PR 导入' : 'GitHub 导入'})`); } else { - t = await loadTask(S, task ?? fail('--task 或 --new/--issue 必填')); + t = await loadTask(S, task ?? fail('--task 或 --pr/--issue/--new 必填')); } // 领取检查:new/ready/waiting-info 可领;processing 活锁拒领、死锁可覆盖; @@ -235,7 +252,7 @@ async function main() { if (!cmd || cmd === '--help' || cmd === '-h') { console.log(`用法: pnpm loop start [--stage ] --task # stage 缺省 triage - [--new --title ".." [--body ".."] | --issue ] + [--pr | --issue | --new --title ".." [--body ".."]] pnpm loop checkpoint --run --note ".." pnpm loop end --run --outcome [--note ".."] [--comment ".."] [--label ] [--no-github] [--task ] diff --git a/scripts/loop/shared/state.mjs b/scripts/loop/shared/state.mjs index 282f137..63063b2 100644 --- a/scripts/loop/shared/state.mjs +++ b/scripts/loop/shared/state.mjs @@ -290,13 +290,15 @@ export function planActions(t, outcome, { label, comment, note } = {}) { if (outcome === 'retry') return []; // 未实际执行(故障/草稿),不该产生写动作建议 const repo = ghRepoOf(t.url); if (!repo) return []; + // PR 与 issue 是两个不同的 gh 子命令面:对 PR 编号调 `gh issue edit` 会失败。 + const gh = t.kind === 'pr' ? 'pr' : 'issue'; const acts = []; const l = label ?? OUTCOME_MAP[outcome]?.label ?? null; - if (l) acts.push({ kind: 'label', repo, number: t.id, label: l, stripRoles: true }); + if (l) acts.push({ kind: 'label', gh, repo, number: t.id, label: l, stripRoles: true }); if (outcome === 'closed') { - acts.push({ kind: 'close', repo, number: t.id, body: comment ?? note ?? 'Closed by the loop.' }); + acts.push({ kind: 'close', gh, repo, number: t.id, body: comment ?? note ?? 'Closed by the loop.' }); } else if (comment) { - acts.push({ kind: 'comment', repo, number: t.id, body: comment }); + acts.push({ kind: 'comment', gh, repo, number: t.id, body: comment }); } return acts; } @@ -319,25 +321,26 @@ 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'; // PR 走 gh pr,issue 走 gh issue if (a.kind === 'label') { let stale = []; if (a.stripRoles) { - const cur = gh(['issue', 'view', String(a.number), '-R', a.repo, '--json', 'labels', '-q', '.labels[].name']); + 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 = ['issue', 'edit', String(a.number), '-R', a.repo, '--add-label', 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: 打标 ${a.label} 失败: ${(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(['issue', 'close', String(a.number), '-R', a.repo, '--comment', a.body]); + const r = gh([sub, 'close', String(a.number), '-R', a.repo, '--comment', a.body]); if (r.status !== 0) warn(`[loop] warn: close #${a.number} 失败: ${(r.stderr || '').trim()}`); else { log(`[loop] gh: #${a.number} closed with comment`); applied.push(a); } } else if (a.kind === 'comment') { - const r = gh(['issue', 'comment', String(a.number), '-R', a.repo, '--body', a.body]); + const r = gh([sub, 'comment', String(a.number), '-R', a.repo, '--body', a.body]); if (r.status !== 0) warn(`[loop] warn: 评论 #${a.number} 失败: ${(r.stderr || '').trim()}`); else { log(`[loop] gh: #${a.number} commented`); applied.push(a); } } From ce3fd0269658e0ac9bab17cc8f06d051dec49ecf Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 01:12:10 +0800 Subject: [PATCH 05/18] chore(loop): shrink R2 configuration to three secrets `R2_ACCOUNT_ID` is not a credential: R2's S3 endpoint is only ever `https://.r2.cloudflarestorage.com`, so the account ID is part of an address, and it cannot be derived from an API token. Same for the bucket name. Neither belongs in a secret. - `R2_ACCOUNT_ID` is now read from a repository *variable* (falling back to a same-named secret, so either store works). - `R2_BUCKET` defaults to `loop-state-alex1990` in `shared/r2.mjs` and no longer has to be configured at all. - Required configuration drops from five items to four: three secrets (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `DEEPSEEK_API_KEY`) plus one variable. --- .github/workflows/loop.yml | 7 +++++-- scripts/loop/README.md | 34 +++++++++++++++++++++++++++------- scripts/loop/shared/r2.mjs | 15 +++++++++++---- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index bcc21cf..6695464 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -59,11 +59,14 @@ jobs: LOOP_MODEL: deepseek/deepseek-flash LOOP_RUN_TIMEOUT_MS: '3300000' # 55min,留出 push 余量(job timeout 60min) GH_TOKEN: ${{ secrets.LOOP_GH_TOKEN || github.token }} + # 凭据:只能是 secret DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} + # 非敏感配置:优先读 repository variable,回落 secret(两种配法都支持)。 + # ACCOUNT_ID 是 R2 端点的一部分(https://.r2.cloudflarestorage.com),不是凭据。 + R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID || secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }} steps: # checkout 默认分支:loop 需要本仓的 skills/ 与 scripts/loop/, diff --git a/scripts/loop/README.md b/scripts/loop/README.md index fa1d968..81a72fc 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -135,16 +135,34 @@ R2 (Cloudflare dashboard): *Object Read & Write* → **specify bucket `loop-state-alex1990` only**. 3. Note **Access Key ID**, **Secret Access Key**, and the **Account ID**. -GitHub → Settings → Secrets and variables → Actions: +GitHub → Settings → Secrets and variables → Actions. + +**Three secrets** (actual credentials — they cannot be inferred from each other): | Secret | Value | | --- | --- | -| `R2_ACCOUNT_ID` | Cloudflare account ID | -| `R2_ACCESS_KEY_ID` | token access key id | -| `R2_SECRET_ACCESS_KEY` | token secret | -| `R2_BUCKET` | `loop-state-alex1990` | +| `R2_ACCESS_KEY_ID` | R2 token access key id | +| `R2_SECRET_ACCESS_KEY` | R2 token secret | | `DEEPSEEK_API_KEY` | LLM key (DeepSeek platform) | -| `LOOP_GH_TOKEN` | fine-grained PAT (contents/issues/PRs write, this repo only); optional while report-only | + +**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 `loop-state-alex1990` in `shared/r2.mjs`). 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 +``` 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 @@ -154,10 +172,12 @@ private and `state/` is gitignored (so the runner cannot seed it from a checkout # once, locally winget install Amazon.AWSCLI # or the macOS/Linux equivalent -R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... R2_BUCKET=loop-state-alex1990 \ +R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... \ node scripts/loop/r2-sync.mjs seed # then `check` ``` +(`R2_BUCKET` may be omitted — it defaults to `loop-state-alex1990`.) + `seed` (like `push`) omits `--delete`, so it can only add or overwrite. ## A1 acceptance (one week; report boundary ⇒ GitHub stays untouched) diff --git a/scripts/loop/shared/r2.mjs b/scripts/loop/shared/r2.mjs index fc58b7b..e81fb31 100644 --- a/scripts/loop/shared/r2.mjs +++ b/scripts/loop/shared/r2.mjs @@ -11,19 +11,25 @@ * - `AWS_EC2_METADATA_DISABLED=true`:避免在非 EC2 环境探测实例元数据拖慢/报错。 * * 失败语义(重要):pull 失败必须中止整条链 —— 否则本地 state/ 为空, - * 随后的 push --delete 会把远端状态层清空。 + * 随后 push 出去的就不是完整状态层。 */ import { spawnSync } from 'node:child_process'; export const R2_PREFIX = 'state/tiny-oss/'; -const REQUIRED = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET']; +/** + * 只有一个桶,所以桶名内置为默认值(非秘密,也可用 `R2_BUCKET` 覆盖)。 + * 真正必须外部注入的只有三项 —— 前两项是凭据,第三项是端点的一部分, + * 三者都无法从彼此推导出来。 + */ +const DEFAULT_BUCKET = 'loop-state-alex1990'; +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(`缺少 R2 环境变量: ${missing.join(', ')}(应配为 Actions secrets,勿写进仓库文件)`); + throw new Error(`缺少 R2 环境变量: ${missing.join(', ')}(应配为 Actions secret/variable,勿写进仓库文件)`); } return { ...process.env, @@ -35,8 +41,9 @@ export function r2Env() { }; } +export const bucket = () => process.env.R2_BUCKET || DEFAULT_BUCKET; const endpoint = () => `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; -const s3url = (prefix) => `s3://${process.env.R2_BUCKET}/${prefix}`; +const s3url = (prefix) => `s3://${bucket()}/${prefix}`; function aws(args, { label }) { const r = spawnSync('aws', args, { env: r2Env(), encoding: 'utf8' }); From 0b820d5514a3675dca4b283cec9ddddf1fd84a72 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 01:23:51 +0800 Subject: [PATCH 06/18] chore(loop): derive one R2 bucket per repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision: each repository gets its own bucket. Sharing one bucket across repositories cannot be made safe, because 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.__`, with no prefix dimension. Under a shared bucket every repo token can read and write the whole bucket, so one leaked credential or fork-context injection reaches every repository's state layer. - Bucket name is derived at runtime as `loop-state-`: from `GITHUB_REPOSITORY` in CI, from `package.json` name locally. A sibling repository therefore needs no code change to get its own bucket. `R2_BUCKET` still overrides (migration, triage, scratch bucket). - `R2_PREFIX` drops the redundant repo segment (`state/`): the bucket name already carries the repository identity, so remote keys now mirror the local layout one-to-one. - `R2_BUCKET` no longer has to be configured at all; unset expands to an empty string, which falls through to the derivation. - README documents the per-repo setup and the onboarding steps for an additional repository, and records why sharing a bucket cannot work. --- .github/workflows/loop.yml | 4 ++- scripts/loop/README.md | 54 +++++++++++++++++++++++++++++++++----- scripts/loop/shared/r2.mjs | 43 +++++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index 6695464..a5424ab 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -63,9 +63,11 @@ jobs: 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 }} - # 非敏感配置:优先读 repository variable,回落 secret(两种配法都支持)。 + # 非敏感配置:优先读 repository variable,回落同名 secret(两种配法都支持)。 # ACCOUNT_ID 是 R2 端点的一部分(https://.r2.cloudflarestorage.com),不是凭据。 R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID || secrets.R2_ACCOUNT_ID }} + # 桶名无需配置:代码按 `loop-state-` 自动推导(每仓一桶)。 + # 仅当要指向别的桶(迁移/排查)时才设它;未设时展开为空串 → 走推导。 R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }} steps: diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 81a72fc..c6cca0e 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -114,7 +114,26 @@ the ops.md outcome table. ## R2 state layer -- Bucket `loop-state-alex1990`, prefix `state/tiny-oss/`; keys mirror `state/`. +**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 `name` locally. So tiny-oss +writes to `loop-state-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. + +Keys inside the bucket mirror the local layout (`state/tasks/…`), since the +bucket name already carries the 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 @@ -126,13 +145,18 @@ the ops.md outcome table. - **`pull` failure must abort the job** — an empty local `state/` pushed back would otherwise destroy the remote state layer. -### One-time setup +### One-time setup (per repository) R2 (Cloudflare dashboard): -1. R2 → **Create bucket** → `loop-state-alex1990`. +1. R2 → **Create bucket** → `loop-state-tiny-oss` (i.e. `loop-state-`). 2. R2 → **API → Manage R2 API Tokens → Create API Token** → permission - *Object Read & Write* → **specify bucket `loop-state-alex1990` only**. + *Object Read & Write* → **Specify bucket → `loop-state-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. @@ -154,8 +178,8 @@ so the account ID is part of an address, and bucket names are not sensitive): 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 `loop-state-alex1990` in `shared/r2.mjs`). Both the `R2_ACCOUNT_ID` and -`R2_BUCKET` lookups fall back to the same-named secret, so either store works. +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 @@ -164,6 +188,20 @@ 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. +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): @@ -176,7 +214,9 @@ R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... \ node scripts/loop/r2-sync.mjs seed # then `check` ``` -(`R2_BUCKET` may be omitted — it defaults to `loop-state-alex1990`.) +(The bucket name comes from `package.json`, so `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. diff --git a/scripts/loop/shared/r2.mjs b/scripts/loop/shared/r2.mjs index e81fb31..966a61f 100644 --- a/scripts/loop/shared/r2.mjs +++ b/scripts/loop/shared/r2.mjs @@ -15,15 +15,47 @@ */ import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -export const R2_PREFIX = 'state/tiny-oss/'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); /** - * 只有一个桶,所以桶名内置为默认值(非秘密,也可用 `R2_BUCKET` 覆盖)。 - * 真正必须外部注入的只有三项 —— 前两项是凭据,第三项是端点的一部分, - * 三者都无法从彼此推导出来。 + * 桶内路径前缀。**每仓一桶**(owner 定稿),桶名已承载仓库身份, + * 前缀不再重复 repo 段 —— 远端 key 与本地 `state/` 一一对应。 */ -const DEFAULT_BUCKET = 'loop-state-alex1990'; +export const R2_PREFIX = 'state/'; + +/** + * 每仓一桶。 + * + * 为什么不用一个桶装所有仓库:R2 的 API token 权限只能限到**桶**,限不到前缀 + * (Access Policy 的资源标识是 `...r2.bucket.__`, + * 没有 key/prefix 维度)。共用桶时「每仓一把独立 token」形同虚设 —— 每把都能读写 + * 整个桶,一次凭据泄漏或 fork 注入的影响面就是所有接入仓库。 + * + * 桶名规则 `loop-state-`,`R2_BUCKET` 可覆盖(迁移、排查、临时指向别的桶)。 + */ +let repoNameCache = null; + +function repoName() { + if (repoNameCache) return repoNameCache; + // CI:GITHUB_REPOSITORY = "owner/repo" + const fromCi = (process.env.GITHUB_REPOSITORY ?? '').split('/')[1]; + if (fromCi) return (repoNameCache = fromCi); + // 本地(seed 等):package.json 的 name,与仓库名约定一致 + try { + repoNameCache = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).name; + } catch (e) { + throw new Error(`无法确定仓库名(GITHUB_REPOSITORY 未设,且读不到 ${path.join(ROOT, 'package.json')}):${e.message}`); + } + return repoNameCache; +} + +/** 本仓的 R2 桶名。 */ +export const bucket = () => process.env.R2_BUCKET || `loop-state-${repoName()}`; + const REQUIRED = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY']; export function r2Env() { @@ -41,7 +73,6 @@ export function r2Env() { }; } -export const bucket = () => process.env.R2_BUCKET || DEFAULT_BUCKET; const endpoint = () => `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; const s3url = (prefix) => `s3://${bucket()}/${prefix}`; From da8f781173b1dc19ddaa5a31a8cac91c9eba20c6 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 01:25:29 +0800 Subject: [PATCH 07/18] chore(loop): include the owner in the derived bucket name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bucket names are a flat, account-wide namespace, so a name collision is a real collision. Deriving `loop-state-` left same-named repositories under different GitHub organisations or accounts pointed at one bucket. The name is now `loop-state--` — e.g. tiny-oss resolves to `loop-state-alex1990-tiny-oss` — which moves the collision surface up to owner + repository. - Owner comes from `GITHUB_REPOSITORY` in CI and from `package.json`'s `repository` field locally. All npm forms resolve identically: object `{url}`, string shorthand `owner/repo`, `github:owner/repo`, SSH (`git@github.com:owner/repo.git`) and https with or without `.git`. A string `repository` value (previously unhandled, and a real failure mode) is now accepted. - The name is normalised to R2's rules: lowercase letters, digits and hyphens only, no leading/trailing hyphen, 3-63 characters. A stable content hash replaces the tail when `owner-repo` would exceed 63, since GitHub permits repository names up to 100 characters. - Local and CI derivations were verified to agree, which is what makes `seed` write to the same bucket the workflow reads. - An unresolvable repository identity now fails with an actionable message pointing at `R2_BUCKET`, instead of silently deriving a wrong name. --- .github/workflows/loop.yml | 2 +- scripts/loop/README.md | 42 ++++++++++++++++++--------- scripts/loop/shared/r2.mjs | 59 +++++++++++++++++++++++++++++--------- 3 files changed, 74 insertions(+), 29 deletions(-) diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index a5424ab..4164531 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -66,7 +66,7 @@ jobs: # 非敏感配置:优先读 repository variable,回落同名 secret(两种配法都支持)。 # ACCOUNT_ID 是 R2 端点的一部分(https://.r2.cloudflarestorage.com),不是凭据。 R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID || secrets.R2_ACCOUNT_ID }} - # 桶名无需配置:代码按 `loop-state-` 自动推导(每仓一桶)。 + # 桶名无需配置:代码按 `loop-state--` 自动推导(每仓一桶)。 # 仅当要指向别的桶(迁移/排查)时才设它;未设时展开为空串 → 走推导。 R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }} diff --git a/scripts/loop/README.md b/scripts/loop/README.md index c6cca0e..8e63380 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -115,11 +115,12 @@ 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 `name` locally. So tiny-oss -writes to `loop-state-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). +`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 @@ -131,8 +132,17 @@ 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 the repository identity. +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 @@ -149,9 +159,9 @@ bucket name already carries the repository identity. R2 (Cloudflare dashboard): -1. R2 → **Create bucket** → `loop-state-tiny-oss` (i.e. `loop-state-`). +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-tiny-oss` only**. + *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 @@ -178,8 +188,8 @@ so the account ID is part of an address, and bucket names are not sensitive): 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. +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 @@ -195,7 +205,9 @@ 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. +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. @@ -214,9 +226,11 @@ R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... \ node scripts/loop/r2-sync.mjs seed # then `check` ``` -(The bucket name comes from `package.json`, so `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.) +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. diff --git a/scripts/loop/shared/r2.mjs b/scripts/loop/shared/r2.mjs index 966a61f..e50ce71 100644 --- a/scripts/loop/shared/r2.mjs +++ b/scripts/loop/shared/r2.mjs @@ -15,6 +15,7 @@ */ 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'; @@ -28,33 +29,63 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '. export const R2_PREFIX = 'state/'; /** - * 每仓一桶。 + * 每仓一桶,桶名 `loop-state--`。 * * 为什么不用一个桶装所有仓库:R2 的 API token 权限只能限到**桶**,限不到前缀 * (Access Policy 的资源标识是 `...r2.bucket.__`, * 没有 key/prefix 维度)。共用桶时「每仓一把独立 token」形同虚设 —— 每把都能读写 * 整个桶,一次凭据泄漏或 fork 注入的影响面就是所有接入仓库。 * - * 桶名规则 `loop-state-`,`R2_BUCKET` 可覆盖(迁移、排查、临时指向别的桶)。 + * 为什么带 owner:桶名是账号内全局唯一的平铺空间,桶名撞车就真的撞车。带上 owner + * 把冲突面从「仓库名」提到「owner + 仓库名」,将来管理多个 GitHub 组织也不会互撞。 + * `R2_BUCKET` 可覆盖(迁移、排查、临时指向别的桶)。 */ -let repoNameCache = null; - -function repoName() { - if (repoNameCache) return repoNameCache; - // CI:GITHUB_REPOSITORY = "owner/repo" - const fromCi = (process.env.GITHUB_REPOSITORY ?? '').split('/')[1]; - if (fromCi) return (repoNameCache = fromCi); - // 本地(seed 等):package.json 的 name,与仓库名约定一致 +let ownerRepoCache = null; + +/** 解析 owner/repo:CI 取 GITHUB_REPOSITORY,本地取 package.json 的 repository.url。 */ +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 { - repoNameCache = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).name; + pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')); } catch (e) { - throw new Error(`无法确定仓库名(GITHUB_REPOSITORY 未设,且读不到 ${path.join(ROOT, 'package.json')}):${e.message}`); + throw new Error(`无法确定仓库身份:GITHUB_REPOSITORY 未设,且读不到 ${path.join(ROOT, 'package.json')}(${e.message})。请显式设置 R2_BUCKET。`); + } + // npm 允许 repository 是字符串(`"owner/repo"`、`"github:owner/repo"`)或对象(`{ 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(`无法从 package.json 推导 owner/repo(repository = ${raw || '缺失'})。请显式设置 R2_BUCKET。`); } - return repoNameCache; + return (ownerRepoCache = { owner: m[1], repo: m[2] }); +} + +/** R2 桶名只允许小写字母/数字/连字符,且不以连字符开头或结尾(官方约束)。 */ +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 仓库名上限 100 字符,`owner-repo` 拼接后理论上会超过 63。 + // 截断 + 稳定哈希:同一仓库每次推导同一桶名,不同仓库也不会因截断而碰撞。 + const h = createHash('sha256').update(base).digest('hex').slice(0, 8); + return `${base.slice(0, BUCKET_MAX - h.length - 1).replace(/-+$/, '')}-${h}`; } /** 本仓的 R2 桶名。 */ -export const bucket = () => process.env.R2_BUCKET || `loop-state-${repoName()}`; +export const bucket = () => process.env.R2_BUCKET || derivedBucketName(); const REQUIRED = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY']; From 79c624168b582857b83b4adad1536f54c5a4baf7 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 01:37:32 +0800 Subject: [PATCH 08/18] chore(loop): let workflow_dispatch check out its own ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job always checked out the default branch. That is correct for every production path — issues, schedule, release and pull_request_target must run the repository's trusted host code, and a fork PR must never have its code checked out at all — but it made the manual smoke entry unusable on this workflow's own PR: `main` does not have `scripts/loop/` yet, so the run died with MODULE_NOT_FOUND on a file that exists on the branch. `workflow_dispatch` now checks out `github.ref`. Dispatch can only target refs in this repository (fork branches are not among them), so the safety property is unchanged, and the branch under review can be exercised before merging. --- .github/workflows/loop.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index 4164531..b001f96 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -71,11 +71,15 @@ jobs: R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }} steps: - # checkout 默认分支:loop 需要本仓的 skills/ 与 scripts/loop/, - # 且**绝不检出 fork 代码**(外部 PR 的 diff 用 gh 读取)。 + # checkout 什么: + # - 生产路径(issues / schedule / release / pull_request_target)必须用**默认分支** + # 的可信宿主代码 —— 尤其是 fork PR,绝不执行或检出外部代码(diff 用 gh 读)。 + # - workflow_dispatch 例外:它是人来指定的冒烟入口,用 github.ref。 + # 否则本 workflow 自己的 PR 无法被测试(默认分支上还没有这些脚本)。 + # dispatch 只能针对本仓的 ref 触发,fork 分支不在其中,因此仍然安全。 - uses: actions/checkout@v4 with: - ref: ${{ github.event.repository.default_branch || github.ref }} + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.repository.default_branch }} - uses: pnpm/action-setup@v4 with: From 37e37fd83a2344df0df63af66e68fda23528d840 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 01:43:04 +0800 Subject: [PATCH 09/18] chore(loop): correct the model id, validate it, add an infrastructure smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real end-to-end run (all steps green, 3m05s) surfaced three things: - `LOOP_MODEL` was `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` rather than failing, so the run looked healthy while using an unintended model. The id is corrected, and the install step now validates it against `pi --list-models deepseek` and fails the job on a mismatch. - There was no cheap way to check the pipeline: an empty `stage` still gets the input's `default:` (`triage`), so the smoke cost 102,860 tokens. Added a `smoke` input that exercises checkout/toolchain/pi install/R2 pull+push and skips the agent — the entry to use when onboarding a repo or when a credential is in doubt. - Three comments still pointed at `scripts/loop/lib/` after the rename to `shared/`, and `run.mjs` referenced a `run-stage.mjs` that never existed. Defect log records D18-D20 as fixed and D21 (the dependabot coverage-comment 403) as open, awaiting a maintainer decision. Report-boundary compliance of the real run was verified independently — repository-wide, no item carries a loop label and #21/#35 have no comments — not from the run's own summary. --- .github/workflows/loop.yml | 18 +++++++++++-- scripts/loop/README.md | 54 ++++++++++++++++++++++++++++++++++---- scripts/loop/entry.mjs | 18 ++++++++++++- scripts/loop/run.mjs | 2 +- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index b001f96..cf4a88e 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -30,6 +30,10 @@ on: 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 @@ -56,7 +60,8 @@ jobs: LOOP_REPO: ${{ github.repository }} # 非 dispatch 事件下 github.event.inputs 为 null → 取默认 report(严格只报告) LOOP_EXECUTE_WRITES: ${{ github.event.inputs.execute_writes || 'false' }} - LOOP_MODEL: deepseek/deepseek-flash + LOOP_SMOKE: ${{ github.event.inputs.smoke || 'false' }} + LOOP_MODEL: deepseek/deepseek-v4-flash LOOP_RUN_TIMEOUT_MS: '3300000' # 55min,留出 push 余量(job timeout 60min) GH_TOKEN: ${{ secrets.LOOP_GH_TOKEN || github.token }} # 凭据:只能是 secret @@ -98,7 +103,16 @@ jobs: npm i -g @earendil-works/pi-coding-agent@0.85.1 pi --version echo '--- available DeepSeek models ---' - pi --list-models deepseek || true + available=$(pi --list-models deepseek) + echo "$available" + # pi 对无效 --model 是**静默回退到默认模型**,不报错 —— 一个拼错的 + # 模型名会安静地用错模型跑完每一次 run。所以在装好后立刻校验。 + want="${LOOP_MODEL#*/}" + if ! grep -qE "(^|[[:space:]])${want}([[:space:]]|$)" <<<"$available"; then + echo "::error::LOOP_MODEL=$LOOP_MODEL 不在 pi 的 DeepSeek 目录中(期望模型段 '$want')。" + exit 1 + fi + echo "model $want is available" - name: Pull state from R2 run: node scripts/loop/r2-sync.mjs pull diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 8e63380..6c4f897 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -240,6 +240,8 @@ 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. +- [ ] `workflow_dispatch` smoke (`-f smoke=true`): pipeline green end to end + (checkout → pi install → R2 pull → push) with no agent run - [ ] `workflow_dispatch` smoke: real issue → triage run → task file + state transition correct, **GitHub unchanged** - [ ] Every route has one real execution: issue triage / bugfix-feature / deps / @@ -315,6 +317,33 @@ vacuous when nothing is written to GitHub. Source: this file, plus `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). ## Environment facts @@ -325,11 +354,26 @@ vacuous when nothing is written to GitHub. Source: this file, plus - **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. -- `pi --list-models deepseek` prints `No models available. Use /login ...` when - no key is configured — the model catalogue is credential-gated, so the - `LOOP_MODEL` question is settled by the first run that has - `DEEPSEEK_API_KEY` set. Credential/config failures classify as `retry`, not - as a task failure, so a missing key never fills the human inbox. +- **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. - No `rclone` and no `aws` CLI on this Windows box — R2 sync is runner-side only; diff --git a/scripts/loop/entry.mjs b/scripts/loop/entry.mjs index 8078824..cd69647 100644 --- a/scripts/loop/entry.mjs +++ b/scripts/loop/entry.mjs @@ -3,7 +3,7 @@ * Loop runner 编排入口 —— GitHub Actions job 调用的唯一脚本。 * * 一次调用 = 一个决策:路由事件 → 落 inbox / 写评测 / 跑一个 run。 - * 状态层读写全部经 `lib/state.mjs`;本文件只做编排与报告,不直接改状态字段。 + * 状态层读写全部经 `shared/state.mjs`;本文件只做编排与报告,不直接改状态字段。 * * 不变量: * - 收尾是**唯一写入者**:agent 只写 `state/reports/.result.json`, @@ -269,6 +269,22 @@ async function main() { const decision = route(ctx); log(`event=${eventName}.${action || '(none)'} → ${decision.action}(${decision.reason})· writeLevel=${writeLevel}`); + // 冒烟模式:只验证基础设施(checkout / 工具链 / pi 安装 / R2 pull+push), + // 不启动 agent。A1 的手动冒烟入口要能在不消耗 token、不等 LLM 的情况下 + // 回答"管道通不通"——加新仓库、怀疑凭据失效时首先用它。 + if (process.env.LOOP_SMOKE === 'true') { + log('smoke 模式:跳过 agent,仅验证基础设施'); + 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)', + ].join('\n')); + return 0; + } + let result = { state: 'noop', note: decision.reason }; if (decision.action === 'run') { diff --git a/scripts/loop/run.mjs b/scripts/loop/run.mjs index 90ae717..8623b61 100644 --- a/scripts/loop/run.mjs +++ b/scripts/loop/run.mjs @@ -2,7 +2,7 @@ /** * tiny-oss Loop 本地宿主(CLI) * - * 状态层原语全部来自 `./lib/state.mjs`(与 runner 编排 run-stage.mjs 共用, + * 状态层原语全部来自 `./shared/state.mjs`(与 runner 编排 entry.mjs 共用, * 保证单一实现)。本文件只负责人工驱动的命令行仪式。 * * 用法(`pnpm loop` = 本文件,见 package.json): From ef3aef71ec73d0f145707fe783147e0ceacd0ecb Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 01:46:17 +0800 Subject: [PATCH 10/18] chore(loop): render system-level runs correctly in SUMMARY A run with no task (sweep / retro / release preflight) carries `taskId: null`, and renderSummary printed it literally as `task #null`. System-level runs now render as `system`. Surfaced by the first real run, which was a system-level triage. Environment facts also record that AWS CLI v2 is installed locally to match the runner, and why `pip install awscli` is not a substitute for seeding: it produces a `.cmd` shim that Node refuses to spawn on Windows. --- scripts/loop/README.md | 7 +++++-- scripts/loop/shared/state.mjs | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 6c4f897..f99cbdd 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -376,8 +376,11 @@ vacuous when nothing is written to GitHub. Source: this file, plus 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. -- No `rclone` and no `aws` CLI on this Windows box — R2 sync is runner-side only; - local seeding needs an AWS CLI installed first. +- 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 diff --git a/scripts/loop/shared/state.mjs b/scripts/loop/shared/state.mjs index 63063b2..b696616 100644 --- a/scripts/loop/shared/state.mjs +++ b/scripts/loop/shared/state.mjs @@ -262,7 +262,11 @@ export async function renderSummary(s) { 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'); - if (st) L.push(`- ${rid} task #${st.taskId} ${st.stage}${en ? ` → ${en.outcome}` : ' (未收尾)'}`); + // 系统级 run(sweep / retro / release 预检)没有 taskId + if (st) { + const what = st.taskId ? `task #${st.taskId}` : 'system'; + L.push(`- ${rid} ${what} ${st.stage}${en ? ` → ${en.outcome}` : ' (未收尾)'}`); + } } L.push(''); } From 4783d4602e6236e62f0346bfe2e0e4f31ded8150 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 01:56:13 +0800 Subject: [PATCH 11/18] chore(loop): no pager, bounded timeouts, and a readable smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for running the R2 sync from a real terminal: - `AWS_PAGER: ''` — aws CLI v2 pipes results to a pager, which in a scripted call looks exactly like a hang: the cursor stops, nothing is printed, and it is waiting for a keypress. Programmatic calls must never page. - `--cli-connect-timeout` / `--cli-read-timeout` and `AWS_MAX_ATTEMPTS` bound every call, so a slow link fails visibly instead of sitting silent. - sync operations now stream to the terminal instead of being buffered by spawnSync. On a cross-border link a seed takes seconds to tens of seconds, and with no output that reads as a hang even while it is progressing normally. The smoke mode also reports what it pulled — task count with status breakdown, runs, reports, acceptance rows, whether SUMMARY.md is present — since "the pipeline is green" is only half of what a smoke should tell you. `LOOP_EXPECT_TASKS` turns that into an assertion for scripted use. --- scripts/loop/entry.mjs | 31 +++++++++++++++++++++++++++++-- scripts/loop/shared/r2.mjs | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/scripts/loop/entry.mjs b/scripts/loop/entry.mjs index cd69647..753c068 100644 --- a/scripts/loop/entry.mjs +++ b/scripts/loop/entry.mjs @@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { - makeState, ensureDirs, readJson, writeJson, saveTask, listTasks, + makeState, ensureDirs, readJson, readJsonl, writeJson, saveTask, listTasks, appendAcceptance, beginRun, finishRun, lockExpired, nowIso, outcomeAllowed, allowedOutcomes, describeActions, } from './shared/state.mjs'; @@ -271,9 +271,32 @@ async function main() { // 冒烟模式:只验证基础设施(checkout / 工具链 / pi 安装 / R2 pull+push), // 不启动 agent。A1 的手动冒烟入口要能在不消耗 token、不等 LLM 的情况下 - // 回答"管道通不通"——加新仓库、怀疑凭据失效时首先用它。 + // 回答两件事:管道通不通,以及刚拉下来的状态层对不对。 if (process.env.LOOP_SMOKE === 'true') { log('smoke 模式:跳过 agent,仅验证基础设施'); + 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(`状态层任务数为 ${tasks.length},期望 ${expectsState}`); + } + await writeStepSummary([ '## Loop — smoke (infrastructure only)', '', `- event: \`${eventName}.${action || '(none)'}\``, @@ -281,6 +304,10 @@ async function main() { `- 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; } diff --git a/scripts/loop/shared/r2.mjs b/scripts/loop/shared/r2.mjs index e50ce71..9e22eb0 100644 --- a/scripts/loop/shared/r2.mjs +++ b/scripts/loop/shared/r2.mjs @@ -101,17 +101,41 @@ export function r2Env() { AWS_DEFAULT_REGION: 'auto', AWS_REQUEST_CHECKSUM_CALCULATION: 'when_required', AWS_EC2_METADATA_DISABLED: 'true', + // AWS CLI v2 会把结果交给 pager(`less`)。脚本化调用下这表现为"命令挂起"—— + // 光标停住、毫无输出、其实是在等按键。程序化调用永远不要 pager。 + AWS_PAGER: '', + // 跨境访问 R2 的握手约 0.4s,但慢链路下会退化。给出明确的边界, + // 让失败快速暴露成错误,而不是长时间静默重试。 + AWS_MAX_ATTEMPTS: process.env.AWS_MAX_ATTEMPTS ?? '3', }; } +/** 全局 CLI 边界:连接/读取超时,避免"看起来挂起"。 */ +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}`; -function aws(args, { label }) { - const r = spawnSync('aws', args, { env: r2Env(), encoding: 'utf8' }); +/** + * 调用 aws CLI。 + * + * `stream: true` 让子进程直接写终端 —— sync 类操作在跨境链路上可能需要数秒到 + * 数十秒,spawnSync 的缓冲会让这段时间毫无输出("看起来挂起")。只有在需要 + * 解析输出的场合(check 数行数)才捕获。 + */ +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}] 无法执行 aws cli:${r.error.message}`); + if (stream) { + if (r.status !== 0) throw new Error(`[r2:${label}] aws cli 退出码 ${r.status}`); + return ''; + } const out = (r.stdout ?? '').trim(); const err = (r.stderr ?? '').trim(); - if (r.error) throw new Error(`[r2:${label}] 无法执行 aws cli:${r.error.message}`); if (r.status !== 0) throw new Error(`[r2:${label}] aws cli 退出码 ${r.status}\n${err || out}`); if (out) console.log(out); if (err) console.error(err); @@ -120,7 +144,7 @@ function aws(args, { label }) { /** 远端 → 本地。不做 --delete:runner 上 state/ 全新,陈旧文件问题不存在。 */ export function pull(dir, { prefix = R2_PREFIX } = {}) { - return aws(['s3', 'sync', s3url(prefix), dir, '--endpoint-url', endpoint()], { label: 'pull' }); + return aws(['s3', 'sync', s3url(prefix), dir, '--endpoint-url', endpoint()], { label: 'pull', stream: true }); } /** @@ -129,7 +153,7 @@ export function pull(dir, { prefix = R2_PREFIX } = {}) { * 代价是 run 期间删掉的过期锁文件会残留在远端,由 TTL 语义消化(lockExpired)。 */ export function push(dir, { prefix = R2_PREFIX } = {}) { - return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'push' }); + return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'push', stream: true }); } /** @@ -137,7 +161,7 @@ export function push(dir, { prefix = R2_PREFIX } = {}) { * 远端已有对象时宁可保守,不冒清空风险。 */ export function seed(dir, { prefix = R2_PREFIX } = {}) { - return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'seed' }); + return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'seed', stream: true }); } /** 连通性与凭据自检:列出前缀下对象数(空桶同样算通过)。 */ From 6dc942607e5f234524bfd4a964a8c686a96c2601 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 02:02:09 +0800 Subject: [PATCH 12/18] chore(loop): give system-level runs their own outcome vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects surfaced by the first real sweep run on Actions: - System-level runs (sweep / retro / release preflight) had no outcome vocabulary of their own, and the sweep recorded `closed`. In the task vocabulary `closed` means "closed without a PR, excluded from the acceptance denominator", so retro reading end rows would have counted sweep/retro executions as closed tasks. System runs now use `completed / failed / retry / aborted`; `finishRun` rejects a task-scoped outcome when the run has no task (and vice versa), and the agent prompt states which vocabulary applies. - `renderSummary` had no section for `status: new`, so a task the sweep had just created was invisible in the human summary — the single item most in need of attention. A `未处理 (new)` section now lists them. Both were found by the loop running itself, not by inspection. --- scripts/loop/README.md | 27 ++++++++++++++++++++++++ scripts/loop/run.mjs | 3 ++- scripts/loop/shared/agent.mjs | 5 +++-- scripts/loop/shared/state.mjs | 39 +++++++++++++++++++++++++++++------ 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index f99cbdd..3f1a345 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -96,6 +96,23 @@ GitHub event ─▶ loop.yml (concurrency group `loop` = platform-level single w 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 @@ -344,6 +361,16 @@ vacuous when nothing is written to GitHub. Source: this file, plus 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 `未处理 (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. ## Environment facts diff --git a/scripts/loop/run.mjs b/scripts/loop/run.mjs index 8623b61..b55d5ef 100644 --- a/scripts/loop/run.mjs +++ b/scripts/loop/run.mjs @@ -258,7 +258,8 @@ async function main() { [--label ] [--no-github] [--task ] [--write-level report|auto] # 默认 report:GitHub 写动作仅列出 pnpm loop summary | view [--run |--task ] -outcome: ${Object.keys(OUTCOME_MAP).join(' | ')} +outcome(任务): ${Object.keys(OUTCOME_MAP).join(' | ')} +outcome(系统级 run): completed | failed | retry | aborted state 根: ${S.root}(env STATE_DIR 可覆盖) 写边界: LOOP_WRITE_LEVEL=report|auto(默认 report)`); return; diff --git a/scripts/loop/shared/agent.mjs b/scripts/loop/shared/agent.mjs index a2ae71a..037e641 100644 --- a/scripts/loop/shared/agent.mjs +++ b/scripts/loop/shared/agent.mjs @@ -77,8 +77,9 @@ export function buildPrompt({ task, stage, runId, writeLevel, mode, repo, lead = 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: \`outcome\` is recorded on the end row only (no task`); - L.push(' transition). Use the outcome the skill prescribes for the sweep/retro it performed.'); + 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'); diff --git a/scripts/loop/shared/state.mjs b/scripts/loop/shared/state.mjs index b696616..dd944b1 100644 --- a/scripts/loop/shared/state.mjs +++ b/scripts/loop/shared/state.mjs @@ -34,6 +34,17 @@ export const OUTCOME_MAP = { /** stage → 允许的成功 outcome 白名单(D5 防呆);未列出的 stage 全放行。 */ const COMMON_OUTCOMES = ['failed', 'rejected', 'accepted', 'retry']; + +/** + * 系统级 run(sweep / retro / release 预检)的 outcome —— 只记录"这一轮跑得怎样", + * 不映射任何任务状态。 + * + * 为什么不能复用任务的 outcome:`closed` 在任务语义里意为"直接关闭、不进接受率 + * 分母"。系统级 run 若记 `closed`,retro 读 end 行做统计时会把 sweep/retro 的运行 + * 次数算成被关闭的任务,污染双口径。 + */ +export const SYSTEM_OUTCOMES = ['completed', 'failed', 'retry', 'aborted']; + export const STAGE_OUTCOMES = { triage: ['triaged', 'needs-info', 'needs-triage', 'closed'], bugfix: ['pr-opened'], @@ -235,6 +246,13 @@ export async function renderSummary(s) { return `#${t.id} — ${t.title}${d} (${when(t)})`; })); + // `new` = 事件已入库但还没 triage。最容易被人漏看,所以必须出现在摘要里 + // (sweep 刚建档的任务就是这一类)。 + sec('未处理 (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; @@ -385,17 +403,26 @@ 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 || !OUTCOME_MAP[outcome]) { - throw new Error(`outcome 必填且 ∈ {${Object.keys(OUTCOME_MAP).join(', ')}}`); - } + if (!outcome) throw new Error('outcome 必填'); const rows = await readRunLines(s, runId); if (!rows.length) throw new Error(`run ${runId} 不存在(${s.runFile(runId)})`); if (rows.some((r) => r.event === 'end')) throw new Error(`run ${runId} 已有 end 行,重复收尾被拒绝(幂等)`); const start = rows[0]; - // 系统级 run(sweep/retro/release 预检)无任务文件:只记 run 行,不做状态转移。 + // 系统级 run(sweep/retro/release 预检)无任务文件:只记 run 行,不做状态转移, + // 且用另一套 outcome 词表(`closed` 等任务语义不该出现在这里)。 const t = start.taskId ? await loadTask(s, start.taskId) : null; - if (t && !outcomeAllowed(start.stage, outcome)) { - throw new Error(`stage=${start.stage} 不允许 outcome=${outcome}(D5;允许: ${allowedOutcomes(start.stage).join(', ')})`); + if (!t) { + if (!SYSTEM_OUTCOMES.includes(outcome)) { + throw new Error(`系统级 run(无任务)的 outcome ∈ {${SYSTEM_OUTCOMES.join(', ')}},收到 ${outcome};` + + '任务的 outcome 语义(如 closed)不应出现在系统级 run 里(会污染双口径统计)'); + } + } else { + if (!OUTCOME_MAP[outcome]) { + throw new Error(`outcome 必填且 ∈ {${Object.keys(OUTCOME_MAP).join(', ')}},收到 ${outcome}`); + } + if (!outcomeAllowed(start.stage, outcome)) { + throw new Error(`stage=${start.stage} 不允许 outcome=${outcome}(D5;允许: ${allowedOutcomes(start.stage).join(', ')})`); + } } const at = nowIso(); From 250ccb060fbc03547db3240726f2ac068693ea65 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 02:02:30 +0800 Subject: [PATCH 13/18] chore(loop): record A1 acceptance progress with run evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked items now cite the run that proves them, and the two real runs are written up: the smoke (`34509932312`) and the first real stage (`34511392550`, system-level sweep, 77,850 tokens). The drift criterion needed correcting. While A1 runs at the report boundary, every proposed GitHub action stays unexecuted *by design* — so a task file saying `needs-triage` next to a GitHub item with no label is expected behaviour, not drift. The real sweep surfaced this as an anomaly. What the week should judge is whether each proposal was correct and whether new events produce proposals matching reality. --- scripts/loop/README.md | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 3f1a345..06dbb06 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -257,25 +257,38 @@ 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. -- [ ] `workflow_dispatch` smoke (`-f smoke=true`): pipeline green end to end - (checkout → pi install → R2 pull → push) with no agent run -- [ ] `workflow_dispatch` smoke: real issue → triage run → task file + state - transition correct, **GitHub unchanged** -- [ ] Every route has one real execution: issue triage / bugfix-feature / deps / - external-PR readonly / sweep / release preflight +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) +- [ ] `workflow_dispatch` smoke: a **new** event → run → task file + state + transition correct, **GitHub unchanged** (the two real runs so far were + system-level, so the task-scoped path is unproven on Actions) +- [ ] 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 -- [ ] Engine stability: N consecutive headless pi runs without hanging; exit-code - mapping matches the failure-classification table -- [ ] Cost readable: every run's `tokens`/`durationMs` land in the end row; the - `pi --list-models deepseek` line confirms the real model id -- [ ] Reports human-readable: the Step Summary alone tells you what happened, - without downloading anything -- [ ] State layer and GitHub show no drift after a week → human decides on L2 +- [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 From 6a70891fbc26c0b84f5e32c4743d08dec967b587 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 02:06:24 +0800 Subject: [PATCH 14/18] chore(loop): verify the task-scoped path on Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 34511902164 (`task=36 stage=triage`, 80,289 tokens) exercised the path that was still unproven: task claimed → processing → agent triage → needs-triage → waiting-human with the matching label. Boundary re-checked against GitHub, not the run's own summary: PR #36 carries no labels and no label timeline events. --- scripts/loop/README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 06dbb06..7905815 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -267,9 +267,14 @@ was *correct*, and whether new events produce proposals that match reality. (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) -- [ ] `workflow_dispatch` smoke: a **new** event → run → task file + state - transition correct, **GitHub unchanged** (the two real runs so far were - system-level, so the task-scoped path is unproven on Actions) +- [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`) From ceecd918bc4786c4a1567b30d28a4d3cd14c9e74 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 02:08:09 +0800 Subject: [PATCH 15/18] chore(loop): check out the PR's code for same-repo PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking out the default branch unconditionally broke `pr-review`, which is one of the loop's main jobs: reviewing a PR requires the PR's code, and `main` never has it. Every `pull_request` run on this very PR failed with MODULE_NOT_FOUND (`scripts/loop/` does not exist on `main` yet), which also made the failure look like a defect rather than the missing file it was. The rule is now keyed on whether the code is trusted: workflow_dispatch → github.ref (dispatch can only target refs in this repository) same-repo PR → the PR head branch (only OWNER/MEMBER/COLLABORATOR can push such branches; this is what makes loop's own `loop/-*` PRs reviewable) everything else → the default branch, which is where **fork PRs** land: external code is never checked out, only this repository's trusted scripts run, and the diff is read with `gh pr diff` Verified the expression across seven event shapes, including the two that matter for safety: a fork PR resolves to the default branch even though its head ref is present, and a same-repo PR resolves to its head branch. --- .github/workflows/loop.yml | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index cf4a88e..3aedc97 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -76,15 +76,27 @@ jobs: R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }} steps: - # checkout 什么: - # - 生产路径(issues / schedule / release / pull_request_target)必须用**默认分支** - # 的可信宿主代码 —— 尤其是 fork PR,绝不执行或检出外部代码(diff 用 gh 读)。 - # - workflow_dispatch 例外:它是人来指定的冒烟入口,用 github.ref。 - # 否则本 workflow 自己的 PR 无法被测试(默认分支上还没有这些脚本)。 - # dispatch 只能针对本仓的 ref 触发,fork 分支不在其中,因此仍然安全。 + # checkout 什么 —— 按"这段代码可不可信"决定: + # + # workflow_dispatch → github.ref。人来指定的冒烟/演练入口;dispatch 只能针对 + # 本仓 ref,fork 分支不在其中,所以仍然安全。 + # 同仓 PR → PR 的 head 分支。loop 自己开的 PR(`loop/-*`)就是这一类, + # 而 pr-review 必须拿到 PR 的代码才能审 —— 只检 default 分支 + # 会让 pr-review 永远看不到被审内容。同仓分支只有 + # OWNER/MEMBER/COLLABORATOR 能建,属可信代码。 + # 其他一切 → 默认分支。**fork PR 落在这里**:绝不 checkout 外部代码, + # 只用本仓默认分支的可信脚本,diff 用 `gh pr diff` 读。 + # issues / schedule / release / pull_request_target 同理。 - uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || github.event.repository.default_branch }} + 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: From efaf5a4b0c53fbaedb85da64cd8e354fa1b76d0e Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 02:10:35 +0800 Subject: [PATCH 16/18] chore(loop): log every decision that produces no run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skip was reported only in the Step Summary, so the Actions log showed the route line and then stopped — indistinguishable from a silent failure. Under an event storm this is exactly the line a human needs, since most events are supposed to produce nothing. Every no-run path now states its reason in the log: task not claimable (idempotent skip), event with no matching task, event already consumed, event queued in the inbox with no run, and unrouted events. Also records D24 (same-repo PRs must check out the PR head, or `pr-review` can never see what it reviews — this was why the A1 PR's own `loop` check failed) and D26 as open: a `waiting-merge` task cannot be re-claimed, so a loop PR that gains a commit is skipped rather than re-reviewed. D26 is left for A1's week to quantify. --- scripts/loop/README.md | 20 ++++++++++++++++++++ scripts/loop/entry.mjs | 18 +++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/scripts/loop/README.md b/scripts/loop/README.md index 7905815..5710eda 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -389,6 +389,26 @@ was *correct*, and whether new events produce proposals that match reality. 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. ## Environment facts diff --git a/scripts/loop/entry.mjs b/scripts/loop/entry.mjs index 753c068..a403831 100644 --- a/scripts/loop/entry.mjs +++ b/scripts/loop/entry.mjs @@ -97,8 +97,14 @@ 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) return { state: 'noop', note: `#${taskId} 无对应任务,事件不入箱` }; - if ((t.eventIds ?? []).includes(key)) return { state: 'noop', note: `事件已消费(${key})` }; + if (!t) { + log(`事件不入箱:#${taskId} 尚无对应任务(先由生命周期事件建档)`); + return { state: 'noop', note: `#${taskId} 无对应任务,事件不入箱` }; + } + if ((t.eventIds ?? []).includes(key)) { + log(`事件已消费(幂等跳过):${key}`); + return { state: 'noop', note: `事件已消费(${key})` }; + } t.eventInbox = [...(t.eventInbox ?? []), { id: key, type: `${ctx.eventName}.${ctx.action}`, at: nowIso(), summary: summarizeEvent(ctx), @@ -113,6 +119,7 @@ async function handleInbox({ ctx, repo }) { if (t.status === 'waiting-info') { return { state: 'run', taskId: t.id, stage: 'triage', kind: t.kind, note: '补充信息后重新 triage' }; } + log(`事件已入箱,不产生 run:任务 #${t.id} status=${t.status}(留待消费方接手)`); return { state: 'inbox-only', note: `任务 status=${t.status},事件已入箱待消费` }; } @@ -144,7 +151,12 @@ async function doRun({ decision, ctx, repo, writeLevel }) { const kind = decision.kind ?? 'issue'; task = await prepareTask({ taskId: decision.taskId, kind, repo }); const claim = claimability(task); - if (!claim.ok) return { state: 'skipped', note: `任务 #${task.id} 不可领取:${claim.reason}` }; + if (!claim.ok) { + // 跳过必须有日志:事件风暴下,人要从 Actions 日志一眼看出 + // "这个事件被正确跳过了",而不是以为它悄悄失败。 + log(`跳过:任务 #${task.id} 不可领取(${claim.reason})—— 事件已幂等处理,不产生 run`); + return { state: 'skipped', note: `任务 #${task.id} 不可领取:${claim.reason}` }; + } if (claim.resume) warn(`任务 #${task.id} 锁已过期,接管续跑`); } From cd29dad67921329ddc35eb1324686c35a2f0e53a Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 02:18:18 +0800 Subject: [PATCH 17/18] chore(norms): add the commit-message convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The type decides what `changelogen` puts in the user-facing CHANGELOG, so it has to describe what a *package consumer* can observe — not what changed in this repository. Records the rule, and the reason maintenance work must not borrow `fix` or `refactor`: both are rendered in the CHANGELOG and `refactor` also carries a patch semver, so internal iteration would read to a user as a change to the package. This came out of the A1 branch, where 14 loop commits were written as `fix`/`refactor`/`docs` and would have shipped as "Fixes" and "Refactors". --- AGENTS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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`. From 5df0f974b6f33c152ea4b18dcf5ed1b225c4bea6 Mon Sep 17 00:00:00 2001 From: Alex Chao Date: Fri, 11 Sep 2026 02:34:43 +0800 Subject: [PATCH 18/18] chore(loop): translate the loop host to English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository is English-only apart from `README_zh-CN.md`, and the loop host introduced in this branch was written in Chinese — comments, log messages and error strings across nine files, which would have left `scripts/loop/` half in one language and half in another (the A0 README and the norms layer are English). Translated: comments and every human-facing string in the four shared modules, the orchestrator, both CLIs, the workflow and the host README. Untouched by design: identifiers, JSON keys, CLI flags, env var names, paths, the provider/network regexes that match English error text from pi and HTTP servers, and the agent prompt body. `README.md`'s language switcher and the existing Chinese in `example/`, `test/` and `src/cos/signature.ts` are out of scope and unchanged. `state/SUMMARY.md` section titles necessarily changed with `renderSummary` (`In progress`, `Awaiting human merge`, `Inbox`, `Ready to claim`, `Unprocessed (new)`, `Terminal counts`, `Recent acceptance`, `Recent runs`). The immutable event logs under `state/tasks/` and `state/runs/` keep their historical Chinese, which is what an append-only log should do. Also fixes D27, found while reviewing the translated output: `run.mjs` subcommands did not catch errors thrown by the shared library, so `pnpm loop start --task ` printed a Node stack trace instead of a one-line reason. Verified behaviour is unchanged: 17 route-decision cases, 9 failure- classification cases (including the credential and rate-limit regexes), the full CLI flow with its idempotence and stage-whitelist guards, every orchestrator path (no-op, metrics, system-level run, task-scoped run, inbox), smoke mode, the English SUMMARY rendering, and the R2 bucket derivation. --- .github/workflows/loop.yml | 309 ++++++++++++++-------------- scripts/loop/README.md | 8 +- scripts/loop/entry.mjs | 134 ++++++++----- scripts/loop/r2-sync.mjs | 17 +- scripts/loop/run.mjs | 158 +++++++++------ scripts/loop/shared/agent.mjs | 93 ++++++--- scripts/loop/shared/r2.mjs | 367 ++++++++++++++++++---------------- scripts/loop/shared/route.mjs | 60 +++--- scripts/loop/shared/state.mjs | 174 ++++++++-------- 9 files changed, 738 insertions(+), 582 deletions(-) diff --git a/.github/workflows/loop.yml b/.github/workflows/loop.yml index 3aedc97..8863866 100644 --- a/.github/workflows/loop.yml +++ b/.github/workflows/loop.yml @@ -1,149 +1,162 @@ -name: Loop - -# 单一 workflow = 调度器 + 沙盒(05 §5.1):所有事件并入同一串行域, -# concurrency 组提供平台级单写者锁(R2 无并发写)。 -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(每周 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 - -# 最小权限:读 GitHub 用 GITHUB_TOKEN;写操作(仅 execute_writes=true 预演)走 -# LOOP_GH_TOKEN,且只在默认分支上下文存在 —— fork 事件无 secrets(平台保证)。 -permissions: - contents: read - issues: read - pull-requests: read - -jobs: - loop: - runs-on: ubuntu-24.04 - timeout-minutes: 60 - # 注意:job 级 env 只能引用 github / secrets / vars 等上下文。 - # `inputs` 在非 workflow_dispatch 事件下不可用,`runner` 上下文只存在于 step 内 —— - # 两者都不放在这里(曾经导致 workflow 解析失败,GitHub 只报 "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 }} - # 非 dispatch 事件下 github.event.inputs 为 null → 取默认 report(严格只报告) - 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,留出 push 余量(job timeout 60min) - GH_TOKEN: ${{ secrets.LOOP_GH_TOKEN || github.token }} - # 凭据:只能是 secret - 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 }} - # 非敏感配置:优先读 repository variable,回落同名 secret(两种配法都支持)。 - # ACCOUNT_ID 是 R2 端点的一部分(https://.r2.cloudflarestorage.com),不是凭据。 - R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID || secrets.R2_ACCOUNT_ID }} - # 桶名无需配置:代码按 `loop-state--` 自动推导(每仓一桶)。 - # 仅当要指向别的桶(迁移/排查)时才设它;未设时展开为空串 → 走推导。 - R2_BUCKET: ${{ vars.R2_BUCKET || secrets.R2_BUCKET }} - - steps: - # checkout 什么 —— 按"这段代码可不可信"决定: - # - # workflow_dispatch → github.ref。人来指定的冒烟/演练入口;dispatch 只能针对 - # 本仓 ref,fork 分支不在其中,所以仍然安全。 - # 同仓 PR → PR 的 head 分支。loop 自己开的 PR(`loop/-*`)就是这一类, - # 而 pr-review 必须拿到 PR 的代码才能审 —— 只检 default 分支 - # 会让 pr-review 永远看不到被审内容。同仓分支只有 - # OWNER/MEMBER/COLLABORATOR 能建,属可信代码。 - # 其他一切 → 默认分支。**fork PR 落在这里**:绝不 checkout 外部代码, - # 只用本仓默认分支的可信脚本,diff 用 `gh pr diff` 读。 - # 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 对无效 --model 是**静默回退到默认模型**,不报错 —— 一个拼错的 - # 模型名会安静地用错模型跑完每一次 run。所以在装好后立刻校验。 - want="${LOOP_MODEL#*/}" - if ! grep -qE "(^|[[:space:]])${want}([[:space:]]|$)" <<<"$available"; then - echo "::error::LOOP_MODEL=$LOOP_MODEL 不在 pi 的 DeepSeek 目录中(期望模型段 '$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 - - # 崩溃/超时也要把状态层推回(任务留在 processing,由下次 sweep 按 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 +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/scripts/loop/README.md b/scripts/loop/README.md index 5710eda..b1f794c 100644 --- a/scripts/loop/README.md +++ b/scripts/loop/README.md @@ -381,7 +381,7 @@ was *correct*, and whether new events produce proposals that match reality. 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 `未处理 (new)` + 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 @@ -409,6 +409,12 @@ was *correct*, and whether new events produce proposals that match reality. 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 diff --git a/scripts/loop/entry.mjs b/scripts/loop/entry.mjs index a403831..1f80f8e 100644 --- a/scripts/loop/entry.mjs +++ b/scripts/loop/entry.mjs @@ -1,21 +1,24 @@ #!/usr/bin/env node /** - * Loop runner 编排入口 —— GitHub Actions job 调用的唯一脚本。 + * Loop runner orchestration entry — the only script the GitHub Actions job calls. * - * 一次调用 = 一个决策:路由事件 → 落 inbox / 写评测 / 跑一个 run。 - * 状态层读写全部经 `shared/state.mjs`;本文件只做编排与报告,不直接改状态字段。 + * 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. * - * 不变量: - * - 收尾是**唯一写入者**:agent 只写 `state/reports/.result.json`, - * 由本文件调 finishRun 落状态(05 §5.1 把收尾列为 run 脚本仪式)。 - * - report 边界下不产生任何 GitHub 写操作(写动作只作为清单进报告)。 - * - 事件幂等:`eventIds` 记已消费事件键,重复投递 → no-op。 + * 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. * - * 环境契约(workflow 注入): + * Environment contract (injected by the workflow): * LOOP_EVENT_NAME / LOOP_EVENT_ACTION / LOOP_EVENT_JSON / LOOP_REPO - * LOOP_WRITE_LEVEL(report|auto,默认 report) - * LOOP_MODEL(pi 的 --model,如 deepseek/deepseek-flash) - * LOOP_RUN_TIMEOUT_MS、LOOP_SESSION_DIR、GITHUB_STEP_SUMMARY + * 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'; @@ -38,17 +41,20 @@ const S = makeState(ROOT); const log = (m) => console.log(`[loop] ${m}`); const warn = (m) => console.warn(`[loop] warn: ${m}`); -/* ------------------------------------------------------------ GitHub 读取 */ +/* ------------------------------------------------------------ GitHub read */ function ghJson(args) { const r = spawnSync('gh', args, { encoding: 'utf8' }); - if (r.status !== 0) throw new Error(`gh ${args.join(' ')} 失败: ${(r.stderr || '').trim()}`); + 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'; -/** 任务文件不存在时从 GitHub 拉取建档;已存在则镜像标签(GitHub 为准)。 */ +/** + * 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) { @@ -66,7 +72,7 @@ async function prepareTask({ taskId, kind, repo }) { }; t.timeline.push({ at: t.createdAt, event: 'created', by: 'workflow', detail: `${kind} from GitHub` }); await writeJson(S.taskFile(t.id), t); - log(`任务 #${t.id} 已建档(${kind})`); + log(`task #${t.id} created (${kind})`); return t; } @@ -74,14 +80,14 @@ const claimability = (t) => { if (t.status === 'processing') { return lockExpired(t.lockedBy) ? { ok: true, resume: true } - : { ok: false, reason: `被 ${t.lockedBy?.runId} 持有` }; + : { 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 ?? {}; @@ -91,19 +97,20 @@ function summarizeEvent(ctx) { } /** - * 信息补足事件:永不静默丢弃。先落 eventInbox,再按任务状态决定是否补跑(05 §3 判定顺序)。 + * 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(`事件不入箱:#${taskId} 尚无对应任务(先由生命周期事件建档)`); - return { state: 'noop', note: `#${taskId} 无对应任务,事件不入箱` }; + 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(`事件已消费(幂等跳过):${key}`); - return { state: 'noop', note: `事件已消费(${key})` }; + log(`event already consumed (idempotent skip): ${key}`); + return { state: 'noop', note: `event already consumed (${key})` }; } t.eventInbox = [...(t.eventInbox ?? []), { @@ -112,18 +119,23 @@ async function handleInbox({ ctx, repo }) { t.eventIds = [...(t.eventIds ?? []), key].slice(-50); await saveTask(S, t); - // new/ready 补跑原 stage;waiting-info 重新 triage;其余状态留待消费方接手 + // 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: '补跑' }; + 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: '补充信息后重新 triage' }; + return { + state: 'run', taskId: t.id, stage: 'triage', kind: t.kind, note: 're-triage on new info', + }; } - log(`事件已入箱,不产生 run:任务 #${t.id} status=${t.status}(留待消费方接手)`); - return { state: 'inbox-only', note: `任务 status=${t.status},事件已入箱待消费` }; + 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 }; @@ -152,12 +164,12 @@ async function doRun({ decision, ctx, repo, writeLevel }) { task = await prepareTask({ taskId: decision.taskId, kind, repo }); const claim = claimability(task); if (!claim.ok) { - // 跳过必须有日志:事件风暴下,人要从 Actions 日志一眼看出 - // "这个事件被正确跳过了",而不是以为它悄悄失败。 - log(`跳过:任务 #${task.id} 不可领取(${claim.reason})—— 事件已幂等处理,不产生 run`); - return { state: 'skipped', note: `任务 #${task.id} 不可领取:${claim.reason}` }; + // 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.id} 锁已过期,接管续跑`); + if (claim.resume) warn(`task #${task.id} lock expired, taking over and resuming`); } const rid = await beginRun(S, task, stage, { @@ -165,7 +177,9 @@ async function doRun({ decision, ctx, repo, writeLevel }) { trigger: `${ctx.eventName}.${ctx.action || '(none)'}`, model: process.env.LOOP_MODEL ?? null, }); - log(`run ${rid} 开始(${task ? `task #${task.id}, ` : '系统级, '}stage=${stage}${decision.mode === 'readonly' ? ', readonly' : ''})`); + 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, @@ -187,22 +201,23 @@ async function doRun({ decision, ctx, repo, writeLevel }) { const events = parseEvents(res.stdout); const { usage, stopReason, toolCalls, turns } = summarize(events); - // agent 的结论文件(编排层是收尾的唯一写入者) + // 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 判定(exit=${res.code})`; + note = result.note ?? `agent decision (exit=${res.code})`; } else if (result?.outcome) { outcome = 'failed'; - note = `agent 给出 outcome=${result.outcome},但 stage=${stage} 不允许(允许: ${allowedOutcomes(stage).join(', ')})`; + 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}(无结果文件)`; - warn(`未取到 ${rid}.result.json —— ${note}`); + note = `${cls.reason} (no result file)`; + warn(`no ${rid}.result.json found — ${note}`); } const { task: done, actions } = await finishRun(S, { @@ -214,11 +229,13 @@ async function doRun({ decision, ctx, repo, writeLevel }) { writeLevel, log, warn, }); - log(`run ${rid} 收尾: outcome=${outcome} (exit ${cls.exit})${usage?.totalTokens ? `, tokens=${usage.totalTokens}` : ''}`); + log(`run ${rid} finished: outcome=${outcome} (exit ${cls.exit})` + + `${usage?.totalTokens ? `, tokens=${usage.totalTokens}` : ''}`); if (actions.length) { log(writeLevel === 'auto' - ? `GitHub 动作已执行 ${actions.length} 项` - : `GitHub 动作未执行(report 边界):${actions.length} 项待人工,见报告与 Step Summary`); + ? `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(',')}` : ''}`); @@ -233,7 +250,7 @@ async function doRun({ decision, ctx, repo, writeLevel }) { async function writeStepSummary(md) { const f = process.env.GITHUB_STEP_SUMMARY; - if (!f) { log('(无 GITHUB_STEP_SUMMARY,跳过)'); return; } + if (!f) { log('(no GITHUB_STEP_SUMMARY, skipping)'); return; } await fs.appendFile(f, md + '\n', 'utf8'); } @@ -271,21 +288,23 @@ async function main() { 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'; - // 写边界:workflow 传 LOOP_EXECUTE_WRITES(workflow_dispatch 的预演开关), - // 也接受显式 LOOP_WRITE_LEVEL 覆盖(本地调试用)。 + // 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}`); + log(`event=${eventName}.${action || '(none)'} → ${decision.action} ` + + `(${decision.reason}) · writeLevel=${writeLevel}`); - // 冒烟模式:只验证基础设施(checkout / 工具链 / pi 安装 / R2 pull+push), - // 不启动 agent。A1 的手动冒烟入口要能在不消耗 token、不等 LLM 的情况下 - // 回答两件事:管道通不通,以及刚拉下来的状态层对不对。 + // 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 模式:跳过 agent,仅验证基础设施'); + 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; @@ -306,7 +325,7 @@ async function main() { 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(`状态层任务数为 ${tasks.length},期望 ${expectsState}`); + throw new Error(`state layer has ${tasks.length} tasks, expected ${expectsState}`); } await writeStepSummary([ @@ -332,17 +351,22 @@ async function main() { 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})` }, + 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 ? `评测已写入:${JSON.stringify(result.row)}` : `评测已存在,跳过(幂等)`); + 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 / 已完成的 run 都是成功退出 + return 0; // noop / inbox-only / metrics / a completed run all exit successfully } main() diff --git a/scripts/loop/r2-sync.mjs b/scripts/loop/r2-sync.mjs index 0b6f39a..c35ae73 100644 --- a/scripts/loop/r2-sync.mjs +++ b/scripts/loop/r2-sync.mjs @@ -1,12 +1,15 @@ #!/usr/bin/env node /** - * R2 状态层同步 CLI —— workflow 的独立步骤调用(不依赖 agent 是否跑完)。 + * R2 state layer sync CLI — invoked as a standalone workflow step (independent of + * whether the agent has finished running). * - * 用法: node scripts/loop/r2-sync.mjs pull|push|seed|check - * pull job 开始:远端 → 本地 state/(失败必须中止,否则 push 会覆盖远端为本地空态) - * push job 结束(if: always()):本地 state/ → 远端(不带 --delete,R2 无版本化) - * seed 一次性播种本地已有状态层 - * check 连通性与凭据自检 + * 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'; @@ -25,7 +28,7 @@ try { else if (cmd === 'seed') { console.log(`[loop:r2] seed ← ${S.root}`); seed(S.root); } else if (cmd === 'check') { check(); } else { - console.log('用法: node scripts/loop/r2-sync.mjs pull|push|seed|check'); + console.log('usage: node scripts/loop/r2-sync.mjs pull|push|seed|check'); process.exit(1); } } catch (e) { diff --git a/scripts/loop/run.mjs b/scripts/loop/run.mjs index b55d5ef..05633cb 100644 --- a/scripts/loop/run.mjs +++ b/scripts/loop/run.mjs @@ -1,24 +1,27 @@ #!/usr/bin/env node /** - * tiny-oss Loop 本地宿主(CLI) + * tiny-oss Loop local host (CLI) * - * 状态层原语全部来自 `./shared/state.mjs`(与 runner 编排 entry.mjs 共用, - * 保证单一实现)。本文件只负责人工驱动的命令行仪式。 + * 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. * - * 用法(`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 ] - * [--task ] # 反查未收尾 run(D2) + * [--task ] # look up an unfinished run (D2) * pnpm loop summary | view [--run | --task ] * - * 写边界(--write-level | LOOP_WRITE_LEVEL,默认 report): - * report 只写状态层 + 报告:GitHub 写动作仅以"待人工执行"清单呈现(A1 语义) - * auto 执行打标/评论/关闭(A0 已放权语义;升 L2 后为常态) + * 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 取值与状态转移(ops.md "Labels → task state"): + * 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 @@ -28,8 +31,9 @@ * accepted → accepted None * failed → waiting-human + needs-triage * - * D9:start 领取时若本地任务为终态(closed/rejected)而 GitHub issue 已重开为 OPEN, - * 自动重置 ready + 清 decision + timeline 记 reopened 再领取,回 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 path from 'node:path'; @@ -57,7 +61,8 @@ function fail(msg) { function resolveWriteLevel(args) { const wl = args['write-level'] ?? process.env.LOOP_WRITE_LEVEL ?? 'report'; - if (!WRITE_LEVELS.includes(wl)) fail(`--write-level 须为 ${WRITE_LEVELS.join('|')}(当前: ${wl})`); + if (!WRITE_LEVELS.includes(wl)) fail(`--write-level must be ${WRITE_LEVELS.join('|')} ` + + `(got: ${wl})`); return wl; } @@ -67,21 +72,24 @@ async function cmdStart(args) { 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 || pr) { - if (isNew && !title) fail('--new 需要 --title'); + if (isNew && !title) fail('--new requires --title'); let id; let kind = 'issue'; let extra = {}; if (pr) { - // PR 与 issue 共用 triage 面(issue-tracker.md);kind 区分,编号空间相同。 + // 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 失败(gh 未装或无认证?):${(gh.stderr || '').trim()}`); + 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} 状态=${it.state},仅 OPEN 的 PR 可导入(D1)`); + 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 }; @@ -94,9 +102,11 @@ async function cmdStart(args) { } 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 = { @@ -116,51 +126,62 @@ async function cmdStart(args) { extra = { synthetic: true }; } if (await readJson(S.taskFile(id))) { - fail(`任务 #${id} 已存在,拒绝覆盖(${S.taskFile(id)});重跑请用 start --task ${id} [--stage ](D3)`); + 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(S.taskFile(id), t); - console.log(`[loop] 任务 #${id} 已创建(${isNew ? '本地合成' : pr ? 'GitHub PR 导入' : 'GitHub 导入'})`); + console.log(`[loop] task #${id} created (` + + `${isNew ? 'local synthetic' : pr ? 'GitHub PR import' : 'GitHub import'})`); } else { - t = await loadTask(S, task ?? fail('--task 或 --pr/--issue/--new 必填')); + t = await loadTask(S, task ?? fail('--task or --pr/--issue/--new is required')); } - // 领取检查:new/ready/waiting-info 可领;processing 活锁拒领、死锁可覆盖; - // 终态任务而 GitHub issue 已重开 → 自动重置 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') { if (!lockExpired(t.lockedBy)) { - fail(`任务 #${t.id} 被 ${t.lockedBy?.runId ?? '?'} 持有(status=processing),请先结束或等 TTL 过期`); + 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: 任务 #${t.id} 的锁已过期(${t.lockedBy?.runId}),本次接管续跑`); + 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 = repoOf(t.url); const ghState = repo ? spawnSync('gh', ['issue', 'view', String(t.id), '-R', repo, '--json', 'state', '-q', '.state'], { encoding: 'utf8' }) : null; if (ghState && ghState.status === 0 && ghState.stdout.trim() === 'OPEN') { - console.warn(`[loop] warn: 任务 #${t.id} 本地 status=${t.status},GitHub issue 已重开 → 重置为 ready(D9)`); + 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 = await beginRun(S, t, s, { sandbox: `local:${process.platform}`, trigger: 'manual' }); - console.log(`[loop] run 开始: ${rid}`); - console.log(`[loop] task #${t.id}(stage=${s})已领取 → ${path.relative(ROOT, S.taskFile(t.id))}`); - 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'); const hasSkill = await fs.access(skillFile).then(() => true, () => false); console.log(hasSkill ? `[loop] skills/${s}/SKILL.md` - : `[loop] skills/${s}/SKILL.md 不存在(该 stage 无专属 skill;用到 verify/review 时读对应 SKILL.md)`); - console.log(`[loop] 写边界: ${resolveWriteLevel(args)}(report = 只写状态层与报告,GitHub 写动作仅列出)`); - console.log('[loop] 过程可记 checkpoint;完成后执行:'); + : `[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 "..."]`); } @@ -171,17 +192,18 @@ async function cmdEnd(args) { const wl = resolveWriteLevel(args); let rid = run; if (!rid) { - if (!task) fail('--run 或 --task (反查未收尾 run)必填'); + 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(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}`); + console.log(`[loop] looked up unfinished run: ${rid}`); } try { const { task: t, actions, applied } = await finishRun(S, { @@ -190,20 +212,21 @@ async function cmdEnd(args) { log: console.log, warn: console.warn, }); const m = OUTCOME_MAP[outcome]; - console.log(`[loop] run ${rid} 收尾: outcome=${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 写动作已执行 ${applied.length}/${actions.length} 项`); + console.log(`[loop] GitHub write actions applied ${applied.length}/${actions.length}`); } else { - console.log('[loop] 写边界=report:以下 GitHub 动作待人工执行'); + console.log('[loop] write level=report: GitHub write actions awaiting human execution'); for (const d of describeActions(actions)) console.log(` - ${d}`); - console.log(`[loop] 已追加到报告: ${path.relative(ROOT, path.join(S.reportsDir, `${rid}.md`))}`); + console.log(`[loop] appended to report: ` + + `${path.relative(ROOT, path.join(S.reportsDir, `${rid}.md`))}`); } } else if (noGithub) { - console.log('[loop] --no-github:跳过 GitHub 写动作'); + console.log('[loop] --no-github: skipping GitHub write actions'); } - console.log('[loop] SUMMARY.md 已刷新'); + console.log('[loop] SUMMARY.md refreshed'); } catch (e) { fail(e.message); } @@ -218,11 +241,11 @@ function repoOf(url) { async function cmdCheckpoint(args) { const { run: rid, note } = args; - if (!rid || !note) fail('--run --note "..." 必填'); + if (!rid || !note) fail('--run --note "..." is required'); const rows = await readRunLines(S, rid); - if (!rows.length) fail(`run ${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 已记录`); + console.log(`[loop] run ${rid} checkpoint recorded`); } /* ---------------------------------------------------------------- view */ @@ -231,7 +254,7 @@ async function cmdView(args) { const { run: rid, task } = args; if (rid) { const rows = await readRunLines(S, rid); - if (!rows.length) fail(`run ${rid} 不存在`); + if (!rows.length) fail(`run ${rid} does not exist`); for (const l of rows) console.log(JSON.stringify(l, null, 2)); return; } @@ -250,28 +273,35 @@ 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 + 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] [--task ] - [--write-level report|auto] # 默认 report:GitHub 写动作仅列出 + [--write-level report|auto] # default report: GitHub write actions are only listed pnpm loop summary | view [--run |--task ] -outcome(任务): ${Object.keys(OUTCOME_MAP).join(' | ')} -outcome(系统级 run): completed | failed | retry | aborted -state 根: ${S.root}(env STATE_DIR 可覆盖) -写边界: LOOP_WRITE_LEVEL=report|auto(默认 report)`); +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(S); - if (cmd === 'start') return cmdStart(args); - if (cmd === 'end') return cmdEnd(args); - if (cmd === 'checkpoint') return cmdCheckpoint(args); - if (cmd === 'summary') return renderSummary(S); - if (cmd === 'view') return cmdView(args); - fail(`未知子命令 ${cmd}(--help 查看用法)`); + // 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 index 037e641..812b992 100644 --- a/scripts/loop/shared/agent.mjs +++ b/scripts/loop/shared/agent.mjs @@ -1,22 +1,30 @@ /** - * 驱动 pi 无头执行一个 loop run。 + * Drive pi headlessly to execute one loop run. * - * 分工:开场/执行/收尾由 agent 按 prompt 完成(与 A0 的 skill 约定一致); - * 本模块只负责构造 prompt、spawn、采集用量与退出信号、分类失败。 - * 状态写入仍由 agent 调 `pnpm loop end` 触发(复用 run.mjs 的白名单与幂等), - * 编排层只在该调用缺失时兜底。 + * 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 事实(2026-09 一手验证): - * - `--mode json` → JSONL 事件流(session / agent_start / turn_* / message_* / - * tool_execution_* / agent_end);末轮 message_end 带权威 usage 与 stopReason。 - * - `--approve` 必需:非交互模式不弹 trust prompt,缺它就忽略项目本地资源。 - * - 退出码:正常 0;末轮 stopReason=error|aborted → 1;SIGTERM 143 / SIGHUP 129。 - * - print 模式合并管道 stdin 到初始 prompt(大 prompt 走 stdin,避开 argv 上限)。 + * 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'; -/** 提示词语言与 skills/AGENTS.md 保持一致(英文),避免 agent 在英文规范里读中文指令。 */ +/** + * 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}.`); @@ -85,18 +93,21 @@ export function buildPrompt({ task, stage, runId, writeLevel, mode, repo, lead = return L.join('\n'); } -/** JSONL → 事件数组;非 JSON 行(进度提示等)忽略。 */ +/** 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 { /* 忽略非事件行 */ } + try { events.push(JSON.parse(s)); } catch { /* ignore non-event lines */ } } return events; } -/** 从事件流提取用量与末轮停止原因(usage 只在 message_end 权威)。 */ +/** + * 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; @@ -108,7 +119,10 @@ export function summarize(events) { }; } -/** 采样 pi 的会话文件(artifact 用):返回指定目录下最新的 .jsonl。 */ +/** + * 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 = () => {} }) { @@ -122,8 +136,9 @@ export function runPi({ prompt, cwd, sessionDir, model, timeoutMs = 3600000, log ]; if (model) args.push('--model', model); - // 引擎是模板层的实例变量 {{engine}}(05 §4.2):默认 pi,可用 LOOP_ENGINE_CMD 覆盖 - // (如本地用别的 CLI/包装脚本跑同一契约)。 + // 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'], @@ -134,7 +149,7 @@ export function runPi({ prompt, cwd, sessionDir, model, timeoutMs = 3600000, log const timer = setTimeout(() => { timedOut = true; - log(`[loop] run 超时(${Math.round(timeoutMs / 60000)}min),发送 SIGTERM`); + log(`[loop] run timed out (${Math.round(timeoutMs / 60000)}min), sending SIGTERM`); child.kill('SIGTERM'); setTimeout(() => child.kill('SIGKILL'), 30000).unref?.(); }, timeoutMs); @@ -150,35 +165,47 @@ export function runPi({ prompt, cwd, sessionDir, model, timeoutMs = 3600000, log resolve({ code: code ?? (signal ? 1 : 0), signal, stdout, stderr, timedOut }); }); - child.stdin.on('error', () => { /* spawn 失败或提前退出时的 EPIPE 不应炸掉流程 */ }); + // 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(); }); } /** - * 失败分类(Q6 决策):机器故障 → 重试;agent 判定失败 → 收件箱;分不清 → 保守重试。 - * 收件箱是给人看的,不该被 provider 抖动灌满(否则人工介入率失去意义)。 - * 退出码沿用 run 契约:0 完成 / 1 失败→收件箱 / 2 卡死中止 / 3 建议重试。 + * 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 超时中止' }; - if (code === 0) return { exit: 0, kind: 'ok', reason: 'pi 正常退出' }; + 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: `被信号终止(${signal ?? code})` }; + 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)' }; } - if (code === -1) return { exit: 3, kind: 'retry', reason: 'pi 无法启动(安装/路径问题)' }; const s = String(stderr ?? ''); - // 凭据/配置类故障(缺 key、key 无效、pi 未解析到模型):属宿主配置问题,不是任务问题, - // 绝不能因此把任务推进人工收件箱 —— 实测 pi 在无 key 时输出 "No models available"。 + // 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: '引擎凭据/配置未就绪 → 重试(需检查 secrets)' }; + 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: '疑似 provider/网络瞬时故障 → 重试' }; + 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} → 转收件箱` }; + return { exit: 1, kind: 'failed', reason: `agent stopReason=${stopReason} → to inbox` }; } - return { exit: 3, kind: 'retry', reason: `退出码 ${code} 无法归因 → 保守重试` }; + 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 index 9e22eb0..7fffdf4 100644 --- a/scripts/loop/shared/r2.mjs +++ b/scripts/loop/shared/r2.mjs @@ -1,173 +1,194 @@ -/** - * R2 状态层同步(pull / push / seed)。 - * - * 工具选择:runner 镜像(ubuntu-24.04)预装 AWS CLI v2(2.36.x)→ 直接走 R2 的 - * S3 兼容端点,零安装、零新增仓库依赖(不引 @aws-sdk/client-s3)。 - * - * 取值要点: - * - region 传 `auto`:R2 要求非空但不使用该值(官方 aws CLI 文档)。 - * - `AWS_REQUEST_CHECKSUM_CALCULATION=when_required`:AWS CLI ≥ 2.23.0 默认对上传 - * 计算 CRC32 校验和,对部分 S3 兼容服务不友好;按需计算是兼容性最保守的一档。 - * - `AWS_EC2_METADATA_DISABLED=true`:避免在非 EC2 环境探测实例元数据拖慢/报错。 - * - * 失败语义(重要):pull 失败必须中止整条链 —— 否则本地 state/ 为空, - * 随后 push 出去的就不是完整状态层。 - */ - -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)), '..', '..', '..'); - -/** - * 桶内路径前缀。**每仓一桶**(owner 定稿),桶名已承载仓库身份, - * 前缀不再重复 repo 段 —— 远端 key 与本地 `state/` 一一对应。 - */ -export const R2_PREFIX = 'state/'; - -/** - * 每仓一桶,桶名 `loop-state--`。 - * - * 为什么不用一个桶装所有仓库:R2 的 API token 权限只能限到**桶**,限不到前缀 - * (Access Policy 的资源标识是 `...r2.bucket.__`, - * 没有 key/prefix 维度)。共用桶时「每仓一把独立 token」形同虚设 —— 每把都能读写 - * 整个桶,一次凭据泄漏或 fork 注入的影响面就是所有接入仓库。 - * - * 为什么带 owner:桶名是账号内全局唯一的平铺空间,桶名撞车就真的撞车。带上 owner - * 把冲突面从「仓库名」提到「owner + 仓库名」,将来管理多个 GitHub 组织也不会互撞。 - * `R2_BUCKET` 可覆盖(迁移、排查、临时指向别的桶)。 - */ -let ownerRepoCache = null; - -/** 解析 owner/repo:CI 取 GITHUB_REPOSITORY,本地取 package.json 的 repository.url。 */ -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(`无法确定仓库身份:GITHUB_REPOSITORY 未设,且读不到 ${path.join(ROOT, 'package.json')}(${e.message})。请显式设置 R2_BUCKET。`); - } - // npm 允许 repository 是字符串(`"owner/repo"`、`"github:owner/repo"`)或对象(`{ 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(`无法从 package.json 推导 owner/repo(repository = ${raw || '缺失'})。请显式设置 R2_BUCKET。`); - } - return (ownerRepoCache = { owner: m[1], repo: m[2] }); -} - -/** R2 桶名只允许小写字母/数字/连字符,且不以连字符开头或结尾(官方约束)。 */ -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 仓库名上限 100 字符,`owner-repo` 拼接后理论上会超过 63。 - // 截断 + 稳定哈希:同一仓库每次推导同一桶名,不同仓库也不会因截断而碰撞。 - const h = createHash('sha256').update(base).digest('hex').slice(0, 8); - return `${base.slice(0, BUCKET_MAX - h.length - 1).replace(/-+$/, '')}-${h}`; -} - -/** 本仓的 R2 桶名。 */ -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(`缺少 R2 环境变量: ${missing.join(', ')}(应配为 Actions secret/variable,勿写进仓库文件)`); - } - 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 会把结果交给 pager(`less`)。脚本化调用下这表现为"命令挂起"—— - // 光标停住、毫无输出、其实是在等按键。程序化调用永远不要 pager。 - AWS_PAGER: '', - // 跨境访问 R2 的握手约 0.4s,但慢链路下会退化。给出明确的边界, - // 让失败快速暴露成错误,而不是长时间静默重试。 - AWS_MAX_ATTEMPTS: process.env.AWS_MAX_ATTEMPTS ?? '3', - }; -} - -/** 全局 CLI 边界:连接/读取超时,避免"看起来挂起"。 */ -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}`; - -/** - * 调用 aws CLI。 - * - * `stream: true` 让子进程直接写终端 —— sync 类操作在跨境链路上可能需要数秒到 - * 数十秒,spawnSync 的缓冲会让这段时间毫无输出("看起来挂起")。只有在需要 - * 解析输出的场合(check 数行数)才捕获。 - */ -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}] 无法执行 aws cli:${r.error.message}`); - if (stream) { - if (r.status !== 0) throw new Error(`[r2:${label}] aws cli 退出码 ${r.status}`); - return ''; - } - const out = (r.stdout ?? '').trim(); - const err = (r.stderr ?? '').trim(); - if (r.status !== 0) throw new Error(`[r2:${label}] aws cli 退出码 ${r.status}\n${err || out}`); - if (out) console.log(out); - if (err) console.error(err); - return out; -} - -/** 远端 → 本地。不做 --delete:runner 上 state/ 全新,陈旧文件问题不存在。 */ -export function pull(dir, { prefix = R2_PREFIX } = {}) { - return aws(['s3', 'sync', s3url(prefix), dir, '--endpoint-url', endpoint()], { label: 'pull', stream: true }); -} - -/** - * 本地 → 远端。**不带 `--delete`**:R2 没有对象版本化(Cloudflare 文档无此能力, - * 仅有 bucket lock 保留策略),删除不可回滚 —— 因此只做"覆盖 + 新增"。 - * 代价是 run 期间删掉的过期锁文件会残留在远端,由 TTL 语义消化(lockExpired)。 - */ -export function push(dir, { prefix = R2_PREFIX } = {}) { - return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'push', stream: true }); -} - -/** - * 首次播种:本地 → 远端,**不带 --delete**(只增不删)—— - * 远端已有对象时宁可保守,不冒清空风险。 - */ -export function seed(dir, { prefix = R2_PREFIX } = {}) { - return aws(['s3', 'sync', dir, s3url(prefix), '--endpoint-url', endpoint()], { label: 'seed', stream: true }); -} - -/** 连通性与凭据自检:列出前缀下对象数(空桶同样算通过)。 */ -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] 连通 OK,前缀 ${prefix} 现有 ${n} 个对象`); - return n; -} +/** + * 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 index c682c83..9e761c6 100644 --- a/scripts/loop/shared/route.mjs +++ b/scripts/loop/shared/route.mjs @@ -1,15 +1,21 @@ /** - * GitHub 事件 → Loop 决策(纯函数路由)。 + * GitHub event → Loop decision (pure-function router). * - * 权威:05 宿主实现规格 §3 路由表 / docs/agents/ops.md "Trigger → loop map"。 - * 本模块不做任何 IO:输入是 workflow 注入的事件上下文,输出 Decision,由 entry.mjs 执行。 - * 纯函数 = 可用固定事件样本离线回归,不必触发真实 GitHub 事件。 + * 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(其余 = 外部贡献者)。 */ +/** + * author_association considered "this repo's own people" + * (everything else = external contributor). + */ export const OWNER_ROLES = ['OWNER', 'MEMBER', 'COLLABORATOR']; -/** 每周 retro 的 cron(与 loop.yml 的 schedule 保持一致)。 */ +/** 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 }); @@ -17,7 +23,10 @@ const run = (o) => ({ action: 'run', ...o }); const metrics = (o) => ({ action: 'metrics', ...o }); const inbox = (o) => ({ action: 'inbox', ...o }); -/** loop PR 判定:head 分支 `loop/-*` 且 body 含 `loop-task: #`(两处一致才认)。 */ +/** + * 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 ?? ''); @@ -32,8 +41,9 @@ export const isDependabotPr = (pr) => export const isExternalPr = (pr) => !OWNER_ROLES.includes(pr?.author_association ?? ''); /** - * 幂等键:同键事件已消费过则跳过。 - * 优先用不可变标识(PR head sha / comment id),退回 issue 的 updated_at 版本号。 + * 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 ?? {}; @@ -57,15 +67,16 @@ export function route({ eventName, action, event = {} }) { if (action === 'opened' || action === 'reopened') { return run({ taskId: n, kind: 'issue', stage: 'triage', reason: `issues.${action}` }); } - // 信息补足:永不静默丢弃,先落 inbox 再按任务状态决定是否补跑(05 §3 判定顺序) + // 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} 无 Loop 动作`); + return noop(`issues.${action} has no Loop action`); } case 'issue_comment': { - if (action !== 'created') return noop(`issue_comment.${action} 无 Loop 动作`); + if (action !== 'created') return noop(`issue_comment.${action} has no Loop action`); const issue = event.issue ?? {}; return inbox({ taskId: issue.number, @@ -78,7 +89,7 @@ export function route({ eventName, action, event = {} }) { case 'pull_request': { const pr = event.pull_request ?? {}; if (action !== 'opened' && action !== 'synchronize') { - return noop(`pull_request.${action} 走其他路由`); + return noop(`pull_request.${action} routed elsewhere`); } const loopTask = loopTaskOf(pr); if (loopTask) { @@ -88,27 +99,30 @@ export function route({ eventName, action, event = {} }) { 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: '外部 PR 只读分析' }); + return run({ + taskId: pr.number, kind: 'pr', stage: 'triage', mode: 'readonly', + reason: 'external PR analysed read-only', + }); } - // PR 与 issue 共用 triage 面(issue-tracker.md: PRs ARE a triage surface) - return run({ taskId: pr.number, kind: 'pr', stage: 'triage', reason: `本仓 PR ${action}` }); + // 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} 无 Loop 动作`); + return noop(`pull_request_review.${action} has no Loop action`); } const loopTask = loopTaskOf(pr); - if (!loopTask) return noop('非 loop PR 的 review 不触发 run'); + 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} 无 Loop 动作`); + if (action !== 'closed') return noop(`pull_request_target.${action} has no Loop action`); const loopTask = loopTaskOf(pr); - if (!loopTask) return noop('非 loop 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'}`, @@ -123,7 +137,7 @@ export function route({ eventName, action, event = {} }) { } case 'release': { - if (action !== 'published') return noop(`release.${action} 无 Loop 动作`); + 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' }, @@ -146,10 +160,10 @@ export function route({ eventName, action, event = {} }) { }); } if (inputs.stage) return run({ stage: inputs.stage, reason: 'manual dispatch (no task)' }); - return noop('workflow_dispatch 未提供 task/stage'); + return noop('workflow_dispatch provided no task/stage'); } default: - return noop(`未监听的事件 ${eventName}`); + return noop(`unhandled event ${eventName}`); } } diff --git a/scripts/loop/shared/state.mjs b/scripts/loop/shared/state.mjs index dd944b1..8a3d6ca 100644 --- a/scripts/loop/shared/state.mjs +++ b/scripts/loop/shared/state.mjs @@ -1,23 +1,23 @@ /** - * tiny-oss Loop 状态层原语 —— 本地宿主 `run.mjs` 与 Actions 编排 `run-stage.mjs` 共用。 + * 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). * - * 唯一写入者原则:两个入口都只经本模块读写 `state/`,避免两份实现漂移 - * (A0 期 #33 的 acceptance 漏行就是人手漂移的实例)。 - * - * schema:任务文件 / run 行 / 锁 / SUMMARY 见 scripts/loop/README.md; - * 状态机与双口径定义见 docs/agents/ops.md、docs/agents/triage-labels.md。 + * 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 */ -/** 五角色标签(triage-labels.md):互斥,禁止叠加矛盾角色。 */ +/** 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 → 任务终态 + 应打标签(ops.md "Labels → task state")。 */ +/** 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' }, @@ -27,21 +27,21 @@ export const OUTCOME_MAP = { 'rejected': { status: 'rejected', label: null }, 'accepted': { status: 'accepted', label: null }, 'failed': { status: 'waiting-human', label: 'needs-triage' }, - // 宿主扩展(不在 ops.md 的 outcome 表内):provider/网络瞬时故障, - // 任务应回到可领取态而不是灌进收件箱 —— 收件箱是给人看的(run 契约退出码 3)。 + // 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 → 允许的成功 outcome 白名单(D5 防呆);未列出的 stage 全放行。 */ +/** stage → whitelist of allowed successful outcomes (D5 guard); others allow all. */ const COMMON_OUTCOMES = ['failed', 'rejected', 'accepted', 'retry']; /** - * 系统级 run(sweep / retro / release 预检)的 outcome —— 只记录"这一轮跑得怎样", - * 不映射任何任务状态。 + * System-level run (sweep / retro / release precheck) outcomes — they only record + * "how this round went" and map to no task status. * - * 为什么不能复用任务的 outcome:`closed` 在任务语义里意为"直接关闭、不进接受率 - * 分母"。系统级 run 若记 `closed`,retro 读 end 行做统计时会把 sweep/retro 的运行 - * 次数算成被关闭的任务,污染双口径。 + * 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']; @@ -63,7 +63,7 @@ export function allowedOutcomes(stage) { return [...COMMON_OUTCOMES, ...(STAGE_OUTCOMES[stage] ?? [])]; } -/* ---------------------------------------------------------------- 时钟 */ +/* ---------------------------------------------------------------- clock */ const pad = (n) => String(n).padStart(2, '0'); @@ -76,11 +76,11 @@ export const newRunId = () => { + `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${rand4()}`; }; -/* ------------------------------------------------------------ 状态层路径 */ +/* ------------------------------------------------------------ state-layer paths */ /** - * 构造状态层句柄。`stateRoot` 默认 `/state`,`STATE_DIR` 环境变量可覆盖 - * (runner 上锚定到 job 工作目录,本地锚定到仓库根)。 + * 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'); @@ -107,7 +107,7 @@ export async function ensureDirs(s) { ); } -/* --------------------------------------------------------------- 文件 IO */ +/* --------------------------------------------------------------- file IO */ export async function readJson(file) { try { @@ -141,7 +141,7 @@ async function appendJsonl(file, row) { 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')); @@ -155,7 +155,7 @@ export async function listTasks(s) { export async function loadTask(s, id) { const t = await readJson(s.taskFile(id)); - if (!t) throw new Error(`task #${id} 不存在(${s.taskFile(id)})`); + if (!t) throw new Error(`task #${id} not found (${s.taskFile(id)})`); return t; } @@ -164,16 +164,19 @@ export async function saveTask(s, t) { await writeJson(s.taskFile(t.id), t); } -/** 下一本地任务 id:数字文件名最大 +1;空目录从 9000 起(避开 GitHub 编号段)。 */ +/** 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 */ -/** 全托管形态下互斥由 workflow `concurrency` 承担;锁文件退化为记录与防呆。 */ +/** + * 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; @@ -193,14 +196,14 @@ 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 ?? ''}`; /** - * 追加一条评测事件;同 `taskId+event+pr` 已存在则跳过(幂等)。 - * 写入者 = workflow job(agent 不写);sweep 补写带 `sweep-corrected`。 - * 返回 true 表示本次确实写入。 + * 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); @@ -214,7 +217,7 @@ export async function appendAcceptance(s, row) { export async function renderSummary(s) { const tasks = await listTasks(s); const L = []; - L.push(`# Loop 状态摘要 (updated ${nowIso()})`); + L.push(`# Loop state summary (updated ${nowIso()})`); L.push(''); const sec = (title, rows) => { if (!rows.length) return; @@ -225,30 +228,31 @@ export async function renderSummary(s) { const tag = (t) => (t.labels?.length ? ` [${t.labels.join(',')}]` : ''); const when = (t) => (t.updatedAt ?? '').slice(0, 16).replace('T', ' '); - sec('运行中', tasks.filter((t) => t.status === 'processing').map((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('待人工放行 (waiting-merge)', tasks.filter((t) => t.status === 'waiting-merge').map((t) => { + 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('收件箱 (waiting-human / waiting-info)', + 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)', tasks.filter((t) => t.status === 'ready').map((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` = 事件已入库但还没 triage。最容易被人漏看,所以必须出现在摘要里 - // (sweep 刚建档的任务就是这一类)。 - sec('未处理 (new)', tasks.filter((t) => t.status === 'new').map((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)})`; })); @@ -256,34 +260,34 @@ export async function renderSummary(s) { 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('## 终态计数', ''); + L.push('## Terminal counts', ''); L.push(`closed=${c('closed')} accepted=${c('accepted')} rejected=${c('rejected')}`, ''); } - // 最近评测(metrics/acceptance.jsonl:workflow 写,agent 不写) + // Recent acceptance (metrics/acceptance.jsonl: written by the workflow, not agents) const acc = (await readJsonl(s.acceptanceFile)).slice(-5); if (acc.length) { - L.push('## 最近评测', ''); + 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(''); } - // 近期 run 尾部(≤5 条,防超长) + // 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('## 近期 run', ''); + 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'); - // 系统级 run(sweep / retro / release 预检)没有 taskId + // 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}` : ' (未收尾)'}`); + L.push(`- ${rid} ${what} ${st.stage}${en ? ` → ${en.outcome}` : ' (unfinished)'}`); } } L.push(''); @@ -293,7 +297,7 @@ export async function renderSummary(s) { await fs.writeFile(s.summaryFile, summary.trimEnd() + '\n', 'utf8'); } -/* ------------------------------------------------------------ GitHub 动作 */ +/* ------------------------------------------------------------ GitHub actions */ export function ghRepoOf(url) { const m = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/\d+/.exec(url ?? ''); @@ -301,18 +305,18 @@ export function ghRepoOf(url) { } /** - * 由 outcome 推导 GitHub 写动作清单 —— **纯函数,不触网**。 - * - * report 模式只调用本函数并把 `describeActions` 的输出写进报告; - * auto 模式(L2+ / 预演)才交给 `applyActions` 执行。 - * 这是"严格只报告"的唯一开关点:不调 apply 即不写 GitHub。 + * 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 []; // 本地合成任务无 GitHub 目标 - if (outcome === 'retry') return []; // 未实际执行(故障/草稿),不该产生写动作建议 + 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 与 issue 是两个不同的 gh 子命令面:对 PR 编号调 `gh issue edit` 会失败。 + // 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; @@ -325,11 +329,11 @@ export function planActions(t, outcome, { label, comment, note } = {}) { return acts; } -/** 人读描述,供 report 模式的报告与 Step Summary 使用。 */ +/** 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 ? ' (先移除其它五角色标签)' : ''}`; + 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)}`; @@ -338,12 +342,15 @@ export function describeActions(actions) { const firstLine = (s) => String(s ?? '').split('\n')[0].slice(0, 120); -/** 执行写动作(仅 auto / 预演路径调用)。失败只 warn,不回滚本地状态。 */ +/** + * 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'; // PR 走 gh pr,issue 走 gh issue + const sub = a.gh ?? 'issue'; // PRs go to gh pr, issues to gh issue if (a.kind === 'label') { let stale = []; if (a.stripRoles) { @@ -355,24 +362,29 @@ export function applyActions(actions, { log = console.log, warn = console.warn } 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: 打标 ${a.label} 失败: ${(r.stderr || '').trim()}`); + 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} 失败: ${(r.stderr || '').trim()}`); + 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: 评论 #${a.number} 失败: ${(r.stderr || '').trim()}`); + 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 生命周期 */ +/* ---------------------------------------------------------- run lifecycle */ -/** 领取任务并写 start 行,返回 runId。`task` 为 null 表示系统级 run(sweep/retro/release 预检)。 */ +/** + * 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); @@ -383,12 +395,12 @@ export async function beginRun(s, t, stage, { sandbox, trigger = 'manual', model return rid; } -/** report 模式:把待人工执行的写动作追加到该 run 的报告文件。 */ +/** 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 = [ - '', '## 待人工执行的 GitHub 动作(写边界=report)', '', + '', '## Pending GitHub actions (write level=report)', '', ...describeActions(actions).map((d) => `- [ ] ${d}`), '', ].join('\n'); await fs.appendFile(file, block, 'utf8'); @@ -396,32 +408,38 @@ export async function appendActionBlock(s, runId, actions) { } /** - * 收尾一个 run:写 end 行 → 更新任务 → 释放锁 → GitHub 动作(report 只列出,auto 才执行) - * → 刷新 SUMMARY。本地 CLI 与 runner 编排共用,保证幂等与白名单只有一份实现。 + * 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 必填'); + if (!outcome) throw new Error('outcome is required'); const rows = await readRunLines(s, runId); - if (!rows.length) throw new Error(`run ${runId} 不存在(${s.runFile(runId)})`); - if (rows.some((r) => r.event === 'end')) throw new Error(`run ${runId} 已有 end 行,重复收尾被拒绝(幂等)`); + 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]; - // 系统级 run(sweep/retro/release 预检)无任务文件:只记 run 行,不做状态转移, - // 且用另一套 outcome 词表(`closed` 等任务语义不该出现在这里)。 + // 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(`系统级 run(无任务)的 outcome ∈ {${SYSTEM_OUTCOMES.join(', ')}},收到 ${outcome};` - + '任务的 outcome 语义(如 closed)不应出现在系统级 run 里(会污染双口径统计)'); + 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 必填且 ∈ {${Object.keys(OUTCOME_MAP).join(', ')}},收到 ${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} 不允许 outcome=${outcome}(D5;允许: ${allowedOutcomes(start.stage).join(', ')})`); + throw new Error(`stage=${start.stage} disallows outcome=${outcome} (D5; ` + + `allowed: ${allowedOutcomes(start.stage).join(', ')})`); } } const at = nowIso(); @@ -442,7 +460,7 @@ export async function finishRun(s, { } if (pr) { const n = Number(pr); - if (!Number.isInteger(n) || n <= 0) throw new Error('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];