Repository navigation
chore(loop): add Actions + R2 host for stage A1 (report-only) - #36
Conversation
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.
Coverage
|
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.
…comes 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.
The failing
|
| Step | Result |
|---|---|
Set up job / checkout / setup-node / pnpm install --frozen-lockfile |
pass |
| Install engine (pi) | pass — added 132 packages in 6s, pi --version → 0.85.1 |
| Pull state from R2 | fail — secrets absent (correct place to stop) |
| Run stage | skipped — a failed pull must not run the stage |
Push state to R2 (if: always()) |
fail — same cause |
| Upload session transcripts | pass |
Three things now verified by real execution rather than by reading docs:
- pi installs on
ubuntu-24.04in ~6s (~21 MB, 132 packages, no install scripts) — the engine choice holds on Actions, well inside the job budget. - A failed
pullaborts the run instead of letting an empty localstate/reach the push. This was the main data-loss risk, since R2 has no object versioning. if: always()really does push after a crash, so a cancelled or timed-out job still lands its last state instead of losing it.
One finding from that same log: pi --list-models deepseek prints No models available. Use /login to log into a provider or API key — pi's model catalogue is credential-gated, so LOOP_MODEL=deepseek/deepseek-flash cannot be confirmed until DEEPSEEK_API_KEY exists. The value is printed on every run, so the first configured dispatch settles it. That log also showed a missing key would have been classified as an agent failure and pushed the task into the human inbox; credential/config errors now classify as retry (commit 5958e68).
To turn this green
Secrets (Settings → Secrets and variables → Actions): R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, DEEPSEEK_API_KEY (+ LOOP_GH_TOKEN, unused while report-only). Bucket creation, the scoped token, and the one-time seed are in scripts/loop/README.md → "One-time setup".
Until those exist, every push to this branch will show a red loop check. That is this workflow working as specified, not a broken PR.
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 <n>` 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.
`R2_ACCOUNT_ID` is not a credential: R2's S3 endpoint is only ever `https://<ACCOUNT_ID>.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.
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.<ACCOUNT_ID>_<JURISDICTION>_<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-<repo>`: 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.
Design change: one R2 bucket per repository (commit
|
Bucket names are a flat, account-wide namespace, so a name collision is a
real collision. Deriving `loop-state-<repo>` left same-named repositories
under different GitHub organisations or accounts pointed at one bucket.
The name is now `loop-state-<owner>-<repo>` — 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.
Correction: bucket name includes the owner —
|
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.
… smoke 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.
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.
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.
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.
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.
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.
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.
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/<n>-*` 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.
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.
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".
1259f8e to
cd29dad
Compare
Commit prefixes corrected — this branch was force-pushedRaised in review: 14 of the commits here were Why it mattered more than wording. A user installing v1.2.0 would have read "Fixes: log every decision that produces no run" and concluded the package had been fixed. All 16 commits are now New SHAs (the old ones referenced in my earlier comments are gone;
Verified the rewrite changed no content: The rule is now recorded (
|
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 <missing>` 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.
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.
Implements 05 §7 stage A1 (
自动只报告) for tiny-oss: the event-triggered headless host — Actions as scheduler + sandbox, R2 as the state layer.What lands
.github/workflows/loop.ymlconcurrency: {group: loop}) = scheduler + sandbox. Triggers: issues / issue_comment / pull_request / pull_request_review / pull_request_target(closed) / release / schedule / workflow_dispatch.scripts/loop/entry.mjsscripts/loop/shared/state.mjsscripts/loop/shared/route.mjsscripts/loop/shared/agent.mjsscripts/loop/shared/r2.mjsscripts/loop/run.mjs--write-level.Write boundary (owner decision: strict report)
LOOP_WRITE_LEVEL=reportby default. Runs write the state layer + a report; every GitHub write action (labels, comments, closing, opening a PR) is listed as a checklist in the report and in the Actions Step Summary, never executed.workflow_dispatchcarriesexecute_writes=trueso the L2 write path can be rehearsed in a controlled moment before it is left on.Engine
pi(@earendil-works/pi-coding-agent@0.85.1) — three required verifications passed: headless--mode jsonJSONL event stream, DeepSeek built in, ~21 MB with no install scripts.--approveis mandatory or project-local resources are silently ignored. pi has no built-in permission gating, so the job'spermissions:block is read-only and write credentials live only in default-branch contexts.Two findings worth review
pushdeliberately omits--deleteand syncing is overwrite-plus-add only..gitignore:4(lib) matches at any depth, so the shared library underscripts/loop/lib/would never have been committed and the runner checkout would have crashed. Renamed toscripts/loop/shared/; the existing ignore rule is untouched.Verified locally
CLI report boundary performs zero GitHub writes · run closing is idempotent · stage→outcome whitelist enforced · event routing 17/17 · acceptance writes deduplicated by
taskId+event+pr· system-levelsweep/retro-scheduledruns work (task-less) · a stubbed engine exercising prompt →result.json→ state transition → Step Summary with token accounting · provider flake classifies asretryinstead of filling the human inbox.Before this can run
Needs R2 + LLM secrets and a one-time state-layer seed — steps are in
scripts/loop/README.md("One-time setup"). Thepi --list-models deepseekline printed on every run settles the one open question (deepseek/deepseek-flashis unconfirmed against pi's built-in catalogue).A1 acceptance list (rewritten for the report boundary, since the drift criterion is vacuous when nothing is written) is in
scripts/loop/README.md.