Skip to content

Agent Issue Pipeline

opencode-agent[bot] edited this page Oct 4, 2026 · 2 revisions

Agent Issue Pipeline

JNode's hands-off issue automation: receptionist triage, a per-ticket DEV/REVIEW/FEEDBACK loop, evidence-gated label and closure logic, and CI self-healing.

Overview

JNode runs a mostly autonomous pipeline from "issue opened" to "PR merged, issue closed". Five GitHub Actions workflows plus four JavaScript state machines cooperate through issue labels, hidden HTML state comments, and slash-command issue comments.

issue opened
  -> auto-triage.yml   posts "/oc triage issue #N"                    [issues: opened]
  -> triage.yml        Triage runs, read-only checkout, posts report   ["/oc triage" comment]
  -> ticket-runner.js  auto-starts DEV                                 [Triage workflow_run]
  -> opencode.yml      agent works the ticket, opens a PR              ["/oc Please proceed…"]
  -> ticket-runner.js  DEV -> REVIEW, posts "/oc review" on the PR    [opencode workflow_run]
  -> agent review      "Verdict: approve" | "Verdict: request-changes"
       request-changes -> FEEDBACK -> "/oc fix Address review feedback." -> REVIEW
       approve + !auto-merge-eligible -> HUMAN_REVIEW (native GitHub PR Review UI)
       approve + eligible + safe diff + green CI -> MERGE -> DONE + close
  -> ant.yml Java CI   green re-reviews a deferred PR; red posts CI-heal
  -> wiki-update.yml   weekly documentation pass

orchestrator.yml / orchestrator.js is a parallel driver for batched multi-task master issues; it uses the same phases and the same gate in orchestrator-helpers.js.

Key Components

