Skip to content

Commit 18361db

Browse files
committed
feat: implement multi-step orchestrator pipeline
- Overhaul orchestrator.js to manage a multi-step PR state machine (DEV -> REVIEW -> HUMAN_REVIEW -> MERGE). - Integrate native GitHub PR UI human review approval gates. - Support auto-merge label to bypass human review. - Refactor opencode-post-step.js to correctly propagate agent refusal/triage states and override existing PR labels. - Update orchestrator.yml triggers for pull_request_review events and configure required PR write permissions. - Document the new phase transitions and state object schemas in AGENTS.md. - Expand jnode-issue-resolver skill taxonomy and protocols to support PR feedback loops natively. - Introduce unit test coverage for orchestrator and post-step scripts using Node's native test runner.
1 parent a3c355f commit 18361db

8 files changed

Lines changed: 648 additions & 106 deletions

File tree

.github/AGENTS.md

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,23 @@ CI infrastructure, agent automation, and label conventions for JNode.
3737
+--------------------+ /oc "Please proceed" +-------------------------+
3838
/orchestrate->| orchestrator (js) |------------------------->| next child task in queue |
3939
+--------------------+ +-------------------------+
40+
^ |
41+
pull_request_review| | /oc review | /oc fix
42+
| v
43+
+--------------------+
44+
| PR feedback |
45+
+--------------------+
4046
```
4147

4248
- **opencode** is the worker. It runs the agent once per trigger, posts a result, and exits.
43-
- **orchestrator** is the foreman. It holds a JSON state in the master issue body, picks the next child task from the queue, and triggers it by posting `/oc Please proceed with this task.` on the child issue.
44-
- `orchestrator.yml` listens for `workflow_run` from `opencode`. When a child finishes, the orchestrator advances the queue and triggers the next child via a comment (which loops back into opencode).
45-
- The trigger is one-way: orchestrator triggers opencode by comment; opencode does NOT call orchestrator.
49+
- **orchestrator** is the foreman. It holds a JSON state in the master issue body, picks the next child task from the queue, and tracks its phase (DEV, REVIEW, HUMAN_REVIEW, FEEDBACK, MERGE).
50+
- `orchestrator.yml` listens for `workflow_run` from `opencode` and `pull_request_review`. It advances the phase, loops back via `/oc fix` or `/oc review`, or merges the PR.
4651

47-
A child task is "complete" in the orchestrator's eyes when EITHER:
52+
For single-step tasks, a child task is "complete" in the orchestrator's eyes when EITHER:
4853
- the child issue is closed on GitHub, OR
4954
- the child has one of: `agent/done`, `agent/investigated`, `agent/skip`, `agent/blocked`, `agent/needs-info`.
5055

51-
The `agent/failed` label does NOT count as completion; it triggers a retry (max 3 attempts).
56+
For multi-step PR tasks, completion requires reaching the `MERGE` phase (or short-circuiting on skip labels).
5257

5358
## Orchestrator State Machine
5459

@@ -57,7 +62,7 @@ State lives in the master issue body as a hidden HTML comment:
5762
```html
5863
<!-- ORCHESTRATOR_STATE:
5964
{ "status": "IDLE|IN_PROGRESS|COMPLETED",
60-
"current_task": 487,
65+
"current_task": { "issue": 487, "pr": null, "phase": "DEV", "turn": 0, "max_turns": 3, "retries": 0 },
6166
"queue": [488, 489],
6267
"completed": [485, 486],
6368
"failed": [],
@@ -70,14 +75,22 @@ State lives in the master issue body as a hidden HTML comment:
7075
| Field | Meaning |
7176
|-------|---------|
7277
| `status` | IDLE (initial) / IN_PROGRESS (after first trigger) / COMPLETED (queue empty) |
73-
| `current_task` | Task the agent is working on; `null` when idle |
78+
| `current_task` | Object representing task state, or task number (for older one-shot tasks) |
7479
| `queue` | Pending task numbers, in execution order |
75-
| `completed` | Tasks that finished with a completion label or were closed |
76-
| `failed` | Tasks that hit 3 retries without success |
80+
| `completed` | Tasks that finished completely (merged, or short-circuited) |
81+
| `failed` | Tasks that hit 3 retries without success, or max_turns |
7782
| `retries` | Attempt counter for `current_task`; resets on advance |
7883
| `history` | Append-only event log with ISO timestamps |
7984
| `order` | Original task order from the markdown checklist; rendered top-to-bottom in the status table |
8085

86+
### Phases
87+
88+
- **DEV**: Initial agent run. Agent creates a PR. Transition to `REVIEW`.
89+
- **REVIEW**: Agent reviews the PR. If approved and no `auto-merge` label, transition to `HUMAN_REVIEW`. If `auto-merge`, transition to `MERGE`. If changes requested, transition to `FEEDBACK`.
90+
- **FEEDBACK**: Agent addresses review comments. Transition to `REVIEW`.
91+
- **HUMAN_REVIEW**: Orchestrator waits for native GitHub PR review from a human maintainer. Approval → `MERGE`, Request changes → `FEEDBACK`.
92+
- **MERGE**: Orchestrator squashes the PR and deletes the branch inline.
93+
8194
Initialization: on first run, the orchestrator parses the master issue's markdown checklist (`- [ ] #N`, `- [x] #N`, `- [FAIL] #N`) and builds initial state. Subsequent runs load from the hidden JSON block. If `state.order` is empty (older master), it is backfilled from status-grouped arrays.
8295

8396
Self-healing guard: if the orchestrator wakes up and the `current_task` is already complete (closed or has completion label), it advances immediately and exits. This handles the case where the agent finished but the orchestrator was locked out.
@@ -160,3 +173,11 @@ node .github/scripts/sync-labels.js
160173
```
161174

162175
The `opencode-post-step.js` and `orchestrator.js` modules export a single async function taking `{ github, context, core }`. They are normally invoked from `actions/github-script@v7` and not run standalone.
176+
177+
### Running tests
178+
179+
Unit tests for the `.github/scripts/` logic use the native `node:test` framework (requires Node.js v18+). They do not require any external dependencies.
180+
181+
```bash
182+
node --test .github/scripts/tests/
183+
```

.github/scripts/opencode-post-step.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,21 +56,21 @@ function decideAgentLabel({ existing, conclusion, latestComment, labels, isPR })
5656
if (conclusion === 'failure' || conclusion === 'cancelled') {
5757
return { label: 'agent/failed', reason: 'run concluded: ' + conclusion };
5858
}
59-
if (existing && existing !== 'agent/failed') {
60-
return { label: existing, reason: 'existing agent/* label respected' };
61-
}
6259
if (isRefusalComment(latestComment)) {
6360
return { label: 'agent/skip', reason: 'refusal detected in comment' };
6461
}
6562
if (isNeedsInfoComment(latestComment)) {
6663
return { label: 'agent/needs-info', reason: 'needs-info detected in comment' };
6764
}
68-
if (isPR) {
69-
return { label: 'agent/done', reason: 'PR context' };
70-
}
7165
if (isInvestigationReport(latestComment)) {
7266
return { label: 'agent/investigated', reason: 'investigation report heading detected (verb-override)' };
7367
}
68+
if (existing && existing !== 'agent/failed') {
69+
return { label: existing, reason: 'existing agent/* label respected' };
70+
}
71+
if (isPR) {
72+
return { label: 'agent/done', reason: 'PR context' };
73+
}
7474
if (isInvestigationKind(labels)) {
7575
return { label: 'agent/investigated', reason: 'investigation kind, comment absent' };
7676
}
@@ -127,6 +127,17 @@ module.exports = async ({ github, context, core }) => {
127127
});
128128
core.info('Decision: ' + decision.label + ' (' + decision.reason + ')');
129129

130+
if (existingAgent && existingAgent !== decision.label) {
131+
try {
132+
await github.rest.issues.removeLabel({
133+
owner, repo, issue_number: number, name: existingAgent,
134+
});
135+
core.info('Removed old label: ' + existingAgent);
136+
} catch (err) {
137+
core.warning('Failed to remove old label: ' + err.message);
138+
}
139+
}
140+
130141
try {
131142
await github.rest.issues.addLabels({
132143
owner, repo, issue_number: number, labels: [decision.label],

0 commit comments

Comments
 (0)