Forgejo: CI monitoring on the agent's own pull requests (plan step 20) #109
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#109
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The agent should watch CI on the pull requests it opened itself.
Today:
CI_BULLETinapps/server/src/bridge/session-policy.tstells it to callforgejo_wait_commit_status).handleCommitStatus(apps/server/src/forgejo/dispatcher.ts) skips pull requests authored by the agent.handleLifecyclearchives the agent's PR session as soon as the PR is merged (comment "Until post-merge CI exists…").parseCiWatchdog(apps/server/src/forgejo/repo-config.ts) ignoresci_watchdog.own_prs.Before merge: some CI failures happen outside the in-turn wait: the wait timed out, the turn ended before CI finished, a check was re-run and failed, or someone else pushed to the branch. Each of these resumes the PR's mapped implementer session with a CI-failure seed. Each CI outcome is handled once, using the same dedupe as the others' PR watchdog. A trigger whose head has moved since CI finished is dropped, so a failure the running turn has already fixed and pushed is not fixed a second time.
After merge: the agent's own PR session is not archived at merge. Instead a durable record "waiting for post-merge CI on the merge commit" is stored, and it survives restarts. Status events for that SHA are matched against the record.
Config:
ci_watchdog.own_prs.enabled(boolean, defaulttrue). When it isfalse, own-PR CI is not monitored and merged own PRs are archived at merge, as they are today.Plan
Context
Implement own-PR CI monitoring in the Forgejo dispatcher (
apps/server):ci_watchdog.own_prs.enabledconfig key.Approach
Config:
ci_watchdog.own_prs.enabled. Editapps/server/src/forgejo/repo-config.ts.ciWatchdogOwnPrs: booleantoRepoConfig, with/** Monitoring of CI on PRs authored by the agent; default true. */.DEFAULT_REPO_CONFIGgetsciWatchdogOwnPrs: true.parseCiWatchdogto return{ others: PullFilters | null; own: boolean }. Missing or nullsectiongives{ others: null, own: true }.section.own_prs:undefined/nullgivesown = true.`ci_watchdog.own_prs` must be a mapping.enabledmust beundefinedor a boolean, otherwise the error is`ci_watchdog.own_prs.enabled` must be a boolean.own = enabled ?? true.own_prsare ignored.ci_watchdog.others_prsis read…") to say thatown_prs.enabledis read too.parseRepoConfig.repo-config.test.ts:own_prs: {x: 1}. That stays valid and yieldsciWatchdogOwnPrs: true.own_prs: {enabled: false}givesfalse.own_prs: 1andown_prs: {enabled: 1}give the errors above.ciWatchdogOwnPrs: trueto every expected config literal in that file (lines ~20, 32, 40, 93).Persistence: post-merge records.
apps/server/src/db/migrations/019-forgejo-post-merge.sql:apps/server/src/db/forgejo-post-merge.ts. Follow the style ofdb/forgejo-sessions.ts:getDb(), prepared statements,nowIso(), repo lowercased. Exports:interface PostMergeRecord { repo: string; number: number; sessionId: string; mergeSha: string; baseRef: string; state: "waiting" | "failed"; mergedAt: string }.repois the storedrepo_key.insertPostMerge(r: { repo: string; number: number; sessionId: string; mergeSha: string; baseRef: string }): void:INSERT OR REPLACEwith statewaiting.listWaitingPostMergeBySha(repo: string, sha: string): PostMergeRecord[]listWaitingPostMerge(): PostMergeRecord[]settlePostMerge(repo: string, number: number, to: "done" | "failed"): boolean. This is an atomic claim, and only the caller that getstrueacts.donerunsDELETE … WHERE repo_key=? AND number=? AND state='waiting'.failedrunsUPDATE … SET state='failed', updated_at=? WHERE … AND state='waiting'.changes === 1.getPostMerge(repo: string, number: number): PostMergeRecord | nullapps/server/src/db/forgejo-post-merge.test.ts. Model it onforgejo-sessions.test.ts, withbootDb(). Cover these cases:settlePostMergereturnsfalse.failedrow is not returned by eitherlistWaiting*function.getPostMergestill returns afailedrow.API: the merge commit SHA. In
apps/server/src/forgejo/dispatch-api.ts:merge_commit_sha?: string | nulltoRawPull./** Merge commit; "" when not merged or omitted. */ mergeCommitSha: stringtoPullSummary.readPull.pullfixtures indispatcher.test.tsthat buildPullSummaryliterals, addingmergeCommitSha: "".conversation-detect.ts, extend thepull_requesttype ofPullSyncPayloadLikewithmerge_commit_sha?: string | null.Before merge: fan-out to own PRs. Restructure
handleCommitStatusindispatcher.ts. Steps 4 and 5 share this method.ciWatchdogOthersPrs === null → droppedreturn. Instead set:others = parsed.config.ciWatchdogOthersPrs !== nullown = parsed.config.ciWatchdogOwnPrsno status checks/pending/all checks passeddrops exactly as today, after the post-merge branch from step 5.outcomeas today.listOpenPulls.othersis true.ownis true) are pulls whereheadSha === sha,authoris the agent, andgetForgejoSession(row.repo, p.number) !== null.ci_failed:action: "own_ci_failed",event: "status"sha256(JSON.stringify(["own_ci_failed", repoLower, p.number, sha, outcome]))queued ${n} CI analysis(es) and ${m} own CI fix(es) for ${sha7}no open pull request has head ${sha7}whenn + m === 0CI watchdog not configuredasdroppedwhen!others && !ownAfter merge: matching a status event to a record. In
handleCommitStatus, right after the checks are read:records = listWaitingPostMergeBySha(row.repo, sha).ownis enabled now: a record exists only because own monitoring was enabled at merge.resolvePostMerge(record, checks, row), which returns a finish-reason fragment:post-merge #N: ${k} check(s) pending, no state change.settlePostMerge(…, "done"), callarchiveAfterIdle(record.sessionId). This is a new private helper extracted from the two lines at the end ofhandleLifecycle:waitUntilIdlewith the implementer turn timeout, thenarchiveSession.handleLifecycleis changed to call it too.post-merge #N green: archived.settlePostMerge(…, "failed"), callinsertForgejoTriggerwith:action: "post_merge_ci_failed",event: "status",number: record.number,isPull: true,commentAuthor: row.commentAuthorsha256(JSON.stringify(["post_merge_ci_failed", repoLower, record.number, sha])){ sender: { login: <commentAuthor> }, pull_request: { number, title, user: { login: agent }, head: { ref: "", sha }, base: { ref: record.baseRef } }, ci: { sha, checks } }. Taketitlefromapi.readIssue(owner, repo, number), or"?"if that fails. Leave outbodyandhtml_url: the seed tells the agent to read the PR withforgejo_read_prto get theCloses:line. Put this payload construction plus the insert in a private helperqueuePostMergeFix(record, sha, checks, commentAuthor), which the sweep in step 7 reuses.post-merge #N red: queued fix.;, then continues with the pre-merge outcome.this.pump()if anything was queued.After merge: storing the record instead of archiving. In
handleLifecycle, inside therow.isPullbranch, whenpayload.pull_request?.merged === true:trigger.kind === "forgejo_implementer"payload.pull_request.user.loginequalsthis.deps.agentUsername()(case-insensitive)this.deps.api()is non-nullapi.readRepoFile+parseRepoConfig. On a read or parse failure, log a warning and treatciWatchdogOwnPrsastrue, its default. If it isfalse, archive as today.payload.pull_request.merge_commit_sha.api.readPull(...).data.mergeCommitSha.archived: merged (no merge commit sha).insertPostMerge({ repo: row.repo, number: row.number, sessionId: mapped, mergeSha, baseRef: payload.pull_request.base?.ref ?? "" })finishForgejoTrigger(row.id, "done", \waiting for post-merge CI on ${sha7}`)`this.wake(). Not needed for correctness; the sweep handles it.Sweep: no CI, and timeout. In
ForgejoDispatcher:private sweepTimer: ReturnType<typeof setInterval> | null.start(), afterpump(), runthis.track(this.sweepPostMerge())and start a 60 000 mssetIntervalthat does the same.dispose()clears the timer.POST_MERGE_NO_CI_GRACE_MS = 60_000andPOST_MERGE_TIMEOUT_MS = 24 * 60 * 60_000. Make them overridable through the new optional depspostMergeGraceMs?andpostMergeTimeoutMs?onForgejoDispatcherDeps, the same wayturnTimeoutMsis (tests need this).sweepPostMerge(): for eachlistWaitingPostMerge()record withage = now - merged_at:age < grace, skip.mergeSha. On an API error, log a warning and skip.commentAuthorset to the agent username, andpump().settlePostMerge(…, "done"), then archive.age >= timeout:settlePostMerge(…, "done"), archive, and callcaptureInbox(the same APIfallbackuses; reuse its call shape) with:kind: "ticket",source: INBOX_SOURCEtitle: \Post-merge CI on ${repo}#${n} never finished``body: the merge SHA, the pending contexts, and the session id.classifyChecks(checks): "pending" | "green" | "red" | "none", usingTERMINAL_CHECK_STATES/FAILED_CHECK_STATES. Step 5 mapsnoneto pending; the sweep maps it to archive.Gate and seeds for the two new actions. In
dispatcher.ts:"own-ci-failed" | "post-merge-ci-failed"toReadyReasonand update its doc comment.gate(), addownCi = row.action === "own_ci_failed"andpostMerge = row.action === "post_merge_ci_failed". Before thereviewSubmittedbranch:ownCi: call the new privateresolveOwnCi(job, payload): Promise<boolean>, modelled onresolveCiWatchdog:ciWatchdogOwnPrs === false→dropped "CI monitoring of own pull requests disabled".readPull, with failure handled as inresolveCiWatchdog.headSha !== payload.ci.sha→dropped "head moved since CI finished". This is the guard against fixing twice.dropped "pull request not authored by the agent".getForgejoSession === null→dropped "no session is mapped to this pull request".reason = "own-ci-failed".postMerge: ifgetPostMerge(row.repo, row.number)?.state !== "failed"→dropped "no failed post-merge record". Otherwisereason = "post-merge-ci-failed".ownCi || postMergeto theauthorsource list (payload.pull_request?.user?.login) and to the permission-check exclusion (!synchronized && !basePushed && !autoReview && !ciFailed && !ownCi && !postMerge).agentPrblock, skipreadPullforpostMerge: the PR is merged and its head branch may be gone. SetprHead = "merged"forpostMergesoreuseMappedstill requires an implementer session.resolveSession, extend the "only continues" rule frompull-synchronizedtoown-ci-failedandpost-merge-ci-failed. Both throwthe mapped session is unusable: …instead of creating a session.runReady, add two seed branches calling new builders inconversation-detect.ts:buildOwnCiFailureSeed({ repo, number, payload: CiFailurePayloadLike }): stringCI failed on your pull request ${repo} #${number}, then the branch and SHA line, then the check list. Share the check-line formatting withbuildCiFailureSeedby extractingformatCheckLines(payload).buildPostMergeCiFailureSeed({ repo, number, payload: CiFailurePayloadLike }): stringCI failed on the merge commit ${sha} of your merged pull request ${repo} #${number} (base ${base}), then the check list.agent/fix-ci-${number}), fix the cause, commit, push, and open one follow-up pull request with forgejo_create_pr whose base is ${base}, and whose body saysFollow-up to #${number}plus the originalCloses: #Nline if the merged pull request had one. Then comment on #${number} linking the follow-up pull request."runReady, add both reasons tofinishForgejoTrigger(row.id, "done", reason)(already generic).Policy: allowing the follow-up PR. In
apps/server/src/bridge/session-policy.ts:function followUpAllowed(scope: ForgejoImplementerScope, sessionId: string): boolean. It returns true when the session's newest PR,Math.max(...implementerPrNumbers(scope, sessionId)), hasgetPostMerge(repo, pr)?.state === "failed".decide:forgejo_create_pris allowed whenfollowUpAllowed. Otherwise the existing deny stays.scope.headBranchlock ongit_branchis bypassed whenfollowUpAllowed.git_pushremote check stays.in-process.ts:799). It becomes the newest PR with no record, so the one-PR rule applies again.systemContext, whenfollowUpAllowed, replace the "Do not open another pull request" lines with:- Pull request #${pr} was merged but CI failed on its merge commit. Open exactly one follow-up pull request from a new branch to fix it.Critical files & anchors
apps/server/src/forgejo/dispatcher.ts:handleCommitStatus(~877): fan-out plus post-merge lookup.handleLifecycle(~1218, the comment at 1259): stores the record.gate(~352): new actions.resolveCiWatchdog(~648): the pattern forresolveOwnCi.resolveSession(~1002): the continue-only rule.start/dispose(~266): the sweep.apps/server/src/bridge/session-policy.ts:forgejoImplementerPolicy.decide(~294–314),implementerPrNumbers(~273).apps/server/src/forgejo/repo-config.ts:parseCiWatchdog(~97).apps/server/src/forgejo/conversation-detect.ts:buildCiFailureSeed(~273),PullSyncPayloadLike(~345).apps/server/src/db/forgejo-sessions.ts: style template for the new db module.Verification
Run from
apps/server:bun testandbun run typecheck. Add dispatcher tests insrc/forgejo/dispatcher.test.ts, reusing the existing harness (setup(), the fakeapi,enqueueEvent,implementerSession,ciRows-style queries). A new test file is not needed.readCommitStatusreturns[{context:"ci",state:"failure",…}], andlistOpenPullsreturns an agent PR #7 with headsha, mapped toimpl.own_ci_failedrow isdonewith reasonown-ci-failed, andimplreceived a prompt startingCI failed on your pull request.readPull.headShachanged, the row isdropped "head moved since CI finished".own_prs: {enabled: false}, no own rows.closedevent on agent PR #7 withmerged: trueandmerge_commit_sha: "a"*40.getPostMergeiswaiting, and the row reason iswaiting for post-merge CI on aaaaaaa.forgejo_pull_request) on a merged PR is still archived."a"*40with allsuccessarchivesimpland deletes the record.failed, and apost_merge_ci_failedrow runs onimplwith a seed containingfollow-up pull request.postMergeGraceMs: 0,postMergeTimeoutMs: 0, and the dispatcher started:Post-merge CI on lucas/omp-deck#7 never finished.session-policy.test.ts): withsetForgejoSession("o/r", 12, "s1")and afailedpost-merge record for #12,forgejo_create_pris"allow". AftersetForgejoSession("o/r", 13, "s1"), it is denied again.Assumptions & contingencies
merge_commit_shais assumed to be present in Forgejo'spull_requestclosed payload (unverified; confirm first). If it is absent on the instance, thereadPullfallback from step 6 covers it. If both are empty, the PR is archived as before.Closes: #N(with the colon) is unverified. If it does not, change the implementer prompt (session-policy.ts:369, plus the test atsession-policy.test.ts:171) toCloses #${number}.