File Role
.github/workflows/auto-triage.yml Receptionist. Posts the triage request; re-triages on reporter replies.
.github/workflows/triage.yml Triage runner. Read-only, no checkout writes, no branches, no PRs.
.github/workflows/ticket-runner.yml Ticket Runner. Single job calling ticket-runner.js.
.github/workflows/orchestrator.yml Task Orchestrator. Batch foreman for kind/orchestrator master issues.
.github/workflows/opencode.yml The build agent itself: ISO build + QEMU, then the OpenCode agent.
.github/scripts/ticket-runner.js Per-ticket state machine (DEV/REVIEW/FEEDBACK/HUMAN_REVIEW/MERGE/DONE/FAILED).
.github/scripts/orchestrator.js Batch state machine over a master issue's queue/current_task.
.github/scripts/orchestrator-helpers.js Shared REST helpers: trigger, verdict parsing, merge-safety gate, mergePR.
.github/scripts/opencode-post-step.js Turns the agent's report into an agent/* label and (sometimes) a close.
.github/scripts/sync-labels.js Label bootstrap CLI; owns the PRESERVE set.
.github/scripts/tests/*.test.js 105 node:test unit tests for the three state machines.
.github/AGENTS.md 274-line spec of the pipeline: triggers, labels, decision tree, state format.
opencode.json Repo-level OpenCode config; grants the agent allow on /tmp/* external-directory access.
.opencode/skills/jnode-triage-issue/SKILL.md v0.2.0 triage behaviour contract.
.opencode/skills/jnode-issue-resolver/SKILL.md v0.1.0 worker behaviour contract.

opencode.json — Scratch Space

Added 2026-10-03 (a373767) so the agent can use /tmp for scratch files without a per-run prompt:

{
  "$schema": "https://opencode.ai/config.json",
  "permission": {
    "external_directory": {
      "/tmp/*": "allow"
    }
  }
}

The pattern matters: it is "/tmp/*", so only paths under /tmp are allowed. /tmp itself and /etc are still gated. This is what makes the filesystem-debug and jnode-interact skills able to stage repro scripts and serial logs.

Labels

Three namespaces plus two bare labels. All are bootstrapped by sync-labels.js (which also PRESERVEs orchestrator-internal labels).

Namespace Values
kind/* (blue #1d76db) bug, feature, investigate, wiki, review, chore, question, triage, test, plus orchestrator (not synced)
agent/* (grey #cccccc) in-progress, needs-info, blocked, done, failed, skip, duplicate, investigated
area/* (green #0e8a16) core, fs, net, shell, gui, builder, docs, build, vm, test
bare auto-merge (#0e8a16), no-auto (#d73a4a)
internal orchestrator/locked

Constants in the scripts:

Constant Location Value
ACTIONABLE_KINDS ticket-runner.js:114 kind/bug, kind/feature, kind/chore, kind/wiki, kind/test
AUTO_BLOCKING_LABELS ticket-runner.js:115-116 all 8 agent/* labels
SAFE_AUTO_MERGE_KINDS orchestrator-helpers.js:69 kind/chore, kind/wiki, kind/test
COMPLETION_LABELS ticket-runner.js:210-213 agent/done, agent/investigated, agent/skip, agent/blocked, agent/needs-info, agent/duplicate
SHORT_CIRCUIT_LABELS ticket-runner.js:216-219 the same minus agent/done
CLOSE_KINDS opencode-post-step.js:16 kind/investigate, kind/question
AGENT_COMPLETION_RE opencode-post-step.js:15 /^agent\/(done|investigated|skip|needs-info|blocked|failed)$/

Slash Commands

Command Consumer Meaning
/oc triage [issue #N] triage.yml only (excluded from opencode.yml) read-only classification
/oc Please proceed with this task. opencode.yml bot-of-bots signature; starts the DEV phase
/oc review opencode.yml code review; final line must be Verdict: approve or Verdict: request-changes
/oc fix [text] opencode.yml code fix; also the FEEDBACK phase trigger
/oc investigate, /oc explain opencode.yml investigation kinds
/oc wiki opencode.yml run the update-wiki skill
/oc chore, /oc test opencode.yml chore / test kinds
/run, /run --turns N, /run --max-turns N, /run --reset, /run --fresh ticket-runner.yml per-ticket state machine
/orchestrate orchestrator.yml batch foreman

Routing is case-sensitive; the verb is the first non-whitespace token after /oc . A bare /oc is not a trigger — opencode.yml requires the trailing space. /oc triage is a hard STOP inside the resolver skill, so a triage run can never fall through into a build run.

Triage (triage.yml)

  • run-name: "Triage #${{ github.event.issue.number }} - ${{ github.event.issue.title }}" — this exact string is what ticket-runner.js parses out of workflow_run.display_title via /Triage #(\d+)/.
  • Trigger: issue_comment: types: [created] only. Concurrency triage-<n>, cancel-in-progress: false. timeout-minutes: 20.
  • Job if: not a PR, body starts with or contains /oc triage, does not contain opencode.ai/s/ (the agent's own session link — the self-retrigger guard), and author_association is COLLABORATOR/MEMBER/OWNER.
  • Permissions: contents: read, pull-requests: read, issues: write. No id-token, no contents: write — committing, pushing and opening PRs are structurally impossible.
  • Steps: checkout with persist-credentials: true → set user.name "opencode-agent" / user.email "opencode-agent@users.noreply.github.com" → gh repo clone LSantha/jnode_ai.wiki .wiki with secrets.GH_PAT → JDK 8 zulu → lock the checkout chmod -R a-w . then chmod -R u+w .git → run the agent → opencode-post-step.js (if: success() || failure() || cancelled()) → cleanup.
  • .git is deliberately left writable so the action and the cleanup step still function; everything else is read-only.
  • Deterministic prompt via with: prompt: — a single-line string, not a skill-discovery prompt. It mandates loading jnode-triage-issue, auditing kind/* and area/* labels first, exactly one report opening ## Triage, then the body addendum, and returning the report as the final response instead of calling gh issue comment.
  • Cleanup (if: always()): git checkout -q master first, then delete every opencode/* branch, then rm -rf /tmp/triage-* /tmp/oc-triage-*. Checking out master first is required because the lockdown can leave HEAD detached or on an agent branch.
  • No QEMU, no ISO build, no cache — unlike opencode.yml. JDK is present so git log / gh / jar inspection work, but building is explicitly banned by the skill.

Ticket Runner (ticket-runner.js)

State

Hidden comment <!-- TICKET_RUNNER_STATE: {...} --> matched by STATE_RE. initState(maxTurns) (:34) produces:

{ "phase": "DEV", "pr": null, "turn": 0, "max_turns": 3, "retries": 0,
  "review_in_progress": false,
  "started": "2026-09-06T22:00:00.000Z", "history": [] }

A rendered status block precedes it (renderStatusSection :59, CRLF-tolerant matcher STATUS_SECTION_RE :83) with rows Phase, Turn, Retries, PR, Review in progress, Started. Phase emoji: DEV 🔨, REVIEW 🔍, FEEDBACK 🔧, HUMAN_REVIEW 👤, MERGE 🚀, DONE ✅, FAILED ❌, fallback ⏳.

Triggers

on: issue_comment:[created], issues:[labeled], workflow_run:[opencode, Java CI, Triage]/[completed], pull_request_review:[submitted]. Concurrency ticket-runner-<issue|PR|run id> with cancel-in-progress: false. Permissions: issues: write, pull-requests: write, contents: write, actions: read. Checkout is the default branch.

The YAML gate accepts any kind/ label; the script narrows to ACTIONABLE_KINDS.

/run Handling (handleIssueComment :144)

  1. Body must be /run or start with /run , \n, \r — so /running does not match.
  2. author_association must be COLLABORATOR/MEMBER/OWNER.
  3. PR guard: /run is issues-only; it comments back pointing at /oc review / /oc fix.
  4. Orchestrator guard checkOrchestratorManaged (:235): own body has ORCHESTRATOR_STATE, or the issue is in the queue/current_task of an open kind/orchestrator master with status === "IN_PROGRESS".
  5. --turns N / --max-turns N → customMaxTurns (default 3).
  6. --reset / --fresh nulls the state. Otherwise an existing DONE/FAILED state is nulled ("Starting fresh").
  7. No state → initState, append status, update the issue, then h.triggerTask(issueNumber).
  8. Existing state in HUMAN_REVIEW → informational comment only, no re-trigger.
  9. Otherwise re-trigger on state.pr || issueNumber with phaseMessage(state): FEEDBACK → /oc fix Address review feedback., REVIEW → h.getReviewPrompt(), else /oc Please proceed with this task.

A manual /run bypasses every auto-start guard — no-auto, blocking agent/* labels, actionable kind, triage clarity, existing state. Only the PR and orchestrator guards still apply. This is the documented escape hatch.

Auto-start (autoStartGuards :279)

Fails, in order, on: fetch error, pull_request, no-auto, kind/orchestrator, own body has ORCHESTRATOR_STATE, state already present, any AUTO_BLOCKING_LABELS, no ACTIONABLE_KINDS match, orchestrator-managed.

triageStatus (:306) returns {present, clear, count, requested} from the comment history:

  • requested — any comment matching /(^|\s)\/oc\s+triage(?:\s|$)/i.
  • comments matching /(^|\s)\/(oc|run|orchestrate)(\s|$)/ are skipped so the slash command itself is never mistaken for a report.
  • count — comments matching /## .*Triage/i.
  • clear — evaluated on the last triage comment only: !VAGUE_RE.test(b) && !REFUSAL_RE.test(b), where VAGUE_RE = /needs more info from reporter|needs the following|suggested next:\s*needs-info/i and REFUSAL_RE = /refusal|out of scope/i.

handleIssuesLabeled (:341) reacts to a newly added actionable kind label:

  • triage requested but no report yet → wait, do not double-request.
  • no triage requested → post the full request text (which repeats the "Triage run ONLY … FORBIDDEN: git checkout -b, git push, gh pr create" clause).
  • triage not clear, or count > 3 → wait.
  • otherwise auto-start with initState(3) and history: {event: "auto_start", reason}.

maybeAutoStartAfterTriage (:370) does the same from the Triage workflow completion, requiring present && clear && count <= 3.

The PR-First DEV Guard (handleDevCompletion :627)

This is the most important ordering rule in the whole pipeline:

  1. findPRForIssueWithComment(issueNumber) (:607) — first h.findPRForIssue(n) (open PRs whose head ref is opencode/issue<n>-* or whose body matches Closes|Fixes|Resolves #n); fall back to scanning comments newest-first for /\b(?:Created|Opened) PR #(\d+)\b/i to survive API index lag.
  2. If a PR exists: unconditionally set pr, phase = "REVIEW", retries = 0, review_in_progress = true, then h.triggerTask(prNumber, h.getReviewPrompt()). This happens before the phaseFailed check and before any label inspection.
  3. Only when no PR exists does phaseFailed → retryOrFail, then SHORT_CIRCUIT_LABELS → DONE, then the agent/done branch (kind/feature/kind/bug with no PR → retry), then fallback retryOrFail.

A PR is the authoritative completion signal. A stale agent/needs-info or agent/skip cannot short-circuit a ticket whose agent already opened a PR, and even a failed run advances if a PR exists. orchestrator.js:488-521 mirrors this for case 'DEV'.

In-Flight PR Re-Review Guard

Two layers:

  1. handleJavaCICompletion (:415) only acts on conclusion === 'success', and requires st.phase === "REVIEW" and !st.review_in_progress. On eligibility it sets the flag, records {event: "ci_green_rereview"}, persists, then triggers the review. The orchestrator mirror is orchestrator.js:202.
  2. The flag is cleared only in handleReviewCompletion (:679), as its first statement — so it stays set for the whole duration of an opencode review run.

The flag is set on every /oc review schedule: handleDevCompletion:633, handleFeedbackCompletion:790, the CI-green path :438, and the manual orchestrator retrigger orchestrator.js:415-417.

Finding Severity and the Untracked-🟡 Rule

The resolver skill's review contract (.opencode/skills/jnode-issue-resolver/SKILL.md) uses three markers, and the 🟡 row changed on 2026-10-03 (c50a38d):

Marker Meaning Required action
🔴 Must-fix: bug, license violation, Java 1.6 violation, missing test request changes
🟡 Should-fix: correctness, test validity, maintainability, or a tracked follow-up request changes; a deferred 🟡 requires a follow-up issue created in the same review
🟢 Nit: typo, formatting, optional refactor comment, do not block

Previously 🟡 was explicitly "comment, do not block". It is now a blocking severity, with exactly one escape hatch: the review may proceed to approve only if a follow-up issue was created and linked in the summary. This is stated twice, in the skill's marker table and in its review-instructions paragraph, and mirrored in .github/AGENTS.md's REVIEW phase:

A review must not approve with an untracked 🟡 finding: request changes or create and link a follow-up issue.

The motivation is visible in the same week's commits: two latent NumberUtils production bugs (getSizeUnit("1KB") returning B, and toString(f, Integer.MAX_VALUE) overflowing an int) were discovered while writing tests, and documented in the commit message but deliberately not turned into assertions. Without the untracked-🟡 rule that is an approval with a known unfixed defect and no tracking. See Core-Test-Suite.

REVIEW, FEEDBACK, Merge (handleReviewCompletion :678, handleFeedbackCompletion :767)

REVIEW, FEEDBACK, Merge (handleReviewCompletion :678, handleFeedbackCompletion :767)

  • review_in_progress = false; phaseFailed → retryOrFail.
  • PR labels in SHORT_CIRCUIT_LABELS → DONE with review_short_circuit / feedback_short_circuit.
  • h.getAgentReviewVerdict(pr) returns 'approve', 'request-changes' or null.
    • approve + !isAutoMergeEligible → HUMAN_REVIEW, retries = 0, PR comment "Awaiting human approval via native GitHub PR Review UI."
    • approve + eligible + isDiffSafe + isCIGreen → MERGE, h.mergePR(pr), then DONE + merged + closeIssueWithLabel. On throw: merge_failed + a ⚠️ PR comment.
    • approve + eligible but diff unsafe or CI not green → stay in REVIEW with merge_deferred; CI completion will retry.
    • request-changes → turn += 1; over max_turns → FAILED + max_turns_exceeded + agent/failed; else FEEDBACK, retries = 0, trigger /oc fix Address review feedback.
    • null → retryOrFail ("no verdict found for PR #N. Retrying review.").

Turn and Retry Budgets

Budget Default Overridable On exceed
turn max_turns = 3 only by manual /run --turns N FAILED, {event:"max_turns_exceeded"}, agent/failed
retries 3 no FAILED, {event:"max_retries", phase}, agent/failed

--turns is ignored on the auto paths — autoInitRun hardcodes initState(3). Terminal DONE/FAILED states short-circuit every handler (handleWorkflowRun:503, handleJavaCICompletion:435).

HUMAN_REVIEW (handlePullRequestReview :513)

Skips bot reviewers and non-collaborator associations. Resolves the owning issue via findIssueByPR (:870, head ref ^opencode/issue(\d+)- or a closing keyword, then a scan of recently-updated open issues for st.pr === prNumber). Requires state.phase === "HUMAN_REVIEW".

  • approved → MERGE → mergePR → DONE → close.
  • changes_requested → turn += 1; over limit → FAILED; else FEEDBACK with /oc fix Address human review feedback.

Post-Step (opencode-post-step.js)

Runs in both opencode.yml and triage.yml, if: success() || failure() || cancelled(), with PREV_CONCLUSION = steps.run_agent.outcome. It reads context.payload.issue.number and pages through comments with per_page: 100.

Report Scanners

Function Test Meaning
isRefusalComment /^\s*##\s*(?:🤖\s*)?Refusal\b/im out-of-scope refusal heading
isNeedsInfoComment content match on the three needs-info literals "needs more info from reporter" / "needs the following" / "Suggested next: needs-info"
isTriageComment /## .*Triage/i any ## … Triage heading
isTriageClearComment triage ∧ ¬needs-info ∧ ¬refusal actionable triage
isTriggerComment /(^|\s)\/(oc|run|orchestrate)(\s|$)/ a slash command, never a report
isDuplicateSignal /duplicate-of-#\d+/i Suggested next: duplicate-of-#M
isPRCreationComment /\b(?:Created|Opened) PR #\d+\b/i PR work product
isInvestigationReport /## .*Investigation Report/i investigation work product

findLatestAgentComment(comments) (:66) walks backwards and returns the first body that is neither a trigger comment nor a non-report. Because the review prompt itself quotes both verdicts and the triage request text contains ## Triage, the trigger exclusion is what keeps the scanners from misfiring on instructions.

Label Decision Precedence (decideAgentLabel :76)

  1. refusal → agent/skip
  2. needs-info literals → agent/needs-info
  3. ## … Investigation Report → agent/investigated (verb-override; not kind-based)
  4. duplicate-of-#N → agent/duplicate
  5. Created|Opened PR #N → agent/done
  6. clear triage → {label: null, clearNeedsInfo: true, clearFailed: true}
  7. conclusion === 'failure' || 'cancelled' → agent/failed
  8. an existing agent/* label other than agent/failed is respected
  9. PR context → agent/done
  10. default → agent/done

Reports 1-6 are evaluated before the failure check, so a run that dies during action finalization after the comment landed still gets the report-derived label. Steps 3-5 are evidence gates: the kind label alone is never enough. The old isInvestigationKind(labels) default was removed; agent/investigated now requires the report heading and agent/duplicate requires the link.

A label === null (clear triage) result removes stale agent/needs-info and agent/failed if present, but only when actually present — untouched labels are not churned. agent/in-progress is always removed at the end.

Evidence-Gated Closure (shouldClose :110)

if (isPR) return false;                                  // never close a PR
if (agentLabel === 'agent/duplicate') return true;
if (agentLabel !== 'agent/investigated') return false;
if (isInvestigationReport(latestComment)) return true;    // the evidence
return CLOSE_KINDS.some(k => labels.includes(k));

Because decideAgentLabel can only produce agent/investigated through the report-heading branch, the CLOSE_KINDS fallback is effectively unreachable today — the label decision is already evidence-gated. If the issue is already closed, the close call is skipped with an info log.

Merge Safety Gate (orchestrator-helpers.js)

Auto-merge requires all of:

Gate Rule
isAutoMergeEligible(issue, pr) explicit auto-merge on the issue or PR, or implicit kind/chore/kind/wiki/kind/test
isDiffSafe(pr) ≤ 5 files, no path matching /(^|\/)(core\/src\/native\/x86\/|jnode\.properties$|all\/build\.xml$|all\/conf\/)/, and ≤ 600 added lines when every changed path matches /(^|\/)(src\/test\/|tests\/)/, otherwise ≤ 100; also false if the file list is empty or hits the 100-file pagination cap
isCIGreen(pr) checks.listForRef on the head SHA: at least one success, none in failure/cancelled/timed_out/action_required

Test-Only Diff Allowance

var FORBIDDEN_DIFF_RE = /(^|\/)(core\/src\/native\/x86\/|jnode\.properties$|all\/build\.xml$|all\/conf\/)/;
var TEST_PATH_RE     = /(^|\/)(src\/test\/|tests\/)/;                  // orchestrator-helpers.js:94

// ...
var additions = 0;
var testOnly = true;
for (var i = 0; i < list.length; i++) {
    var filename = list[i].filename || "";
    additions += list[i].additions || 0;
    if (FORBIDDEN_DIFF_RE.test(filename)) return false;
    if (!TEST_PATH_RE.test(filename)) testOnly = false;
}
return additions <= (testOnly ? 600 : 100);                            // :118

The allowance is all-or-nothing: testOnly starts true and is cleared by the first non-test path, so one production file collapses the whole diff back to the 100-line cap. TEST_PATH_RE matches the directory src/test/ or tests/, not the filename, so core/src/test/NumberUtilsTest.java qualifies and core/src/core/NumberUtils.java does not.

The cap was raised three times in one day as the suite-wiring issues (#489, #695, #696, #697) landed: 100 → 300 (950b022, after NumberUtilsTest's +244) → 400 (718680e) → 600 (842b6d7, after WaitTest/ViewMethodTest/DoubleTest/ForEachTest together added 506 lines). The file-count cap stays at 5 and the forbidden-path check stays unconditional, so ASM, jnode.properties, all/build.xml and all/conf/ can never ride the allowance.

.github/scripts/tests/ticket-runner.test.js has one sub-test pinning this triple:

await t.test("isDiffSafe: allows large test-only diffs but not production diffs", async (t) => {
    const hTest = makeHelpers({ files: [{ filename: "core/src/test/NumberUtilsTest.java", additions: 601 }] });
    assert.strictEqual(await hTest.isDiffSafe(99), false);
    const hTestOk = makeHelpers({ files: [{ filename: "core/src/test/NumberUtilsTest.java", additions: 600 }] });
    assert.strictEqual(await hTestOk.isDiffSafe(99), true);
    const hMixed = makeHelpers({ files: [
        { filename: "core/src/test/NumberUtilsTest.java", additions: 200 },
        { filename: "core/src/core/NumberUtils.java", additions: 1 }
    ] });
    assert.strictEqual(await hMixed.isDiffSafe(99), false);
});

Note that raising the cap requires editing three files in lockstep: the constant in orchestrator-helpers.js, the matching sentence in .github/AGENTS.md's REVIEW phase description, and the test boundaries. Getting that wrong is what makes the raise a three-commit sequence rather than one.

mergePR (:155-188) squash-merges and then verifies:

try { await github.rest.pulls.merge({ ..., merge_method: "squash" }); }
catch (err) {
  var message = String((err && err.message) || "");
  if (err && err.status === 204) core.info("Merge API returned 204; verifying PR state");
  else if (/Unexpected end of JSON input/i.test(message)) core.info("Merge API returned an empty body; verifying PR state");
  else throw err;
}
var merged = await github.rest.pulls.get({ ..., pull_number: prNumber });
if (!merged.data || merged.data.merged !== true)
  throw new Error("merge not confirmed; PR state=" + (merged.data && merged.data.state ? merged.data.state : "unknown"));
try { await github.rest.git.deleteRef({ ..., ref: "heads/" + pr.data.head.ref }); }
catch (e) { core.warning("Could not delete branch " + pr.data.head.ref + ": ": e.message); }

Octokit surfaces a successful squash merge as 204 or as Unexpected end of JSON input because there is no response body to parse. Both are downgraded from throws to "verify"; every other error still propagates. Confirmation is authoritative — the PR is re-fetched and must report merged === true, or the caller throws and the ticket records merge_failed rather than claiming success. Branch deletion happens only after confirmation and is best-effort.

getAgentReviewVerdict skips trigger comments with the same /^\/(oc|run|orchestrate)(\s|$)/ guard, and tolerates decorated verdicts (**Verdict**: approve) via /(?:\*{0,2}Verdict\*{0,2}:?\*{0,2}\s*:?\s*)approve\b/i.

CI Self-Healing

Two independent producers post the same marker-deduped comment, so at most one heal is ever attempted per SHA:

Producer Mechanism
ant.yml heal job needs: [build, test, boot32, boot64], if: failure() && github.event_name == 'pull_request', permissions issues: write + pull-requests: write, token secrets.GH_PAT || secrets.GITHUB_TOKEN
ticket-runner.js postCiFixOnce (:392) from handleJavaCICompletion, only when phase is REVIEW or FEEDBACK

Both compute marker = '<!-- CI-HEAL:' + sha.slice(0,7) + ' -->', skip when the PR carries no-auto or agent/in-progress, skip when any of the last 100 comments already contains the marker, and then post:

/oc fix CI failed for <sha7> (jobs: <failed | unknown>): <runUrl>
<!-- CI-HEAL:<sha7> -->

Read the failing check output, fix the code on this branch, and push. Do not open a new PR.

The heal job derives the failed job list from toJSON(needs). ticket-runner.js gets the same information implicitly by only running for REVIEW/FEEDBACK.

Running the Scripts Locally

node --test .github/scripts/tests/*.test.js      # 105 tests, node:test, Node 18+
node .github/scripts/sync-labels.js --dry-run

The three state machines export their internals for tests: _parseState, _initState, _serializeState, _replaceOrAppendState, _replaceOrAppendStatus, _renderStatusSection on ticket-runner.js.

Gotchas & Non-Obvious Behavior

  • A PR beats everything. The DEV handler checks for a PR before phaseFailed and before any agent/* label. Changing that order re-introduces the "ticket opened a PR but a stale agent/needs-info closed the issue" bug.
  • review_in_progress is the single re-review mutex. It is set on every /oc review schedule and cleared only in handleReviewCompletion. Without it, a green CI run and a completing review race into two concurrent reviews.
  • Merge confirmation is not optional. A 204 or an unparseable body means "check the PR", not "it worked". The regression tests assert that an unconfirmed merge must not be reported as success.
  • Slash commands are never reports. isTriggerComment is what stops the review prompt (which quotes both verdicts) and the triage request (which contains ## Triage) from being classified as agent output.
  • Refusal is matched by heading, not by prose. ^\s*##\s*(?:🤖\s*)?Refusal\b — a comment that merely says "out of scope" no longer sets agent/skip. Note the residual inconsistency: ticket-runner.js REFUSAL_RE = /refusal|out of scope/i is still unanchored, but it only marks a triage comment non-clear; it never sets a label.
  • --turns is a manual-only override. Auto-start always uses 3.
  • A manual /run is a deliberate bypass. It is the documented way to re-run a ticket the auto-guards would refuse.
  • Triage is read-only by permissions, not by prompt. contents: read and no id-token make a push impossible even if the agent tries; the prompt's "no branches, no commits, no PRs" is defence in depth.
  • .git must stay writable under lockdown. chmod -R a-w . && chmod -R u+w .git — without the second command the action and the branch cleanup cannot run.
  • Workflow run names are load-bearing. ticket-runner.js extracts the issue number from Triage #123 - title in display_title, so renaming the workflow breaks auto-start.
  • agent/in-progress is never added by a workflow. The resolver skill instructs the agent to add it via gh issue edit, but no workflow step sets it. .github/AGENTS.md:204 claims otherwise; the label is still consumed as a busy flag by auto-triage.yml, ant.yml heal, postCiFixOnce and the orchestrator.
  • CLOSE_KINDS is effectively dead code. It can only be reached after decideAgentLabel already returned agent/investigated, which itself requires the report heading.
  • Off-by-one across components. auto-triage.yml re-triages while triageCount < 3; ticket-runner.js blocks auto-start at count > 3. An issue that reaches 4 triage passes is never re-triaged and also cannot auto-start.
  • All text is US-ASCII. The only exception is the em dash (—, U+2014) in the opencode.yml run name. .github/AGENTS.md renders it as a plain hyphen.

Related Pages

  • GitHub-Actions-Workflows — Parent hub: every workflow file, trigger, and the CI/boot pipeline
  • Testing — The JUnit and boot verification that the ant.yml pipeline runs and that heal reacts to
  • Build-System — The cd-x86-combined-lite regression-tests build the CI and agent workflows execute
  • Code-Conventions — US-ASCII encoding rule enforced across the automation files

Clone this wiki locally