Forgejo: manually started implementer session #44
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#44
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?
Implementation: #47
Problem
Start an unattended implementer session for a Forgejo issue from the deck UI. It models the final automated implementer flow (plan step 21, Implementer intake), building on step 10 (implementation workflow) and step 14 (session routing). Design reference: KB
omp-deck/forgejo-integration.md(Agent as Implementer), plan KBomp-deck/forgejo-integration-plan.md.forgejo_conversation/forgejo_pull_requestwith a write-capable unattended policy: code tools, git tools incl.git_branch/git_commit/git_push, Forgejo read tools,forgejo_create_pr, comment tools limited to the session's artifacts (the issue and any PR the session opened). Anything that would ask is denied, as for the existing kinds.(repo, issue) → sessionis mapped inforgejo_sessions, so @mention comments continue it through the existing dispatcher (author still needs write access). The PR the agent opens is mapped to the same session too.Closes: #N, post blockers as issue comments.Decisions:
Plan
Context
Add a deck-initiated "Implement issue" flow that creates an unattended
forgejo_implementersession in a per-issue clone, auto-runs its first turn under a write-capable policy, maps the issue (and later its PR) to the session inforgejo_sessions, and uses a 2-hour turn timeout. UI: split "New session" button with an "Implement issue" modal form.Approach
Steps 1–4 are server-only and keep the tree building; step 5 adds the HTTP route; step 6 the web UI (depends on 5's protocol types).
1. Trigger kind + policy (
apps/server/src/bridge/session-policy.ts)SessionTriggerKindto"forgejo_conversation" | "forgejo_pull_request" | "forgejo_implementer".forgejo_implementer: ForgejoImplementerScopeinTriggerScopes.readonly turnTimeoutMs: numbertoSessionPolicy<S>(doc: "Abort a dispatched turn after this long."). Conversation and PR policies:30 * 60_000; implementer:2 * 60 * 60_000.SessionPolicy.decidetodecide(toolName: string, params: unknown, scope: S, sessionId: string): PolicyDecisionanddecidePolicy(trigger, toolName, params, sessionId: string). Existing policies ignoresessionId. Only caller:PermissionFilterinapps/server/src/bridge/permission-filter.ts(~L241) — passthis.sessionId. Updatesession-policy.test.ts/permission-filter.test.tscall sites (grep -n "decidePolicy\|\.decide(" apps/server/src).IMPLEMENTER_TOOL_NAMES:read, grep, find, glob, edit, write, bash,git_status, git_repo_info, git_diff, git_log, git_show, git_fetch, git_branch, git_commit, git_push, git_rebase,forgejo_read_issue, forgejo_list_issue_comments, forgejo_search_issues, forgejo_read_pr, forgejo_get_pr_diff, forgejo_read_file_at_ref, forgejo_list_pr_comments_and_reviews, forgejo_search_prs,forgejo_create_pr,forgejo_create_issue_comment, forgejo_create_pr_comment, forgejo_create_pr_thread_comment. Noask. Comment above it: write access is fine because the session runs in its own clone and is only started by the deck user or by comments from users with write access.forgejoImplementerPolicy.decide(toolName, params, scope, sessionId):bash: ifnew RegExp(GIT_BASH_RULE_PATTERN).test(String(params?.command ?? ""))(importGIT_BASH_RULE_PATTERN,GIT_BASH_RULE_MESSAGEfrom./git-bash-rule.ts; match flags exactly as the permission-rule matcher compilesparamPattern— checkfindMatchingRulein permission-filter.ts and use the same flags) →{ deny: GIT_BASH_RULE_MESSAGE }; else"allow". Raw git stays denied even when the user disables the global rule.FORGEJO_COMMENT_TOOLS):"allow"ifparamNumber(params) === scope.numberorgetForgejoSession(\${scope.owner}/${scope.repo}`, n) === sessionId(import from../db/forgejo-sessions.ts; guardtypeof n === "number"); else{ deny: `only #${scope.number} and pull requests opened by this session may be commented on` }`.toolNames:"allow"(so useraskrules and the default-ask offorgejo_create_prdon't become unattended denials; user deny rules still win because PermissionFilter checks them first).{ deny: "not available in unattended Forgejo implementer sessions" }.systemContext({ number, baseBranch })— exact lines: where<base>is`${baseBranch}`orthe remote's default branchwhen null.repoMismatch: reuse the sharedrepoMismatch.SESSION_POLICIESand add aforgejo_implementercase topolicyToolNames,decidePolicy,policySystemContext,assertTriggerRepo. Addexport function policyTurnTimeoutMs(trigger: SessionTrigger): numberwith the same exhaustive-switch shape.2. Checkout base branch + resume (
apps/server/src/forgejo/checkout.ts,apps/server/src/bridge/in-process.ts)createCheckoutandrestoreCheckoutopts gainbaseBranch?: string | null. Forkind === "issue"with a non-emptybaseBranch: replace theset-head+ checkout pair withgit(dest, ["checkout", "--detach", \refs/remotes/origin/${baseBranch}`], "git checkout of the base branch"); null/absent keeps current behaviour. Update the doc comment ("…or, for issues,baseBranch` or the remote's default branch"). A non-existent branch fails with git's message and removes the clone (existing catch).in-process.tsresume (~L340–342): computekindas"pr"whentrigger.kind === "forgejo_pull_request" || (trigger.kind === "forgejo_conversation" && trigger.scope.isPull), else"issue"; passbaseBranch: trigger.kind === "forgejo_implementer" ? trigger.scope.baseBranch : null.3. Map the PR the session opens (
apps/server/src/bridge/in-process.ts)attach()(next to thetool_execution_end/todo_writeblock, ~L892), whentrigger?.kind === "forgejo_implementer"and the event istool_execution_endwithtoolName === "forgejo_create_pr",!event.isError, andtypeof event.result?.details?.number === "number"→setForgejoSession(\${trigger.scope.owner}/${trigger.scope.repo}`, number, sessionId)wrapped in try/catch logginglog.warn("could not map PR #… to session …", err). Event shape ({ toolCallId, toolName, result: { content, details }, isError }) is the oneapps/web/src/lib/reducer.tsconsumes; details come fromForgejoCreatePrResultinforgejo/tools/prs.ts`. This runs for deck-sent prompts too, since it is in the bridge, not the dispatcher.4. Dispatcher: per-kind timeout, implementer reuse, manual start (
apps/server/src/forgejo/dispatcher.ts)deps.turnTimeoutMs?: numberas a test override applying to every kind (doc: "Overrides every kind's timeout (tests)."); delete theturnTimeoutMsfield and its constructor default. Addprivate timeoutFor(kind: SessionTriggerKind): number { return this.deps.turnTimeoutMs ?? SESSION_POLICIES[kind].turnTimeoutMs; }.runTurn(handle, seed, timeoutMs: number)usestimeoutMsfor its timer andtimed out after …message. ChangefailureLabel(kind: FailureKind, timeoutMs: number)(timeout case formatstimeoutMs),surfaceFailure(row, reason, kind, sessionId, timeoutMs = 0)andfail(job, reason, kind, sessionId, timeoutMs = 0)to thread it through; onlyrunReady's turn-failure path passes a real value.resolveSession(andreuseMapped's success value) returns{ handle, broughtLive, kind: SessionTriggerKind }(from the created trigger, or the mapped session'sgetSessionTrigger);runReadycomputesms = this.timeoutFor(kind)and passes it torunTurnandfail.reuseMapped: fortrigger.kind === "forgejo_implementer"require only owner/repo equality (the mapping itself proves the number belongs to the session, since the PR is mapped by step 3); other kinds keep the number check.export class ImplementerStartError extends Error { constructor(readonly status: 404 | 409 | 422 | 502, message: string) }.DispatchApi(forgejo/dispatch-api.ts) withreadIssue(owner, repo, number, signal?): Promise<ApiResult<{ title: string; state: "open" | "closed"; isPull: boolean }>>viaclient.json("GET", repoApiPath(owner, repo, "issues", String(number))),isPull = raw.pull_request != null. Update the test fakeDispatchApiindispatcher.test.ts.buildImplementerSeed({ repo, number, baseBranch }: { repo: string; number: number; baseBranch: string | null }): stringinforgejo/conversation-detect.tsreturningImplement Forgejo issue ${repo}#${number}+\n\nBase branch: ${baseBranch ?? "the remote's default branch"}.api = deps.api(); null →ImplementerStartError(422, "Forgejo is not configured").key = artifactKey({ repo: \${owner}/${repo}`, number }); ifthis.running.has(key)or any pending row has that key → 409the agent is already handling #N; try again when it finishes`.mapped = getForgejoSession(repoFull, number); ifmapped && getSessionTrigger(mapped) !== null && !getArchivedMap([mapped]).has(mapped)→ 409`#N already has session ${mapped}; archive it to start a new implementer session`. (AgetSessionTriggerthrow counts as unusable → proceed.)api.readIssue: not ok with 404 → 404issue #N not found; other failure → 502could not read #N: <status> <message>;isPull→ 422#N is a pull request, not an issue;state !== "open"→ 422#N is closed.this.running.add(key)(blocks the comment queue for this issue;pumpalready skips running keys). From here every failure path mustrunning.delete(key)andpump().cwd = await (deps.createCheckout ?? createCheckout)({ workspacePath: workspace.path, owner, repo, kind: "issue", number, baseBranch }); on throw → 422could not prepare a checkout: <msg>.handle = await deps.bridge.createSession({ cwd, ...(model ? { model } : {}), trigger: { kind: "forgejo_implementer", scope: { owner, repo, number, baseBranch } }, placement: { workspaceId: workspace.id, checkoutPath: cwd } }); on throw →removeCheckout(cwd)and rethrow (route maps to 500).setForgejoSession(repoFull, number, handle.sessionId);handle.setName(\Implement: ${repoFull}#${number}`)with warn-on-failure likeresolveSession`.this.track(...)a background task:outcome = await this.runTurn(handle, buildImplementerSeed(...), this.timeoutFor("forgejo_implementer")); if not ok → post on the issue viaapi.postCommentthe body`omp-deck: the implementer session failed: ${this.failureLabel(outcome.kind, ms)}.\n\nDetails are in omp-deck session \`${sessionId}\`.`; if posting fails →notificationService.notify({ level: "error", title: \Implementer failed: ${repoFull}#${number}`, body: `${outcome.error}\n\nDeck session: `${sessionId}``, source: INBOX_SOURCE }).finally: dispose the handle unlesspresence.openSessionIds()has it (same asrunReady),running.delete(key),pump()`.{ sessionId: handle.sessionId }without awaiting the turn.index.tsunchanged (defaults now come from policies).5. HTTP route (
apps/server/src/routes-forgejo.ts,routes.ts,index.ts, protocol)packages/protocol/src/index.ts, next toForgejoTestResponse):buildForgejoRouter(opts: { startImplementer?: ForgejoDispatcher["startImplementer"] } = {}); addPOST /forgejo/implement(update the header doc list):invalid json body;numbernot a positive integer → 400number must be a positive integer;baseBranchtrimmed, empty → null;modelmust have stringproviderandidif present, else 400.listWorkspaces()(db/workspaces.ts) → 404workspace not found.cfg = readForgejoConfig(); null → 422Forgejo is not configured;ref = await resolveRepoFromCwd(ws.path, cfg.url); null → 422workspace origin is not a repository on the configured Forgejo instance.opts.startImplementermissing → 503implementer is unavailable.ImplementerStartError→c.json({ error: err.message }, err.status); other errors → log + 500{ error: String(err) }; success →ImplementIssueResponse.buildRouteropts gainstartImplementer?: ForgejoDispatcher["startImplementer"], passed tobuildForgejoRouter({ startImplementer: opts.startImplementer })at ~L286.index.tspassesstartImplementer: (o) => forgejoDispatcher.startImplementer(o).6. Web UI
apps/web/src/lib/forgejo-api.ts:implementIssue(body: ImplementIssueRequest): Promise<ImplementIssueResponse>→POST /forgejo/implement.apps/web/src/lib/store.ts: addimplementIssue(req: ImplementIssueRequest): Promise<string>to the store interface (~L188) and implementation mirroringcreateSession(~L368): callforgejoApi.implementIssue,ws.send({ type: "subscribe", sessionId }), add tosubscribed, setactiveId, refresh sessions/workspaces, return the id.apps/web/src/components/ImplementIssueModal.tsxusingui/Modal.tsx: fields Issue number (type="number", required, min 1), Base branch (text, placeholder "default branch"), Model (<select>, first option "Default model" = undefined; options fromapi.listModels()without sessionId, loaded on open, valueprovider/id). Shows the active workspace name read-only. Submit disabled while busy or number invalid; server error text shown inline under the form (notalert). On success: close,navigate(\/session/${id}`)`.apps/web/src/components/NewSessionButton.tsx: split button — main part runs the existing new-session handler passed asonNew, a chevron part (aria-haspopup="menu",aria-expanded) opens a menu with onerole="menuitem""Implement issue" that opensImplementIssueModal. Follow the outside-click/Escape pattern ofcomponents/routines/canvas/AddStepPalette.tsx. Props:{ onNew: () => void; disabled: boolean; busy: boolean; title?: string; size: "sm" | "md" }(sm = Sidebarh-8 text-[13px], md = SessionPickerh-9 text-sm). Both halves disabled under the same condition as today (isGlobal || !scopePath).components/Sidebar.tsx(~L144–153) andcomponents/chat/SessionPicker.tsx(~L81–90) withNewSessionButton.Critical files & anchors
apps/server/src/bridge/session-policy.ts— kinds, policies, four exhaustive switches.apps/server/src/forgejo/dispatcher.ts—resolveSession/reuseMapped/runTurn/failureLabel; newstartImplementer.apps/server/src/bridge/in-process.ts— resumerestoreCheckoutkind (~L340) and event listener (~L892).apps/server/src/bridge/permission-filter.ts~L240 —decidePolicycall gainssessionId.apps/server/src/forgejo/checkout.ts—createCheckoutbase branch.Verification
Run from
/workspace/omp-deck:bun test apps/server/srcand the web/server typechecks (bun run typecheckor the repo's equivalent perpackage.json).New tests:
session-policy.test.ts,describe("forgejo_implementer"): tool surface containsedit,bash,git_push,forgejo_create_prand notask/forgejo_post_review;bashgit commit -m x→ deny,ls→ allow;forgejo_create_pr→ allow;forgejo_create_issue_commentonscope.number→ allow, on 99 → deny, on 99 aftersetForgejoSession("o/r", 99, "s1")with sessionId"s1"→ allow and with"s2"→ deny (needs the DB boot helper used bydb/*.test.ts);policyTurnTimeoutMs2h vs 30 min.checkout.test.ts: issue checkout withbaseBranch: "feature"detaches atorigin/feature; non-existent base branch throws and leaves no directory.dispatcher.test.ts(startImplementer, using the existing harness fakes): creates a session with trigger{ kind: "forgejo_implementer", scope: { owner, repo, number: 4, baseBranch: null } }, mapsrepo#4, sends the implementer seed; 409 when an unarchived mapped trigger session exists; 409 while a pending row for#4is being drained; 422 for a PR/closed issue; 404 for a missing issue; a later mention trigger on#4reuses the implementer session; a trigger on PR#7mapped to the implementer session reuses it; timeout surfaces a comment on the issue.Manual end-to-end (Forgejo configured, a registered workspace whose origin is the Forgejo repo, a throwaway open issue #N):
Implement: owner/repo#Nthat is already streaming;curl -s localhost:8787/api/sessionslists it withworkspacePath= the workspace.Closes: #N. Expected:sqlite3 <dataDir>/deck.db "select * from forgejo_sessions"shows both#Nand the PR number → same session id.#N already has session ….Assumptions & contingencies
read,grep,find,glob,edit,write,bash(the conversation policy's comment names the first four). IfrestrictToolNamesreports an unknown name at session creation, use the names the SDK lists in its built-in tool registry instead.tool_execution_end.result.detailsturns out not to carry custom-tooldetails, parse the PR number from the result textcreated PR #<n>:instead.Implementation decisions so far:
startImplementerchecks again that no worker holds the issue right before it claims the issue's queue slot. The issue read is awaited in between, so a comment trigger could otherwise start on the same issue in that gap.422 could not prepare a checkout: …with git's message. The clone is removed.session-policy.tsnow share oneunknownKindhelper for theirdefaultbranch.