Forgejo: implementer started by a label (plan step 21) #130
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#130
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
Adding the configured intake label to an issue should start an implementer session: the same session the deck's "Implement issue" button starts. Today
startImplementer(apps/server/src/forgejo/dispatcher.ts) is the only way in. It requires the hard-codedAgent/Ready, refusesStatus/Blocked, refuses (409) when the issue already has a usable session, creates the issue clone, swapsAgent/Ready→Agent/In Progressand runs the first turn. Step 18's label path (issue_labelevents, only labels the latest timeline change added, sender write access) only recognisesissue_handler(labelTriggerForinrepo-config.ts).Decisions from grilling:
implementationsection in.omp/forgejo.yamlwithintake_label(enables the label path),progress_label(optional) andmax_concurrent(default 1).progress_labelis added if set.intake_labelif present and addsprogress_labelif set. Without that config it changes no labels. It still refusesStatus/Blocked.forgejo_conversationsession is archived and replaced by a new implementer session, and the mapping moves to it. Any other usable session (implementer, pull request) is a refusal.max_concurrentlimits the first turns that the label path started in a repo. A label row over the limit stays pending and runs when a slot frees. The button is never limited, and its sessions do not count toward the limit.matchWorkspaces(the existing gate), base branchnull(the remote's default), default model, seedbuildImplementerSeed..omp/commands/implement.md(the chat command). The token-budget gate is out of scope.Plan
Context
All work is in
apps/server/src/forgejo/(repo-config.ts,dispatcher.ts, and their tests). The checks, archive/replace, checkout, session, label update and first turn are factored into shared private methods. BothstartImplementerand a new label branch of the dispatcher call them, so there is exactly one implementation.Approach
Config (
repo-config.ts).implementation: { intakeLabel: string | null; progressLabel: string | null; maxConcurrent: number }toRepoConfig.DEFAULT_REPO_CONFIG.implementation = { intakeLabel: null, progressLabel: null, maxConcurrent: 1 }.parseImplementation(doc.implementation), followingparseIssueHandler's style:"`implementation` must be a mapping".intake_labelandprogress_label: optional; a present value must be a non-empty string (trimmed). Errors:"`implementation.intake_label` must be a non-empty string"and the same wording forprogress_label.max_concurrent: optional; a present value must be an integer ≥ 1. Error:"`implementation.max_concurrent` must be a positive integer".parseRepoConfigafter parsing:issueHandler.label→ error"`implementation.intake_label` must differ from `issue_handler.label`"."`implementation.progress_label` must differ from `implementation.intake_label`".LabelTriggerto"issue_handler" | "implementation".labelTriggerForreturns"implementation"whenconfig.implementation.intakeLabel === name(issue_handler is checked first; they cannot collide).implementationsection.Label resolution (
dispatcher.ts).resolveLabelTriggerreturns{ label: string; trigger: LabelTrigger; config: RepoConfig } | null.configurednow includesconfig.implementation.intakeLabelwhen non-null.findaccepts any label wherelabelTriggerFor(config, l) !== null.gate'slabeledbranch:reason = resolved.trigger === "implementation" ? "implementer-intake" : "issue-labeled"."implementer-intake"toReadyReasonand to thelabeldoc comment onReady.resolved.config.implementation.maxConcurrentonReadyas a new fieldmaxConcurrent: number | null(null for every other reason). Set it in thereturnat the end ofgate.Concurrency slots and deferral (
dispatcher.ts).private intakeRunning = new Map<string, number>(), keyed byrow.repo.toLowerCase().private deferred = new Map<string, StoredForgejoTrigger>(): row id → row.gate, just beforereturn {...}, whenreason === "implementer-intake":(intakeRunning.get(k) ?? 0) >= maxConcurrent, return the new sentinel"deferred". The row stayspendingand is not finished.awaitin between, so two drains cannot both take the last slot.gateandgateSafelytoReady | "deferred" | null, and updategate's doc comment: "Every null return has finished the row; "deferred" leaves it pending."drain: on"deferred", keep the id ingated, setdeferred.set(row.id, row)andreturn. That stops draining this artifact so later rows of the same issue stay behind it (FIFO).pump: skip a key when anydeferredrow has thatartifactKey.private releaseIntakeSlot(repo: string):repo.toLowerCase()matches, delete it fromdeferredand fromgated.this.pump().Shared implementer core (
dispatcher.ts), replacing the body ofstartImplementer.READY_LABELandIN_PROGRESS_LABEL. KeepBLOCKED_LABELandhasLabel.private async checkImplementable(api, { owner, repo, number }): Promise<{ replace: string | null }>. It throwsImplementerStartErrorwith these messages:getForgejoSession, thenisUsableTriggerSession. If it is usable andgetSessionTrigger(mapped)?.kind === "forgejo_conversation", thenreplace = mapped. If it is usable with any other kind → 409`#${number} already has session ${mapped}; archive it to start a new implementer session`(the existing text).hasLabel(labels, BLOCKED_LABEL)→ 422`#${number} is labeled Status/Blocked`.private async readImplementationConfig(api, owner, repo): Promise<RepoConfig["implementation"]>. It readsREPO_CONFIG_PATHviaapi.readRepoFileand parses it withparseRepoConfig.ImplementerStartError(502,could not read ${REPO_CONFIG_PATH}: ${status} ${message}).ImplementerStartError(422,${REPO_CONFIG_PATH}: ${error}).private async launchImplementer(opts: { api; workspace; owner; repo; number; baseBranch; model?; replace: string | null; labels: RepoConfig["implementation"] }): Promise<SessionHandle>. Moved fromstartImplementer:replace !== null, firstawait this.archiveAfterIdle(replace, "forgejo_conversation"). Its clone is the sameissue-Npath thatcreateCheckoutre-creates.ImplementerStartError(422, "could not prepare a checkout: …")), thencreateSessionwith theforgejo_implementertrigger,removeCheckouton failure,setForgejoSession(overwrites the old mapping), andsetName.markInProgress(api, issue, sessionId, labels).markInProgress/swapInProgressLabels. Takelabelsand computeadd = progressLabel ? [progressLabel] : []andremove = intakeLabel ? [intakeLabel] : []. If both are empty, return without any API call.`omp-deck could not update this issue's labels: ${reason}\n\nThe implementer session \`${sessionId}\` is running regardless.`and the notification title to`Could not update labels of ${repoFull}#${number}`.runImplementerTurn. Now returnsPromise<TurnOutcome>and still posts its failure comment or notification as today.startImplementer. Keeps its signature and existing behavior:running/pending 409 checks;readImplementationConfig, thencheckImplementable;runningafter the awaits, then claiming the key;launchImplementer, releasing the key on error;this.track(this.runImplementerTurn(...).then(() => undefined).finally(release))..omp/forgejo.yaml.Label path run (
dispatcher.ts). Indrain, routeready.reason === "implementer-intake"to a newprivate async runIntake(ready: Ready)instead ofrunReady:try { … } catch (err) { await this.failInternal(row, err) } finally { this.gated.delete(row.id); this.releaseIntakeSlot(row.repo) }.if (!claimForgejoTrigger(row.id)) return.checkImplementableand thenlaunchImplementer. Useworkspace = ready.workspace,baseBranch = nulland nomodel, andlabelsfrom the config the gate read; carry it onReadyasimplementation: RepoConfig["implementation"] | nullnext tomaxConcurrent, or replacemaxConcurrentwith it.ImplementerStartError:await this.postNotice(job,omp-deck can't start an implementer for #${row.number}: ${err.message}), thenfinishForgejoTrigger(row.id, "dropped", err.message).setForgejoTriggerSession(row.id, handle.sessionId), thenconst outcome = await this.runImplementerTurn(...), awaited here so the artifact key stays held during the first turn. ThenfinishForgejoTrigger(row.id, outcome === "ok" ? "done" : "failed", outcome === "ok" ? "implementer-intake" : outcome.error). Do not callthis.fail, becauserunImplementerTurnalready commented.Ready.maxConcurrent/implementationthe single fieldimplementationso there is one source.Critical files & anchors
apps/server/src/forgejo/dispatcher.ts:gate(~430–621),drain/pump(~381–411),resolveLabelTrigger(~661),startImplementer→swapInProgressLabels→runImplementerTurn(~1720–1924),archiveAfterIdle(~1644).apps/server/src/forgejo/repo-config.ts:RepoConfig,parseRepoConfig,labelTriggerFor.apps/server/src/forgejo/dispatcher.test.ts:describe("ForgejoDispatcher.startImplementer")(~1112). Its harness fakesreadRepoFile, labels and sessions; the issue-labeled test is at ~554.Verification
Run
bun test apps/server/src/forgejofrom/workspace/omp-deck.Update existing tests. Delete the
Agent/Readyrefusal assertions and change"…to Agent/In Progress:"to"omp-deck could not update this issue's labels:". These tests then configureimplementationthrough the harness's repo file.Add these tests.
In
repo-config.test.ts:implementationparsing: the defaults, each error string, and both cross-checks.labelTriggerForreturns"implementation"for the intake label.In
dispatcher.test.ts, with the configimplementation: {intake_label: "Agent/Ready", progress_label: "Agent/In Progress"}:issue_labelrow addingAgent/Readyby a write-access sender produces:forgejo_implementerandbaseBranch: null;"Implement Forgejo issue lucas/omp-deck#4";[Agent/In Progress];donewith reason"implementer-intake".droppedand no session.Status/Blockedpresent → a notice comment containing"can't start an implementer", rowdropped, labels untouched.forgejo_conversationsession mapped to #4 (manual and label path) → that session is archived, a new implementer session is mapped, and there is no 409.forgejo_implementersession → 409 / notice.max_concurrent(default 1): two label rows on issues #4 and #5 inmanualturn mode:pendingwith no session while #4's first turn runs;Assumptions & contingencies
archiveSessiondep not be injectable in the startImplementersetup, inject it the way other lifecycle tests indispatcher.test.tsdo (deps.archiveSession) rather than calling the real bridge.