Per-repo config: prompt fields for the issue-handler (triage) case #235
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#235
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
.omp/forgejo.yamllets a repository opt into an issue handler (issue_handler.label,issue_handler.title_pattern) that triages issues, but offers no way to steer what thathandler does or how it behaves. Two things are missing:
No
system_promptskey for it.system_promptshasglobal,conversation,pull_request,implementerandplan_issue(apps/server/src/forgejo/repo-config.ts:77-83).A triage session is started as a plain
forgejo_conversationtrigger(
apps/server/src/forgejo/dispatcher.ts:1733) andpromptSessionKindderives the prompt kindfrom
trigger.kindalone (apps/server/src/forgejo/repo-system-prompt.ts:11-14), so a triagesession is indistinguishable from a generic @mention conversation and silently receives
system_prompts.conversation.No way to change what triage does. The triage task is hardcoded in
buildIssueHandlerSeed(apps/server/src/forgejo/conversation-detect.ts:686): analyze, searchduplicates, post one comment, "Do not change labels or anything else; do not implement." A
repository that wants triage to apply a severity label, assign an owner, or correlate a
monitoring alert against recent deploys cannot express it.
End state: two new config fields.
system_prompts.issue_handler— standing repository instructions for a triage session.issue_handler.prompt— replaces the steering half of the triage task, keeping a minimalbuilt-in core.
Decided behaviour
whether triage created it or it already existed (someone @mentioned the agent on the issue
first). It stays one for the rest of its life, so later comment turns on that issue also run
under the triage instructions.
global+issue_handler, and notconversation—the same one-block-per-kind rule every other kind follows. Text that should apply during triage
too belongs in
global.issue_handler.promptis set it replaces the built-in steering entirely.The surviving core: always finish by posting one comment on the issue, and never implement.
Label and assignee changes become available to the custom prompt.
labelandtitle_patterntriage shareissue_handler.prompt.pointless. Repository instructions are baked in when a session is created or resumed, so when a
triage trigger lands on a live session that is not yet marked, the dispatcher lets the running
turn finish, marks the session, recycles it (dispose +
ensureSessionLive, which replays thepersisted JSONL and loses no conversation history), and only then dispatches the triage turn.
Plan
Steps are ordered so the tree builds after each. Steps 1–3 are pure additions plus one signature
change; step 4 wires them into session build; steps 5–6 are the dispatcher and seed.
1. Config surface —
apps/server/src/forgejo/repo-config.tsAdd
promptto the issue-handler section andissue_handlertosystem_prompts.RepoConfig["issueHandler"]becomes{ label: string | null; titlePattern: RegExp | null; prompt: string | null } | null.Doc the new member: the steering half of the triage task; null = the built-in steering.
RepoConfig["systemPrompts"]gainsissueHandler: string | null.DEFAULT_REPO_CONFIG.systemPromptsgainsissueHandler: null(line ~99).In
parseIssueHandler(line 249), before the existingif (label === null && titlePattern === null)check, parse the new key with the same shape theother string keys use:
Return
{ ok: true, value: { label, titlePattern, prompt } }. Leave the"issue_handlerneedslabelortitle_pattern"check exactly as it is — it already rejectsa section containing only
prompt, which is the correct outcome.In
parseSystemPrompts(line 212) addissue_handler: "issueHandler"to thekeysmap. Theexisting loop then validates and trims it like the rest.
Extend
PromptSessionKind(line 232) with"issue_handler"and addissue_handler: "issueHandler"toPROMPT_FIELD(line 234).repoSystemPromptneeds no change:it already emits
global+ the one kind block, which is the decided layering.Update the module doc (lines 1-29): the
system_promptssentence must listissue_handleramong the per-kind keys, and the
issue_handlersentence must mention thatpromptreplaces thetriage task's steering while the agent still always comments and never implements.
This changes the shape of two object literals used in
repo-config.test.ts(lines 29, 220, 231build
{ label, titlePattern }; line 287 asserts the fullsystemPromptsobject). Update them toinclude
prompt: null/issueHandler: null; they are shape assertions, not behaviour pins.2. Persisted marker — new migration + db module
The trigger row cannot carry this:
insertSessionTriggeris insert-only(
apps/server/src/db/session-triggers.ts:21-25) and a triage trigger reaching an already-mappedsession never rebuilds it. Mirror the
plan_issue_sessionsbinding instead.New
apps/server/src/db/migrations/024-forgejo-issue-handler-sessions.sql(the runnerauto-discovers numbered
.sqlfiles,apps/server/src/db/index.ts:80-82):New
apps/server/src/db/issue-handler-sessions.ts, modelled onapps/server/src/db/plan-issue-sessions.ts(samegetDb/nowIsoimports, same doc style):markusesINSERT OR IGNOREso re-marking an already-marked session is a no-op (unlike theplan-issue insert, this is called on every triage turn).
isis aSELECT 1 FROM forgejo_issue_handler_sessions WHERE session_id = ?existence check.Call
deleteIssueHandlerSession(id)in the session-delete route next todeletePlanIssueBinding(id)(apps/server/src/routes.ts:216), so a deleted session leaves noorphan row.
3. Prompt-kind resolution —
apps/server/src/forgejo/repo-system-prompt.tspromptSessionKindgains a third input:Body: return
"issue_handler"first whenissueHandleris true (it is only ever set onconversation sessions, and triage-ness outranks the generic conversation kind); otherwise keep the
existing logic unchanged. Update the doc comment to name the new kind and say the mark wins over
the trigger kind.
Both call sites are in
apps/server/src/bridge/in-process.ts(lines 303 and 451) and are updatedin step 4.
4. Session build —
apps/server/src/bridge/in-process.tsCreate path (
createSession, around lines 300-378):issueHandler?: truetoCreateSessionOptsinapps/server/src/bridge/types.ts, documentedas: the session is started by the repository's issue handler; only valid together with a
forgejo_conversationtrigger.if (opts.issueHandler && opts.trigger?.kind !== "forgejo_conversation") throw new Error("issueHandler requires a forgejo_conversation trigger");promptSessionKind(opts.trigger?.kind ?? null, planIssue !== null, opts.issueHandler === true).insertSessionTriggersucceeds (inside the sameif (trigger)block, lines 362-369), callmarkIssueHandlerSession(handle.sessionId)whenopts.issueHandleris set, inside the sametryso a failure takes the samedeleteSession+ rethrow path. The mark must exist before thefirst turn so a later resume keeps the kind.
Resume path (
resumeSession, around lines 404-452):const planIssue = existingId ? getPlanIssueBinding(existingId) : null;(line 405) addconst issueHandler = existingId ? isIssueHandlerSession(existingId) : false;promptSessionKind(trigger?.kind ?? null, planIssue !== null, issueHandler).Nothing else in the prompt assembly changes:
repoPromptalready flows into thesystemPromptcallback at lines 329 and 471.
5. Dispatcher — mark, recycle, and carry the prompt
apps/server/src/forgejo/dispatcher.ts.Carry the custom prompt on
Ready. Add to theReadyinterface (line 359):Populate it where the reason is decided (lines 590-602), reusing the config each branch already
reads — do not re-read
.omp/forgejo.yaml, which is whatimplementationonReadyexists toavoid:
resolveTitleTrigger(line 858) to returnPromise<RepoConfig["issueHandler"] | null>:nullon every path that already finishes the row, and the non-nullhandleron the successpath. Its only caller is line 591; rewrite it as
const handler = await this.resolveTitleTrigger(job, payload); if (handler === null) return null; issueHandlerPrompt = handler.prompt; reason = "issue-title";and update the doc comment.
else(line 600-602) setissueHandlerPrompt = resolved.config.issueHandler?.prompt ?? null;—resolveLabelTriggeralready returns
config.let issueHandlerPrompt: string | null = null;next tolet label(line 552) and addissueHandlerPromptto the returnedReadyobject (line 736).Mark a newly created triage session. In
resolveSessionthecreateSessioncall (line 1753)gains
issueHandler: truewhenready.reason === "issue-labeled" || ready.reason === "issue-title"— pass it only for the conversation branches; the
prHead !== nullimplementer branch can never bereached by a triage reason, so a plain conditional spread is enough:
Add a module-level
function isTriage(reason: ReadyReason): booleannext tocontinueOnly(line 2746) returning
reason === "issue-labeled" || reason === "issue-title", and use iteverywhere this plan tests for triage.
Mark and recycle a reused session. Add a private method and call it in
runReadyimmediatelyafter
resolveSession(line 1067), beforegetPreviousSessionTurn— the recycled handle must bethe one the rest of the turn uses:
Body:
resolvedunchanged when!isTriage(ready.reason)orresolved.created(the createpath already marked it) or
isIssueHandlerSession(resolved.handle.sessionId)(already a triagesession; its prompt is correct).
markIssueHandlerSession(sessionId).await this.waitUntilIdle(sessionId, this.deps.turnTimeoutMs ?? SESSION_POLICIES[resolved.kind].turnTimeoutMs)— the existing helper at line 2037; it returns immediately when nothing is streaming.
await resolved.handle.dispose(), thenconst live = await this.deps.bridge.ensureSessionLive(sessionId).live.ok, return{ ...resolved, handle: live.handle }. KeepbroughtLiveas it was: itdrives the end-of-turn disposal at line 1180, and the session's liveness ownership has not
changed.
!live.ok, log a warning (log.warn) naming the session andlive.code, and returnresolvedunchanged. The mark is already persisted, so the instructions apply from thesession's next wake; failing the whole trigger over a prompt refresh would be worse than running
the turn with the old prompt.
Dispose errors follow the existing convention at line 1895:
.catch+log.warn, never fatal — ifdispose fails, skip the resume and return
resolvedunchanged.6. Triage task seed —
apps/server/src/forgejo/conversation-detect.tsbuildIssueHandlerSeed(line 665) gains aprompt: string | nullfield in its parameter object.Replace the single hardcoded task line (line 686) with:
prompt === null— byte-identical to today, so an unconfigured repository sees no change:promptset — the custom steering, then the invariant core as its own line:The core comes last so it is not buried by a long custom prompt. Both lines join into the same
[...].join("\n")array the function already builds; everything else in the seed (title, labels,trigger line, orientation lines, description fence) is unchanged.
Update the function's doc comment to say
promptreplaces the built-in steering and that thecomment-and-never-implement core always applies.
Both call sites are in
runReady(lines 1119-1128); passprompt: ready.issueHandlerPromptineach.
7. Commented-out examples in this repository's own config —
.omp/forgejo.yamlEvery optional section of the file is documented in place as a commented-out block showing a
realistic value and the default (
system_promptsat lines 18-30,compactionat lines 32-36).The two new keys must follow that convention, or they are undiscoverable: nothing else in the
repository documents this schema except the module doc in
repo-config.ts.Under the existing live
issue_handler:block (lines 1-2), append the new key as a comment witha short explanation of the core that survives:
In the commented
system_promptsblock, addissue_handlerbetweenconversationandpull_request(matching the order the keys are declared inRepoConfig["systemPrompts"]), andextend the block's header comment to say that a session which has triaged an issue uses
issue_handlerinstead ofconversationfor the rest of its life:These are comments only: the live config of this repository keeps its current behaviour, and
parseRepoConfignever sees the new keys. Verified by step 4 of Verification — the file is parsedby the dispatcher tests' fixtures, not by the repository itself, so a typo here cannot break a
test; read the block back after editing to confirm the YAML comment markers are intact.
Critical files & anchors
apps/server/src/forgejo/repo-config.tsparseIssueHandler:249,parseSystemPrompts:212,PROMPT_FIELD:234, module doc :1-29apps/server/src/bridge/in-process.tscreateSession:300-378,resumeSession:404-452createAgentSession; both paths must resolve the kind identically or the prompt flips between wake-ups.apps/server/src/forgejo/dispatcher.tsgate:551-602 and :736,runReady:1067,resolveSession:1753,waitUntilIdle:2037waitUntilIdleandensureSessionLivealready exist and must be reused rather than re-implemented.apps/server/src/forgejo/conversation-detect.tsbuildIssueHandlerSeed:665-693apps/server/src/db/plan-issue-sessions.ts+migrations/017-plan-issue-sessions.sql.omp/forgejo.yamlsystem_promptsblock :18-30,compaction:32-36Verification
Run from the repository root.
Config parsing. Extend
apps/server/src/forgejo/repo-config.test.ts:parseRepoConfig("issue_handler:\n label: needs-triage\n prompt: Apply a severity label.\n")→
config.issueHandlerequals{ label: "needs-triage", titlePattern: null, prompt: "Apply a severity label." }.parseRepoConfig("issue_handler:\n label: needs-triage\n prompt: ' '\n")→ok: falsewith error
"`issue_handler.prompt` must be a non-empty string".parseRepoConfig("issue_handler:\n prompt: x\n")→ok: falsewith the existing"`issue_handler` needs `label` or `title_pattern`".parseRepoConfig("system_prompts:\n issue_handler: t\n")→config.systemPrompts.issueHandler === "t".repoSystemPrompt(config, "issue_handler")withglobal: "g",conversation: "c",issueHandler: "t"→ contains"g"and"t", and not"c".promptSessionKind("forgejo_conversation", false, true) === "issue_handler"andpromptSessionKind("forgejo_conversation", false, false) === "forgejo_conversation".bun test apps/server/src/forgejo/repo-config.test.tsSeed composition. New test in
apps/server/src/forgejo/conversation-detect.test.ts:buildIssueHandlerSeed({ ..., prompt: "Apply a severity label and assign an owner." })containsthat sentence, contains
forgejo_create_issue_comment, containsDo not implement, and doesnot contain
Search for similar or duplicate issues. Withprompt: nullit containsSearch for similar or duplicate issuesandDo not change labels.bun test apps/server/src/forgejo/conversation-detect.test.tsEnd-to-end through the dispatcher — this is the check that proves the feature, using the
existing harness in
apps/server/src/forgejo/dispatcher.test.ts(seeTRIAGE_CONFIGat :608 andthe label tests from :611):
h.api.repoFilereturns"issue_handler:\n label: needs-triage\n prompt: Apply a severity label.\n"; enqueue a labeltrigger; assert
h.sessions[0].handle.prompts[0]containsApply a severity label.andforgejo_create_issue_comment, and does not containSearch for similar or duplicate issues.whenIdle, assertisIssueHandlerSession(h.sessions[0].handle.sessionId)is true, and thath.sessions[0].opts.issueHandleristrue.forgejo_conversationtrigger andmap a live session to the issue (the pattern at :1733 and :2304), then enqueue the label
trigger. Assert the session id is unchanged (no new session created), that
isIssueHandlerSession(id)is now true, and thatensureSessionLivewas called for it afterthe dispose — i.e. the turn ran on a handle obtained after the recycle.
TRIAGE_CONFIGwithout
prompt) must still pass with its'added the label "needs-triage"'assertion and theoriginal task text.
bun test apps/server/src/forgejo/dispatcher.test.tsFull server suite and typecheck, which also catches the
promptSessionKindsignature changeand the
issueHandlerobject-shape updates across existing tests:bun test apps/serverand the repository's typecheck script.Migration applies. Boot the server once against an existing database and confirm
024-forgejo-issue-handler-sessions.sqlis recorded inschema_migrationsand the table exists.An
IF NOT EXISTStable with no data dependency means a re-run is safe.Assumptions & contingencies
ensureSessionLiveafterdisposereturns a handle with the rebuilt prompt. This followsfrom
resumeSessionre-resolvingrepoPromptat line 451, andensureSessionLiveresuming fromthe persisted JSONL (
apps/server/src/bridge/types.ts:42-48). If the bridge instead hands back acached handle for a session disposed moments earlier, drop the recycle: keep the mark, log that
the instructions apply from the next wake, and leave the turn running on the existing handle —
the custom task text still reaches it, which is the larger half of the feature.
resolveSession.prHeadis only setfor pull requests (
dispatcher.ts:679-713) and both triage reasons are issue-only. If a triagereason is ever observed with
prHead !== null, do not mark the session — an implementer sessionmust keep
system_prompts.implementer.DELETE /sessions/:idroute leave a rowbehind, exactly as
plan_issue_sessionsdoes today. Harmless: the row is only ever read bysession id. No cleanup job.