[PIR #13] CI concepts for the forge layer: ci-runs, ci-run-view, ci-failures, ci-run-log - #23
Merged
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on ladder, envelope Registers ci-runs, ci-run-view, ci-failures and ci-run-log in KNOWN_CONCEPTS and DISABLES all four for gitlab and linear — a concept with no script falls through to the github default, so leaving them unset would make a GitLab repo silently run `gh run list` (cluesmith#1455's class). Adds executeForgeCommandDetailed: executeForgeCommand collapses a timeout, a non-zero exit, unparseable output and a disabled concept to one `null`, which is how #12 shipped a pr-exists whose null read as 'no PR exists'. The detailed variant keeps stdout even on failure, because the CI concepts print their error envelope there. _timeout.sh lifts #12's watchdog out of gitea/_lib.sh so gh calls get the same guarantee, and reaps the watchdog so it stops printing 'Terminated: 15' onto every caller's stderr. _ci-extract.sh is the extraction ladder. It strips ANSI first — without that, the payload line `ESC[41mESC[1m FAIL ESC[22m…` matches nothing and a log that plainly contains a failure reports none. Ladder: vitest/jest, go test, tsc, the runner's own ##[error] marker, then a line-ANCHORED first error, then an honest refusal. Anchoring is load-bearing: the first line containing 'Error:' in the reference log is '[artifact-canvas] Error: host blew up', printed by a passing test 1214 lines above the real failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contrary to issue #13, `gh run view --log-failed` does NOT narrow to the failing step: on run 32515040122 it returned 2528 lines / 293 KB with every line tagged 'UNKNOWN STEP'. It selects the failing JOB. So these use `gh api repos/{owner}/{repo}/actions/jobs/{id}/logs` instead — one job, no invented step column, and the same shape Forgejo 16 serves, so both providers share one cache and one extractor. The failing step NAME comes from `gh run view --json jobs`, which is structured and reliable. Measured on that run: 293 KB of log becomes a 1.2 KB response carrying the assertion, the test name, the step name and the line range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against Forgejo 15.0.2 (git.pseudoseed.com) and 16.0.0-dev
(codeberg.org), because the issue's premises did not survive contact:
- `tea actions runs view` and `tea actions runs logs` both 404 against 15.0.2
(they call /actions/runs/{id}/jobs and /actions/jobs/{id}/logs, added in
Forgejo 16.0), and `tea actions runs list --output json` returns empty
strings for workflow, branch, started and duration. So these go through
`tea api`, as #12 established.
- `limit` is IGNORED unless `page` is also sent: actions/runs?limit=3 returned
all 6922 runs. Every list call here sends page=.
- `branch=` and `event=` are silently ignored, and a pull_request run records
head_branch as '#3847' — the PR number. So CODEV_BRANCH_NAME is resolved to
its PR ref with #12's base/head lookup before filtering, client-side.
- Run `id` and `index_in_repo` are two id spaces and BOTH resolve on
/actions/runs/{x}, to different real runs. CODEV_CI_RUN_ID is always `id`.
On a server without the log API, ci-failures and ci-run-log return the
unsupported-server envelope naming the version found and the version needed,
and carrying the failing job names they could still determine — never an empty
failures array, which would say 'your CI is fine' when the truth is 'I cannot
see your CI at all'. ci-run-view keeps working there by recovering jobs from
actions/tasks (jobSource: tasks-scan), reporting taskId with a null id because
a task id is not a job id and the log API does not accept one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…logs
51 tests. The two fixtures are verbatim captures, stored gzipped because the
exact bytes are the point: a 2528-line GitHub vitest failure and a 1599-line
Forgejo Go failure. Between them they carry the three traps that make naive
extraction lie, and each one is a test:
- every payload line is ANSI-wrapped, so a matcher that skips cleaning finds
nothing on a log that plainly contains a failure;
- the first line containing 'Error:' is a PASSING test's fixture string,
1214 lines above the real failure;
- 'Test Files' appears four times before the summary that says failed, and
the Forgejo log's last 25 lines are git credential cleanup — so tailing
returns nothing at all.
Also pinned: an old Forgejo returns unsupported-server and never an empty
failures array; a timeout reports as a timeout through both the script envelope
and executeForgeCommandDetailed; ci-run-log refuses zero or two windows BEFORE
spending an API call; and gitea's list calls always send page= (Forgejo ignores
limit without it).
Both SKILL.md twins document the concepts, the envelope and the version floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… actually accept
The shared vocabulary spells it canceled because that is what
`tea actions runs list --help` documents. Measured against the live Forgejo:
status=canceled returns {"message":"unknown status: canceled"} and
status=cancelled returns 2240 runs. GitHub has always wanted cancelled too. So
the translation applies to both providers, not just to GitHub.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…envelope gh can exit 0 and print an auth prompt, an empty body, or an HTML error page. That reached jq, died under `set -e`, and left jq's diagnostic on stderr with NOTHING on stdout — the one shape these concepts promised never to produce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he page ceiling Asking for 200 runs from a 6922-run repository collected exactly 200 and reported truncated=false, because the ceiling check only fired when a client-side branch or workflow filter was active. A capped answer that says it is complete is the failure this issue is about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aches a URL Both ids are interpolated into a gitea URL path and into jq --argjson on both sides. A pasted run URL built a path nobody meant; anything non-numeric killed jq under set -e with nothing on stdout. Now it is a named bad-input envelope that also says what to pass instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
forge.test.ts pins the size of KNOWN_CONCEPTS in two places. Four CI concepts were added, so both move — and the four names are asserted individually, so a future addition cannot satisfy the count while dropping one of these. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s own cost Two tests in this file failed the full-suite run, and a DIFFERENT pair failed the run before it — the signature of a ceiling set below the work, not of a defect. Measured: one measure-prompt-surface.sh invocation costs 25-30s on a quiet machine, and several of these tests invoke it two or three times (two locales, two runs for determinism, a fixture plus the live repo). The 60s inline ceiling sat under the honest cost of the slowest cases, so under load they were killed mid-run and whichever lost the race read as flaky. Raised to 240s via one named constant. The file passes 24/24 in isolation before and after; this is the same coverage, given room. Preferred over .skip, which would have bought a green run by deleting the check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e real resolver Naming a concept script by path BYPASSES resolution: it skips the .codev/config.json lookup, the provider preset, and any per-repo override. A project that overrides ci-failures would have its override silently ignored and would get GitHub's script against its own forge. The reference Forgejo repo carried three such overrides until #12 shipped, so this is not hypothetical — and the test instructions in this PR's own dev-approval summary told people to do exactly that. Deliberately thin: it delegates to executeForgeCommandDetailed, so there is one place where a forge command is actually run. stdout verbatim (envelope included on the failure path, which is the whole reason the ci-* scripts print it there), and the script's own exit code. Two additions, both about a caller being wrong rather than a forge being wrong: exit 2 for an unknown concept name, listing the valid ones; exit 3 for a concept disabled for this provider, NAMED — team-activity on gitea says 'not available for provider "gitea"' rather than printing nothing. Both SKILL.md twins now document it and say plainly not to call a script by path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects the record on gh run view --log-failed, states the three verification tiers separately (GitHub live end to end, Forgejo 15 live end to end, Forgejo 16 HTTP level only), and routes the measured forge facts to arch.md. The hot-tier lesson on truncation is rewritten rather than displaced: the same rule arrived through a wider door this time (an unsupported server, not just a trimmed result), so it now covers every way 'I could not tell' can be spelled the same way as 'no'. The file stays at ten entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… remedy Two of three lanes could not run — codex and gemini, both provider quota. The lane files are gitignored, so the evidence lives here, where it survives the merge: the verbatim agy probe showing 1.1.17 installed and authenticated and failing on 'Individual quota reached', against porch's notice advising a reinstall and sign-in that could not have helped. Porch will print 'All reviewers approved' over two SKIPPED files (#20). The table is the truth, not the summary line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. THE TIMEOUT LAYERING WAS INVERTED. executeForgeCommandDetailed defaults to a 30s ceiling; the scripts default to a 60s CODEV_FORGE_TIMEOUT. At the defaults the outer kill fired first, so a stalled forge arrived as a generic Node kill and the script's NAMED timeout envelope never printed — the whole point of the inner watchdog. The comment in forge.ts asserted the opposite. It survived because the timeout test forced CODEV_FORGE_TIMEOUT=2, and a test that overrides the defaults cannot detect the defaults being wrong. The new test pins the ORDERING at defaults instead. The inversion predates this PR — #12 gave gitea a 60s watchdog under the same 30s ceiling — so the fix here is narrow: codev forge sets its ceiling above the watchdog, and forge.ts documents the real ordering rather than the intended one. 2. sed -n "1,0p" ON AN EMPTY JOB LOG. head/tail built a reversed range, tolerated by BSD sed and REJECTED by GNU sed — invisible on the macOS box this was written on, and on Linux, where CI runs, an abort under set -e with nothing on stdout. An empty log is now an answer, pinned for all three window modes. Plus a misindented exit and a trailing space in gitea/ci-runs.sh. NOT done, deliberately: a conformance test tying the Ci* contracts to script output. Doing it for four contracts would leave the other eighteen untested while implying they were covered; it is filed as its own issue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 tasks
… 67 new) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch CI went red while the local suite was green. The trigger was a test
harness that pointed TMPDIR at a directory it never created: macOS mktemp -d
ignores an unusable TMPDIR and falls back, GNU mktemp honours it and fails, so
31 tests passed here and failed on the Linux runner.
What that exposed matters more than the harness bug. Every concept needs a temp
dir and the watchdog needs one per call, so mktemp -d failed inside the
watchdog, gh was never run, the wrapper returned 1 — indistinguishable from the
wrapped command failing — and the concept answered:
{"ok":false,"error":"not-found","detail":"run 32515040122 could not be read
(gh exit 1); pass the `id` from ci-runs, not the run `number`"}
A temp-directory problem wearing the face of a missing run, with confident
advice about which id to pass. Same rule as this PR's hot-tier lesson, arriving
again in the code written to enforce it.
- forge_timeout returns 125, not 1, when it cannot create a temp dir: the
command never ran and must not share a status with the command failing.
- Every CI concept runs ci_require_tmpdir before touching a forge — named
forge-error, and no forge call at all.
- The check tests [ -d "$TMPDIR" ] explicitly rather than leaning on mktemp, so
macOS and Linux behave identically. mktemp alone would have kept the split.
Diagnosed in an ubuntu:24.04 container rather than by round-tripping CI. Both
paths verified there; the misdiagnosis is pinned by a test green on both
platforms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workspace config now names an 'opencode' consult lane, which the build on this branch predates and rejects — so the branch's own codev CLI could not run, including the forge subcommand this PR adds. Merging main resolves that and keeps the PR current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by running ci-failures against this repository's own red CI run while this PR was open. It returned Error: Refusing to POST /api/tunnel/disconnect to the default Tower port ... from inside a `stderr |` capture block belonging to a test that PASSES — the test asserts that error is thrown, so the text is the suite working as designed — while the real failure, a vitest worker crash, sat 355 lines further down. Same decoy class as the artifact-canvas one, arriving through a different door: this decoy IS anchored at the start of its line, so the anchoring rule that killed the first one does nothing here. Two fixes: - rung 6 skips lines inside vitest stdout|/stderr| capture blocks (header to the blank line that ends the block); - a new rung 4 recognises the Unhandled Errors banner — a worker death, an unhandled rejection, a teardown crash. No test reports as failed, the rungs above find nothing, and the run is still red. That CI log is now a third gzipped fixture. Both original fixtures extract identically, and the review's CI claim is corrected: the branch was red, no test failed, and test.yml's tolerance guard cannot fire because `grep -q "failed" ` matches the guard's own echoed script and ordinary test names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l four lane verdicts The claude lane's third pass returned APPROVE with four documentation-accuracy notes. The one that mattered: this PR claimed gh run view --log-failed ALWAYS tags lines UNKNOWN STEP. Verified, and it does not — on run 32515040122 all 2528 lines are UNKNOWN STEP, on 32536232930 all 1193 are attributed correctly. The attribution is unreliable, not absent. Nothing about the design changes, since attributed or not the output is a whole job or a whole step (293 KB and 108 KB) and never the assertion — but the strong claim was wrong and is now corrected here, in arch.md, and in both script comments that stated it. Also: fixture count 2 -> 3, commit list regenerated, and an explicit reason why the unsupported-server envelope says failingJobs rather than failures (those entries carry no extract; reusing the key would shape an unsupported server like a successful extraction with the details missing). Coverage table now records all four lanes with their real outcomes, and the rotation as it stood when the review ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pseudoseed
added a commit
that referenced
this pull request
Aug 21, 2026
pseudoseed
added a commit
that referenced
this pull request
Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PIR Review: CI concepts for the forge layer
Fixes #13
Summary
Adds four CI concepts to the forge layer —
ci-runs,ci-run-view,ci-failures,ci-run-log— for bothgithubandgitea, tiered so that only the last two ever read log bytes. A builder asking why CI failed now gets the failing job, the failing step and the assertion instead of a log: on the reference run, 293 KB becomes a 1.2 KB response. Also addscodev forge <concept>, because naming a concept script by path bypasses resolution and would silently ignore a repo's own overrides.The record this PR corrects
Issue #13 says
gh run view --log-failed"already returns only failed steps" and instructs the implementer not to re-derive that. It does not, and the instruction is wrong. Measured on run32515040122of this repository:Every one of those 2528 lines is tagged
UNKNOWN STEP.--log-failedselects the failing job and returns all of it;ghmaps log files to steps by name and falls back toUNKNOWN STEPwhen that mapping misses. The architect independently reproduced this on run32448538074: 919 lines, all 919 taggedUNKNOWN STEP— and had read that same output earlier the same day while diagnosing #6 without registering what it meant.One precision, raised by the claude lane and verified: that attribution is unreliable, not always absent. On run
32536232930the same command attributed all 1193 lines to the failing step correctly. It changes nothing about the design — attributed or not, what comes back is a whole job or a whole step, 293 KB and 108 KB respectively, and never the assertion — but "always UNKNOWN STEP" would have been an overstatement, so it is not claimed here or inarch.md.So codev extracts on both providers, and neither uses
--log-failed. Both fetchactions/jobs/{id}/logs— one job, no invented step column, the same shape Forgejo 16 serves — which is also why they share one cache and one extractor. The failing step name comes fromgh run view --json jobs, which is structured and reliable.Anyone reading #13 later should read this section instead of its "Provider notes".
What the two forges actually do
Everything below was measured against live instances on 2026-08-21, not reasoned about.
Forgejo has no Actions job-log API before 16.0 (released 2026-07-16).
git.pseudoseed.comreports15.0.2+gitea-1.22.0, and theretea actions runs viewandtea actions runs logsboth 404 — they call/actions/runs/{id}/jobsand/actions/jobs/{id}/logs, neither of which exists. Every alternative route was probed. The web UI's own log route exists but is session-only: it rejectsAuthorization: tokenand HTTP basic auth alike, while the API accepts the same token (verified as a control). There is no token-reachable log on 15.x by any path.tea actions runs list --output jsonis also lossy where it does work —workflow,branch,startedanddurationall come back as empty strings — so these scripts go throughtea api, as #12 established.Four query-parameter facts, all footguns:
limitis ignored unlesspageis also sentactions/runs?limit=3returned all 6922 runsstatus=filters server-sidebranch=andevent=are silently ignoredstatus=canceledis rejected{"message":"unknown status: canceled"};cancelledreturns 2240 runs — the opposite of whattea's own--helpdocumentsAnd two shape facts:
pull_requestrun recordshead_branchas#3847— the PR number, not a branch. In the first 100 tasks on the reference repo:#3869×32,#3865×10,main×7,v1.0.230×1. SoCODEV_BRANCH_NAME=builder/xmatches nothing on a repo that runs CI on pull requests unless the branch is resolved to its PR first, whichci-runsdoes with Forgejo/Gitea forge parity: implement pr-search and pr-diff, fix the pr-exists hang #12's base/head lookup.idandindex_in_repoare two id spaces, and both resolve on/actions/runs/{x}to different real runs. The web URL shows the second.ci-runsemits both, the log concepts takeidonly, and a non-numeric value is refused rather than guessed.On Forgejo 16 (verified on codeberg.org,
16.0.0-dev-694):actions/runs/{id}/jobsreturns jobs carrying bothidandtask_id, and the log endpoint accepts the jobid, not thetask_id— passing the task id returns{"message":"resource does not exist"}, which is exactly the 404 that reads as "no logs" if it is not distinguished.actions/jobs/{id}/logsserved 142 KB / 1599 lines in 1.02 s astext/plainwithaccept-ranges: bytes;?step=Nis accepted and ignored.Design
Tiering
ci-runsci-runs,ci-run-viewci-failuresci-runs+CODEV_CI_WORKFLOWci-run-logci-run-logis a separate concept rather than a flag, per the issue's second comment: a window parameter on the main call gets passed by habit, and then every status question drags a log again.The extraction ladder, and the three traps it was built against
All three are from the real captured logs, and each is a test:
ESC[41mESC[1m FAIL ESC[22mESC[49m src/…, so a matcher that does not clean first matches nothing and reports "no recognized failure" on a log that plainly contains one. Cleaning is not cosmetic.[artifact-canvas] Error: host blew up— a fixture string printed by a passing test. The real failure is at 2471. The ladder's generic rung therefore anchors patterns at the start of the line; that decoy'sError:is mid-line and cannot match, and anchoring holds even in logs with no test summary to measure against.Test Filesappears four times before the failing summary, three of them sayingpassedand one a shell line echoinggrep -q "Test Files.*passed". Any rule taking the first match reports a passing suite as the failure.Rungs, in order, with the one that fired named in
matchedBy:vitest/jest →go-test→tsc→ the runner's##[error]marker → line-anchoredfirst-error→ refusal. Runner recognition sits above the##[error]marker deliberately (and as issue #13's own priority order asks): the marker returns one sentence, the vitest rung returns the whole Failed Tests block — test name, assertion, expected/received, file:line.Deviation from the approved plan, stated plainly. The plan said the generic rung would fire only after a passing-suite boundary and otherwise fall through to refusal. Implemented, it fires with anchoring always and the boundary as a preference. Anchoring is what actually kills the observed false positive; refusing whenever no test summary exists would have returned
extracted: falsefor the whole class of install/setup/compile failures and bought no safety.Refusal is a handoff
{ "extracted": false, "reason": "no recognized failure pattern", "failures": [{ "jobId": 11952749, "jobName": "test-unit", "logLines": 1599 }], "next": "ci-run-log CODEV_CI_RUN_ID=6554924 CODEV_CI_JOB_ID=11952749 CODEV_CI_LOG_TAIL=80" }No log lines at all. A builder handed 50 arbitrary lines treats them as the diagnosis and reasons from noise; one told extraction failed reads the log with the call the response already handed it.
Errors are values
Every ci-* concept prints one JSON object on stdout on success and failure, so
timeout/not-found/unsupported-server/forge-error/bad-inputstay distinguishable afterexecuteForgeCommandhas flattened everything else tonull.executeForgeCommandDetailedis added for callers that need the distinction in TypeScript: it returns{ok, data, stdout, stderr, exitCode, timedOut, unavailable, durationMs}and keeps stdout on the failure path.On a Forgejo below 16 the response is
unsupported-server, naming the version found and the version needed and still listing the failing job names it could determine — never an emptyfailuresarray. It calls that listfailingJobs, notfailures, and the difference is deliberate: afailuresentry carries an extract (matchedBy,text,from/to,returnedLines), and these have none — only a name and an id. Reusing the key would make an unsupported server shaped like a successful extraction with the details missing, which is a smaller version of the same lie the envelope exists to prevent. (Raised as an inconsistency by the claude lane; kept, with the reason stated here.) "Your CI is fine" and "I cannot see your CI at all" are opposite facts and must not be the same observation.codev forge <concept>Added at the architect's direction at the dev-approval gate, and the reasoning is correctness rather than convenience: calling
packages/codev/scripts/forge/github/ci-failures.shby path bypasses resolution — the config lookup, the provider preset, and any per-repo override — so a repo that overrides a concept gets the github default against its own forge. The reference Forgejo repo carried three such overrides until #12 shipped. It delegates toexecuteForgeCommandDetailed, prints stdout verbatim and exits with the script's code; its own additions are exit 2 for an unknown concept (listing the valid ones) and exit 3 for a concept disabled for the provider, named.Files Changed
packages/codev/scripts/forge/_ci-extract.sh(+202 / -0) — the extraction ladderpackages/codev/scripts/forge/_ci-lib.sh(+399 / -0) — envelope, caps, cache, windows, id validationpackages/codev/scripts/forge/_timeout.sh(+100 / -0) — Forgejo/Gitea forge parity: implement pr-search and pr-diff, fix the pr-exists hang #12's watchdog, now shared by both providerspackages/codev/scripts/forge/gitea/_lib.sh(+8 / -79) — sources the shared watchdogpackages/codev/scripts/forge/gitea/_ci.sh(+229 / -0)packages/codev/scripts/forge/gitea/{ci-runs,ci-run-view,ci-failures,ci-run-log}.sh(+468 / -0)packages/codev/scripts/forge/github/_lib.sh(+55 / -0)packages/codev/scripts/forge/github/{ci-runs,ci-run-view,ci-failures,ci-run-log}.sh(+407 / -0)packages/codev/src/lib/forge.ts(+119 / -11) — registration, gitlab/linear disabled,executeForgeCommandDetailedpackages/codev/src/lib/forge-contracts.ts(+181 / -0)packages/codev/src/commands/forge.ts(+97 / -0) —codev forge <concept>packages/codev/src/cli.ts(+19 / -0)packages/codev/src/__tests__/pir-13-ci-concepts.test.ts(+976 / -0)packages/codev/src/__tests__/fixtures/pir-13/{github-vitest-failure,forgejo-go-failure,github-vitest-worker-crash}.log.gz(3 files, 98 KB) — the third is this branch's own red CI run, which produced the capture-block decoypackages/codev/src/__tests__/forge.test.ts(+10 / -2) — concept count 18 → 22packages/codev/src/__tests__/spec-1280-measurement-instrument.test.ts(+18 / -25) — timeout ceiling, see Flaky Tests.claude/skills/forge/SKILL.md,.codex/skills/forge/SKILL.md(+133 / -0 each, byte-identical twins)codev/resources/arch.md,codev/resources/lessons-critical.md,codev/resources/lessons-learned.mdcodev/plans/13-ci-forge-concepts.md,codev/reviews/13-ci-forge-concepts.md,codev/state/pir-13_thread.mdCommits
700aefc63feat(forge): CI concept plumbing — shared timeout, extraction ladder, enveloped4605f6b2feat(forge): the four CI concepts for GitHub1fd18d491feat(forge): the four CI concepts for Gitea/Forgejo346243ef5test(forge): pin the CI concepts against two real captured logs5cd31ef16fix(forge): send status=cancelled, the spelling both forges actually accept3e552014efix(forge): a CLI that exits 0 with non-JSON still gets an envelope494a17352fix(forge): gitea ci-runs reports truncation when it hits the page ceiling8e26a661afix(forge): reject a non-numeric run or job id before it reaches a URL067f7b179test(forge): the concept count is 22, not 189893db9cdtest: give the prompt-surface instrument a ceiling above its own cost3334bf9e8feat(cli): codev forge — run a concept through the real resolver5f57f107eReview + retrospective79fb7b664docs: record the review-lane coverage gap and porch's wrong remedy8a57b262cfix: the two defects the claude review lane found9a5a19bacdocs: test counts after the review-lane fixes (5572 passed, 67 new)5a61226d6fix: an unusable TMPDIR must not be reported as a missing runb1f8c7fcefix(forge): the extractor pointed at a passing testTest Results
npm run build: ✓ passnpm test: ✓ pass — 5622 passed, 0 failed, 48 skipped (5670), after mergingorigin/main. 70 new tests inpir-13-ci-concepts.test.ts, plus the two concept-count assertions updated inforge.test.ts.Branch CI: red, and NOT because of a failing test
Read this before reading
npm test: ✓ passabove. That line is the local suite; the branch's own CI is a separate claim, and for several commits it was red while this file said nothing about it. The claude review lane caught that.CI is green at HEAD (
b1f8c7fc:Tests✓,CLI Integration Tests✓), which confirms the diagnosis below — the red was an intermittent worker-teardown crash, not a failing test. The guard defect that turned that flake into a hard failure is still there for whoever hits it next.The last red run (
32536232930) reports:No test failed. A vitest worker fork died during teardown, so one file went unreported.
.github/workflows/test.ymlalready knows about this and tries to tolerate it — its own comment says "Vitest forks pool has a known issue where the worker process crashes during cleanup after all tests pass" — with:That guard cannot fire.
grep -q "failed"runs over the whole captured output, and "failed" appears in it six times on this run — none of them a failing test:spec-1470test names and stdout —reentry-failed,clear-failed, "reports a failed Tower send"git fetch … failedwarnings printed by consult testsSo any worker crash in this repository is a hard CI failure regardless of the test results, and the tolerance the workflow author wrote has never been reachable — which is why a flake that later cleared on a re-run cost this PR two red runs and a diagnosis. That is a defect in
test.yml, not in this diff, and it is not fixed here — fixing another team's CI gate to turn an unrelated red green is the scope creep the review phase warns against. It is reported to the architect with this evidence.What this PR did cause, and has fixed, is the earlier red: the
TMPDIRharness bug above, which failed 31 tests on Linux while passing locally.Verification coverage — three tiers, and they are not the same
Do not read the third as if it were the first two.
1. GitHub — live, end to end through the real dispatcher (config load → preset → env → script → JSON parse), against
pseudoseed/codev:ci-runs(limit 3)truncated: trueci-runs --status failureci-runs --branch builder/pir-12ci-run-viewrun 32515040122ci-failures(cold)vitest, 23 of 2528 lines, 1.2 KB responseci-failures(cached)ci-run-logtail 10ci-run-loghead 5ci-run-loggrep AssertionErrorci-run-logwith no windowbad-input— refused before spending an API callAlso verified against a second real run (
32448538074, the architect's): 919 lines → 20,matchedBy: vitest.2. Forgejo 15.0.2 — live, end to end through the real dispatcher, against
~/dev/entriqon the baregiteapreset with no overrides:ci-runs(limit 3)ci-runs --status failureci-runs --branch builder/air-364#3855ci-run-viewrun 11130tasks-scan, 1 failingci-failuresrun 11130unsupported-server, both versions named, failing job still listedci-run-logrun 11130unsupported-servercodev doctorin that repo resolves all four concepts totea. entriq was read-only — nothing was written to it. Its working tree carries an unrelated uncommitted.codev/config.jsonedit that predates this session (13:34 MDT, deleting the three overrides #12 made redundant).3. Forgejo 16 — HTTP level only. NOT driven through the dispatcher against a live v16 server. The v16 routes were verified with unauthenticated
curlagainstcodeberg.org/forgejo/forgejo(jobs list, job log 200/text/plain/142 KB/1.02 s, run-log zip), and the code path is covered by unit tests driving a stubbedteathat serves the real captured codeberg job log. Driving it through the dispatcher needsteato hold a Codeberg login, which the architect deferred.git.pseudoseed.comwas still on 15.0.2 at the time of writing; the owner has filed the Forgejo 16 upgrade, and if it lands the same code lights up with no change.4. Provider degradation:
resolveAllConceptsreports all four concepts asdisabledforgitlabandlinear— not merely absent, which would fall through to the github default and runghagainst whatever remote it resolved.⚠ Review lane coverage — read this before trusting the review depth
The rotation this review ran under was
["gemini", "codex", "claude", "opencode"], the four-lane list in effect at the time (2026-08-21, ~16:50–17:30 MDT). The owner removedgeminifrom the rotation shortly afterwards —["codex", "claude", "opencode"]— so a later reader will see three names where this table has four. The table records what actually ran, not the current config.The first pass had only ONE lane available and the PR was held rather than merged on it; the
opencodelane (PR #24) was merged and installed mid-flight, which is what made a second live reviewer possible.claude-opus-5)sed -n "1,0p"portability bug; second pass, on the corrected code, found the extractor pointing at a passing test and this file claiming green CI while the branch was red. Third pass, after those fixes: APPROVE, "no blocking issues", shellcheck and tsc clean, review claims verified independently.xai/grok-4.6)Every finding from every lane was reproduced before being acted on, and none was argued down. The four that changed the code are described in What the lanes found below.
Porch's own gate summary will say otherwise, and it is wrong. Both lane files carry
VERDICT: SKIPPED, whichparseVerdict(porch/verdict.ts) does not recognise — it knows onlyAPPROVE,REQUEST_CHANGES,COMMENT— so it falls through to the "treat as COMMENT" default, andallApprovecountsCOMMENTas approval. Porch will print "All reviewers approved!" over two reviewers that read nothing. That is #20, filed by PIR #12; it is porch behaviour, not anything in this diff, and it is not fixed here. Read this table, not the summary line.The lane files themselves are gitignored (
.gitignore:65,codev/projects/*/*.txt), which is why the evidence is restated here, where it survives the merge.Porch reported a remedy that cannot work
The gemini skip notice read:
agy is installed and authenticated. Probed directly, verbatim:
Reinstalling and signing in again would have changed nothing and cost whoever followed the advice their afternoon. A confidently printed remedy that cannot work is the same defect class as #21, where the stuck-mailbox alert names a command that cannot clear a composer. The architect is filing it separately.
What the lanes found
First pass — claude, COMMENT/HIGH. Two real defects, both in precisely the class the absent lanes exist to catch.
1. The timeout layering was inverted, and my own test hid it.
executeForgeCommandDetaileddefaults to a 30s ceiling; the scripts default to a 60sCODEV_FORGE_TIMEOUT. At the defaults the outer kill fires first, so a stalled forge arrived as a generic Node kill and the script's named timeout envelope — the entire point of the inner watchdog, and the thing #17 and #8 are about — never printed. The comment inforge.tsasserted the opposite ordering.The reason it survived to review is worth more than the fix: the timeout test forced
CODEV_FORGE_TIMEOUT=2, and a test that overrides the defaults cannot detect the defaults being wrong. It proved the watchdog works when you tell it to; it could not prove that the watchdog is the ceiling that fires. The correction is a test that pins the ordering at defaults — it lets the real 60s-vs-30s relationship decide, and fails if the outer ceiling ever eats the inner one again.The inversion predates this PR. #12 gave the gitea scripts a 60s watchdog under this same 30s ceiling; it was inherited here, not introduced. Correcting it globally would change the timeout behaviour of every concept and every caller, so what this PR does is narrower:
codev forgesets its ceiling to the script watchdog plus 30s, andforge.tsnow documents the real ordering instead of the intended one. The general case is left to a caller passingtimeoutMs.2.
sed -n "1,0p"on an empty job log. head/tail built a reversed range whenever a log came back empty. BSD sed tolerates it and GNU sed rejects it — so this was invisible on the macOS box it was written on, and on Linux, which is where CI runs, the script would have aborted underset -ewith nothing at all on stdout: the one shape these concepts promised never to produce. It is the textbook case for why the absent lanes matter, found by the lane that ran on the platform where it cannot bite. An empty log is now an answer (logLines: 0, empty window,truncated: false), pinned for all three window modes plus an assertion against the reversed range itself.A third finding was cosmetic (a misindented
exitand a trailing space ingitea/ci-runs.sh), fixed.Second pass — claude, REQUEST_CHANGES/HIGH, on the code after those fixes. Three findings, all correct:
ci-failurespointed at a passing test — the capture-block decoy, above. The best finding of the review, because it was found by running this PR's tool against this PR's own failing CI.codevpredates this PR and has noforgesubcommand; the worktree build predated theopencodelane and rejected the workspace config.mainis now merged (fixing the second) and the instructions build the branch and invoke its own CLI (fixing the first).Third pass — claude, APPROVE/HIGH. No blocking issues; four minor notes, all documentation accuracy, all applied: the fixture count (2 → 3), the commit list, the
--log-failedoverstatement corrected above and inarch.md, and the reasonfailingJobsis deliberately not spelledfailures.A further note was noted rather than requested: the
Ci*contracts inforge-contracts.tsare documentation-only, with no conformance test tying them to actual script output. Deliberately not done here. Adding it for the four CI contracts alone would leave the other eighteen forge contracts untested while implying they were covered — worse than uniformly untested. The architect is filing it as its own issue across all forge contracts.What CI on this PR found that the local suite could not
The branch's own CI went red while
npm testwas green locally, and the cause is the same platform split the review lane had just warned about — this time with a wrong answer at the end of it rather than a crash.The trigger was a test-harness bug: the harness pointed
TMPDIRat a directory it never created. macOSmktemp -dignores an unusableTMPDIRand falls back to the system temp dir; GNUmktemphonours it strictly and fails. So 31 tests passed on the Mac and failed on the Linux runner.What the failure exposed is the part worth reading. Every CI concept needs a temp dir, and the watchdog in
_timeout.shneeds one for every single call. WithTMPDIRunusable,mktemp -dfailed inside the watchdog,ghwas never run, and the wrapper returned 1 — indistinguishable from the wrapped command failing. The concept then answered:{"ok":false,"error":"not-found","detail":"run 32515040122 could not be read (gh exit 1); pass the `id` from ci-runs, not the run `number`"}A temp-directory problem wearing the face of a missing run, complete with confident advice about which id to pass. That is the same rule this PR's hot-tier lesson is about, arriving one more time: "I could not tell" must never be spelled the same way as "no."
Three fixes, and the diagnosis was made in an
ubuntu:24.04container rather than by round-tripping CI:forge_timeoutreturns 125 when it cannot create a temp dir, not 1 — the command never ran, so it must not share a status with the command failing.ci_require_tmpdirbefore it touches a forge, which fails by name withforge-errorand makes no forge call at all.[ -d "$TMPDIR" ]explicitly rather than leaning onmktemp, so macOS and Linux behave identically. Relying onmktempalone would have preserved the split — and the log cache, which reads${TMPDIR:-/tmp}directly, silently does nothing on macOS in that state anyway.The harness bug is fixed, and the misdiagnosis is pinned by a test that passes on both platforms.
Why this PR is held rather than merged on one lane
One of three would be the thinnest coverage of the day, on the diff least suited to it: five POSIX
shscripts, a hand-rolled awk extractor and a pile of jq, where the two absent lanes are the ones that most often catch quoting and portability defects — and where two of the three bugs found during implementation were exactly that class. PR #24 (issue #22) adds an opencode consult lane on an account unrelated to either exhausted quota; the architect is merging it and re-running this review phase with that lane available, rather than waiting for the Aug 27/28 quota resets or merging thin. This section is rewritten with the real verdicts once that re-run completes.Architecture Updates
COLD —
codev/resources/arch.md, § Integration Points → Forge Concept Commands. Concept count 18 → 22, plus four additions, all current-state reference rather than changelog:codev forgeexists to close.executeForgeCommandDetailedand when thenull-flattening ofexecuteForgeCommandis not good enough.logLines/returnedLines/truncatedon anything carrying log text, refusal carrying no log lines, and where the shared implementation lives.--log-failedcorrection, the Forgejo 16 log-API floor, the four query-parameter footguns, the#<pr>branch labelling, the two id spaces, thecancelledspelling, and the per-endpoint costs.Nothing promoted to
arch-critical.md. It is at its cap of ten, and all of this matters only when writing or calling a forge concept — the SKILL.md and the cold map carry it to whoever needs it.Lessons Learned Updates
HOT —
codev/resources/lessons-critical.md, one entry rewritten in place rather than a displacement, because the new instance is the same lesson arriving through a wider door. The existing entry covered truncation only:is now
The architect counted this as the seventh arrival of the same rule in one day. This PR met it three times: an old Forgejo that would have looked like a green run, a page ceiling that reported
truncated: false, and a CLI exiting 0 with non-JSON that produced no stdout at all. The file stays at ten entries.COLD —
codev/resources/lessons-learned.md, seven entries across Process and Testing:--log-failed).--helpis not the API's vocabulary (canceledvscancelled).set -e, a helper returning non-zero decides its caller's exit status.$( ).Things to Look At During PR Review
_ci-extract.sh, the awk program. It is the piece most likely to be subtly wrong on a runner nobody here uses. The ladder is ordered and each rung names itself inmatchedBy, so a wrong answer is at least attributable — but a new runner format falls tofirst-erroror to refusal, and the refusal path is the safe one by design.ci_clean_log's sed pipeline. ANSI/OSC stripping in POSIXsedwith a literal ESC. It is line-count preserving, which is what makesfrom/tousable as line numbers into the raw log; a change that drops or adds a line silently breaks theci-run-loghandoff.tasks-scanfallback (gitea/_ci.sh). It pagesactions/tasksfiltering onrun_number, stops the moment it walks past the run, and reports truncation only when it ran out of allowance before reaching it. The early stop is what keeps a recent run at one page; the ceiling is a separate, higher knob (CODEV_CI_TASKS_MAX_PAGES, default 20) because a page of 50 tasks spans only ~6 runs.executeForgeCommandDetailed'stimedOutis derived fromerr.killed && err.signal, not from an exit code — a killed process can still exit with a status, which is the exact confusion Forgejo/Gitea forge parity: implement pr-search and pr-diff, fix the pr-exists hang #12 documented ingitea_timeout.How to Test Locally
pir-13→ Review Diffcodevpredates thisPR and has no
forgesubcommand (error: unknown command 'forge'):node packages/codev/dist/cli.jsforcodevin every command below. After thismerges and you reinstall globally, plain
codev forge …works.~/dev/entriq):codev doctorin both repos: four new concepts,ghunder github,teaunder gitea.Flaky Tests
None skipped. One pre-existing flake was fixed rather than skipped:
packages/codev/src/__tests__/spec-1280-measurement-instrument.test.tscapped every test at 60 s inline, whilescripts/measure-prompt-surface.shcosts 25–30 s per invocation and several of those tests invoke it two or three times (two locales, two runs for determinism, a fixture plus the live repo). Under full-suite load a different pair failed each run — the signature of a ceiling set below the work, not of a defect. The file passes 24/24 in isolation before and after. Raised to 240 s via one named constant (INSTRUMENT_TIMEOUT_MS), which is the same coverage given room;.skipwould have bought a green run by deleting the check.