ranni-mcp 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/config.ts +8 -0
- package/src/index.ts +60 -11
- package/src/pr.ts +291 -0
- package/src/queue.ts +45 -5
- package/src/types.ts +24 -1
- package/templates/.agents.yaml +0 -17
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -4,6 +4,13 @@ import { parse } from 'yaml';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import type { Config } from './types.js';
|
|
6
6
|
|
|
7
|
+
const GitConfigSchema = z.object({
|
|
8
|
+
auto_pr: z.boolean().default(true),
|
|
9
|
+
branch_prefix: z.string().default('ranni'),
|
|
10
|
+
base_branch: z.string().default('main'),
|
|
11
|
+
await_merge: z.boolean().default(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
7
14
|
const ConfigSchema = z.object({
|
|
8
15
|
worker: z.object({
|
|
9
16
|
command: z.string(),
|
|
@@ -11,6 +18,7 @@ const ConfigSchema = z.object({
|
|
|
11
18
|
}),
|
|
12
19
|
max_workers: z.number().int().min(1).default(3),
|
|
13
20
|
persist_runs: z.boolean().default(false),
|
|
21
|
+
git: GitConfigSchema.optional(),
|
|
14
22
|
dirs: z.record(z.string()),
|
|
15
23
|
manager_context: z.string().optional()
|
|
16
24
|
})
|
package/src/index.ts
CHANGED
|
@@ -4,16 +4,20 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
4
4
|
import { loadConfig } from './config.js';
|
|
5
5
|
import { detectConflict, handleConflict } from './conflict.js';
|
|
6
6
|
import {
|
|
7
|
+
autoAcknowledge,
|
|
7
8
|
cancelTask,
|
|
8
9
|
complete,
|
|
9
10
|
enqueue,
|
|
10
11
|
getPendingResults,
|
|
11
12
|
getSnapshot,
|
|
13
|
+
markAwaitingReview,
|
|
12
14
|
queuePath,
|
|
13
15
|
readQueue,
|
|
14
16
|
resetInterrupted,
|
|
15
|
-
startNext
|
|
17
|
+
startNext,
|
|
18
|
+
writeQueue
|
|
16
19
|
} from './queue.js';
|
|
20
|
+
import { commitAndOpenPR, pollPRStatus, pushToPRBranch } from './pr.js';
|
|
17
21
|
import { initRun } from './runs.js';
|
|
18
22
|
import type { Config, Task } from './types.js';
|
|
19
23
|
import { runWorker } from './worker.js';
|
|
@@ -23,7 +27,8 @@ const DEFAULT_MANAGER_CONTEXT = `ORCHESTRATOR RULES (read before every action):
|
|
|
23
27
|
2. Each task must be self-contained: include all file paths, context, and background the worker needs.
|
|
24
28
|
3. Use depends_on when task B genuinely needs task A's output to be on disk first.
|
|
25
29
|
4. When a worker returns needs_help, surface the question to the user before dispatching a follow-up.
|
|
26
|
-
5. When a worker returns error, decide: retry, dispatch a corrected version, or inform the user
|
|
30
|
+
5. When a worker returns error, decide: retry, dispatch a corrected version, or inform the user.
|
|
31
|
+
6. Tasks in awaiting_review are waiting for their PR to be merged — do not cancel them.`
|
|
27
32
|
|
|
28
33
|
function formatFooter(managerContext: string): string {
|
|
29
34
|
return `\n\n---\n${managerContext}`
|
|
@@ -38,7 +43,7 @@ async function main() {
|
|
|
38
43
|
|
|
39
44
|
let activeCount = 0
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
function tickPool() {
|
|
42
47
|
const available = config.max_workers - activeCount
|
|
43
48
|
if (available <= 0) return
|
|
44
49
|
|
|
@@ -55,6 +60,17 @@ async function main() {
|
|
|
55
60
|
|
|
56
61
|
setInterval(tickPool, 200)
|
|
57
62
|
|
|
63
|
+
if (config.git?.auto_pr) {
|
|
64
|
+
setInterval(() => {
|
|
65
|
+
pollPRStatus(qPath, config, tasks => {
|
|
66
|
+
enqueue(qPath, tasks)
|
|
67
|
+
tickPool()
|
|
68
|
+
}).catch(err => {
|
|
69
|
+
process.stderr.write(`[ranni] Poll error: ${err}\n`)
|
|
70
|
+
})
|
|
71
|
+
}, 60_000)
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
const server = new Server({ name: 'ranni-mcp', version: '0.1.0' }, { capabilities: { tools: {} } })
|
|
59
75
|
|
|
60
76
|
const footer = formatFooter(config.manager_context ?? DEFAULT_MANAGER_CONTEXT)
|
|
@@ -105,7 +121,7 @@ async function main() {
|
|
|
105
121
|
},
|
|
106
122
|
{
|
|
107
123
|
name: 'list_active_workers',
|
|
108
|
-
description: 'Snapshot of the current queue: running workers, pending tasks, and
|
|
124
|
+
description: 'Snapshot of the current queue: running workers, pending tasks, and PRs awaiting review.',
|
|
109
125
|
inputSchema: { type: 'object', properties: {} }
|
|
110
126
|
},
|
|
111
127
|
{
|
|
@@ -176,18 +192,22 @@ async function main() {
|
|
|
176
192
|
case 'list_active_workers': {
|
|
177
193
|
const snapshot = getSnapshot(qPath)
|
|
178
194
|
const runningList = snapshot.running
|
|
179
|
-
.map(t => ` [running]
|
|
195
|
+
.map(t => ` [running] ${t.id} (${t.dir}) — started ${t.started_at}`)
|
|
180
196
|
.join('\n')
|
|
181
197
|
const queuedList = snapshot.queued
|
|
182
198
|
.map(
|
|
183
199
|
t =>
|
|
184
|
-
` [queued]
|
|
200
|
+
` [queued] ${t.id} (${t.dir})${t.depends_on?.length ? ` — waiting on: ${t.depends_on.join(', ')}` : ''}`
|
|
185
201
|
)
|
|
186
202
|
.join('\n')
|
|
203
|
+
const awaitingList = snapshot.awaiting
|
|
204
|
+
.map(t => ` [awaiting_review] ${t.id} (${t.dir}) — ${t.result?.pr_url ?? 'PR pending'}`)
|
|
205
|
+
.join('\n')
|
|
187
206
|
const lines = [
|
|
188
207
|
`Workers: ${activeCount}/${config.max_workers} active`,
|
|
189
208
|
runningList || ' (none running)',
|
|
190
|
-
queuedList || ' (queue empty)'
|
|
209
|
+
queuedList || ' (queue empty)',
|
|
210
|
+
awaitingList || ' (no PRs awaiting review)'
|
|
191
211
|
].join('\n')
|
|
192
212
|
return { content: [{ type: 'text', text: lines + footer }] }
|
|
193
213
|
}
|
|
@@ -207,7 +227,8 @@ async function main() {
|
|
|
207
227
|
const summary = r ? r.summary : '(no summary)'
|
|
208
228
|
const files = r?.files_changed?.length ? `\n Files: ${r.files_changed.join(', ')}` : ''
|
|
209
229
|
const msg = r?.message ? `\n Message: ${r.message}` : ''
|
|
210
|
-
|
|
230
|
+
const pr = r?.pr_url ? `\n PR: ${r.pr_url}` : ''
|
|
231
|
+
return `[${status.toUpperCase()}] ${t.id} (${t.dir})\n ${summary}${files}${msg}${pr}`
|
|
211
232
|
})
|
|
212
233
|
.join('\n\n')
|
|
213
234
|
|
|
@@ -247,13 +268,41 @@ async function runWorkerAsync(
|
|
|
247
268
|
|
|
248
269
|
runLogger?.updateSummary(readQueue(qPath).tasks)
|
|
249
270
|
|
|
250
|
-
const
|
|
251
|
-
|
|
271
|
+
const resolvedDir = config.dirs[task.dir]!
|
|
272
|
+
|
|
273
|
+
const conflict = detectConflict(result, readQueue(qPath), task.id)
|
|
252
274
|
if (conflict) {
|
|
253
|
-
const resolvedDir = config.dirs[task.dir]!
|
|
254
275
|
await handleConflict(qPath, task, conflict, resolvedDir)
|
|
255
276
|
}
|
|
256
277
|
|
|
278
|
+
if (task.parent_task_id && result.status === 'done') {
|
|
279
|
+
// Correction worker: push changes to the parent task's existing PR branch
|
|
280
|
+
const parentTask = readQueue(qPath).tasks.find(t => t.id === task.parent_task_id)
|
|
281
|
+
if (parentTask?.pr_branch) {
|
|
282
|
+
await pushToPRBranch(task, result, resolvedDir, parentTask.pr_branch)
|
|
283
|
+
}
|
|
284
|
+
autoAcknowledge(qPath, task.id)
|
|
285
|
+
} else if (!task.parent_task_id && result.status === 'done') {
|
|
286
|
+
// Normal worker: open a PR, then either babysit it or just record the URL
|
|
287
|
+
const pr = await commitAndOpenPR(task, result, config, resolvedDir)
|
|
288
|
+
if (pr) {
|
|
289
|
+
if (config.git?.await_merge) {
|
|
290
|
+
markAwaitingReview(qPath, task.id, pr.prUrl, pr.branch)
|
|
291
|
+
} else {
|
|
292
|
+
// PR created, task stays done — store url+branch and mark unmerged so
|
|
293
|
+
// depends_on chains wait for the poll to confirm the PR landed
|
|
294
|
+
const q = readQueue(qPath)
|
|
295
|
+
const t = q.tasks.find(x => x.id === task.id)
|
|
296
|
+
if (t) {
|
|
297
|
+
t.pr_branch = pr.branch
|
|
298
|
+
t.pr_merged = false
|
|
299
|
+
if (t.result) t.result.pr_url = pr.prUrl
|
|
300
|
+
writeQueue(qPath, q)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
257
306
|
if (runLogger) {
|
|
258
307
|
const stdout = result.message ?? result.summary
|
|
259
308
|
runLogger.logTask(task.id, stdout)
|
package/src/pr.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { execSync } from 'child_process'
|
|
2
|
+
import { relative } from 'path'
|
|
3
|
+
import { markPRMerged, readQueue, updatePrCommentCursor, writeQueue } from './queue.js'
|
|
4
|
+
import type { Config, Task, WorkerResult } from './types.js'
|
|
5
|
+
|
|
6
|
+
// Promise-chain mutex: only one git operation runs at a time to avoid index corruption
|
|
7
|
+
let gate: Promise<void> = Promise.resolve()
|
|
8
|
+
|
|
9
|
+
function run(cmd: string, cwd: string): string {
|
|
10
|
+
return execSync(cmd, { cwd, encoding: 'utf8' }).trim()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function findGitRoot(dir: string): string {
|
|
14
|
+
return run('git rev-parse --show-toplevel', dir)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function withGateLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
18
|
+
let release!: () => void
|
|
19
|
+
const next = new Promise<void>(r => { release = r })
|
|
20
|
+
const prev = gate
|
|
21
|
+
gate = next
|
|
22
|
+
return prev.then(fn).finally(release)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function gitPaths(resolvedDir: string, gitRoot: string, filesChanged: string[]): string[] {
|
|
26
|
+
const rel = relative(gitRoot, resolvedDir)
|
|
27
|
+
return filesChanged.map(f => (rel ? `${rel}/${f}` : f))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stashAndCheckout(gitRoot: string, label: string, branch: string): { origBranch: string; stashed: boolean } {
|
|
31
|
+
const origBranch = run('git rev-parse --abbrev-ref HEAD', gitRoot)
|
|
32
|
+
const stashOut = run(`git stash push --include-untracked -m "ranni-${label}"`, gitRoot)
|
|
33
|
+
const stashed = !stashOut.includes('No local changes to save')
|
|
34
|
+
run(`git checkout ${branch}`, gitRoot)
|
|
35
|
+
return { origBranch, stashed }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function restoreAndReturn(gitRoot: string, origBranch: string, stashed: boolean): void {
|
|
39
|
+
run(`git checkout ${origBranch}`, gitRoot)
|
|
40
|
+
if (stashed) run('git stash pop', gitRoot)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cherryPickFromStash(gitRoot: string, paths: string[]): void {
|
|
44
|
+
for (const p of paths) {
|
|
45
|
+
try {
|
|
46
|
+
run(`git checkout stash@{0} -- "${p}"`, gitRoot)
|
|
47
|
+
} catch {
|
|
48
|
+
// path may differ between resolvedDir and gitRoot — skip
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ──────────────────────────────────────────────
|
|
54
|
+
// Create branch + PR for a finished task
|
|
55
|
+
// ──────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export async function commitAndOpenPR(
|
|
58
|
+
task: Task,
|
|
59
|
+
result: WorkerResult,
|
|
60
|
+
config: Config,
|
|
61
|
+
resolvedDir: string
|
|
62
|
+
): Promise<{ prUrl: string; branch: string } | undefined> {
|
|
63
|
+
const gitCfg = config.git
|
|
64
|
+
if (!gitCfg?.auto_pr || result.status !== 'done' || result.files_changed.length === 0) {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
return withGateLock(() => doCommitAndPR(task, result, gitCfg, resolvedDir))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function doCommitAndPR(
|
|
71
|
+
task: Task,
|
|
72
|
+
result: WorkerResult,
|
|
73
|
+
gitCfg: NonNullable<Config['git']>,
|
|
74
|
+
resolvedDir: string
|
|
75
|
+
): Promise<{ prUrl: string; branch: string } | undefined> {
|
|
76
|
+
const branch = `${gitCfg.branch_prefix}/${task.id}`
|
|
77
|
+
|
|
78
|
+
let gitRoot: string
|
|
79
|
+
try {
|
|
80
|
+
gitRoot = findGitRoot(resolvedDir)
|
|
81
|
+
} catch {
|
|
82
|
+
process.stderr.write(`[ranni] PR skipped for ${task.id}: not in a git repository\n`)
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const paths = gitPaths(resolvedDir, gitRoot, result.files_changed)
|
|
87
|
+
let origBranch = gitCfg.base_branch
|
|
88
|
+
let stashed = false
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
origBranch = run('git rev-parse --abbrev-ref HEAD', gitRoot)
|
|
92
|
+
|
|
93
|
+
const stashOut = run(`git stash push --include-untracked -m "ranni-temp-${task.id}"`, gitRoot)
|
|
94
|
+
stashed = !stashOut.includes('No local changes to save')
|
|
95
|
+
|
|
96
|
+
run(`git fetch origin ${gitCfg.base_branch} --quiet`, gitRoot)
|
|
97
|
+
run(`git checkout -b ${branch} origin/${gitCfg.base_branch}`, gitRoot)
|
|
98
|
+
|
|
99
|
+
if (stashed) cherryPickFromStash(gitRoot, paths)
|
|
100
|
+
|
|
101
|
+
run(`git add -- ${paths.map(p => `"${p}"`).join(' ')}`, gitRoot)
|
|
102
|
+
run(`git commit -m ${JSON.stringify(`${result.summary}\n\nTask: ${task.id}`)}`, gitRoot)
|
|
103
|
+
run(`git push -u origin ${branch}`, gitRoot)
|
|
104
|
+
|
|
105
|
+
restoreAndReturn(gitRoot, origBranch, stashed)
|
|
106
|
+
|
|
107
|
+
const prBody = buildPrBody(task, result)
|
|
108
|
+
const prUrl = run(
|
|
109
|
+
`gh pr create --title ${JSON.stringify(result.summary)} --body ${JSON.stringify(prBody)} --base ${gitCfg.base_branch} --head ${branch}`,
|
|
110
|
+
gitRoot
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
process.stderr.write(`[ranni] PR opened for ${task.id}: ${prUrl}\n`)
|
|
114
|
+
return { prUrl, branch }
|
|
115
|
+
} catch (err) {
|
|
116
|
+
process.stderr.write(`[ranni] PR creation failed for ${task.id}: ${err}\n`)
|
|
117
|
+
try { restoreAndReturn(gitRoot!, origBranch, stashed) } catch {}
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ──────────────────────────────────────────────
|
|
123
|
+
// Push a correction worker's changes to an existing PR branch
|
|
124
|
+
// ──────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export async function pushToPRBranch(
|
|
127
|
+
task: Task,
|
|
128
|
+
result: WorkerResult,
|
|
129
|
+
resolvedDir: string,
|
|
130
|
+
prBranch: string
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
return withGateLock(() => doPushToPRBranch(task, result, resolvedDir, prBranch))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function doPushToPRBranch(
|
|
136
|
+
task: Task,
|
|
137
|
+
result: WorkerResult,
|
|
138
|
+
resolvedDir: string,
|
|
139
|
+
prBranch: string
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
let gitRoot: string
|
|
142
|
+
try {
|
|
143
|
+
gitRoot = findGitRoot(resolvedDir)
|
|
144
|
+
} catch {
|
|
145
|
+
process.stderr.write(`[ranni] Push skipped for ${task.id}: not in a git repository\n`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const paths = gitPaths(resolvedDir, gitRoot, result.files_changed)
|
|
150
|
+
let origBranch = prBranch
|
|
151
|
+
let stashed = false
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
;({ origBranch, stashed } = stashAndCheckout(gitRoot, `correction-${task.id}`, prBranch))
|
|
155
|
+
|
|
156
|
+
if (stashed) cherryPickFromStash(gitRoot, paths)
|
|
157
|
+
|
|
158
|
+
run(`git add -- ${paths.map(p => `"${p}"`).join(' ')}`, gitRoot)
|
|
159
|
+
run(`git commit -m "Address review feedback (task ${task.id})"`, gitRoot)
|
|
160
|
+
run(`git push origin ${prBranch}`, gitRoot)
|
|
161
|
+
|
|
162
|
+
restoreAndReturn(gitRoot, origBranch, stashed)
|
|
163
|
+
process.stderr.write(`[ranni] Pushed correction for ${task.id} → ${prBranch}\n`)
|
|
164
|
+
} catch (err) {
|
|
165
|
+
process.stderr.write(`[ranni] Push correction failed for ${task.id}: ${err}\n`)
|
|
166
|
+
try { restoreAndReturn(gitRoot!, origBranch, stashed) } catch {}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ──────────────────────────────────────────────
|
|
171
|
+
// Poll open PRs: merge detection (always) + comment relay (await_merge only)
|
|
172
|
+
// ──────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
type CorrectionTask = Omit<Task, 'status' | 'created_at' | 'started_at' | 'finished_at' | 'result' | 'acknowledged'>
|
|
175
|
+
|
|
176
|
+
export async function pollPRStatus(
|
|
177
|
+
qPath: string,
|
|
178
|
+
config: Config,
|
|
179
|
+
dispatch: (tasks: CorrectionTask[]) => void
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
const queue = readQueue(qPath)
|
|
182
|
+
const awaitMerge = config.git?.await_merge ?? false
|
|
183
|
+
|
|
184
|
+
// Track all tasks that have an open PR not yet confirmed merged
|
|
185
|
+
const tracked = queue.tasks.filter(
|
|
186
|
+
t => t.pr_branch && t.pr_merged !== true && (t.status === 'done' || t.status === 'awaiting_review')
|
|
187
|
+
)
|
|
188
|
+
if (tracked.length === 0) return
|
|
189
|
+
|
|
190
|
+
for (const task of tracked) {
|
|
191
|
+
const prUrl = task.result?.pr_url
|
|
192
|
+
if (!prUrl) continue
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const resolvedDir = config.dirs[task.dir]
|
|
196
|
+
if (!resolvedDir) continue
|
|
197
|
+
|
|
198
|
+
let gitRoot: string
|
|
199
|
+
try {
|
|
200
|
+
gitRoot = findGitRoot(resolvedDir)
|
|
201
|
+
} catch {
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fetch comments only when babysitting an awaiting_review task
|
|
206
|
+
const needComments = awaitMerge && task.status === 'awaiting_review'
|
|
207
|
+
const fields = needComments ? 'state,comments' : 'state'
|
|
208
|
+
const raw = run(`gh pr view "${prUrl}" --json ${fields}`, gitRoot)
|
|
209
|
+
const data = JSON.parse(raw) as {
|
|
210
|
+
state: string
|
|
211
|
+
comments?: Array<{ body: string; createdAt: string; author: { login: string } }>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (data.state === 'MERGED') {
|
|
215
|
+
// Works for both done and awaiting_review — markPRMerged handles both
|
|
216
|
+
markPRMerged(qPath, task.id)
|
|
217
|
+
process.stderr.write(`[ranni] PR merged — ${task.id}\n`)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (data.state === 'CLOSED' && task.status === 'awaiting_review') {
|
|
222
|
+
const q = readQueue(qPath)
|
|
223
|
+
const t = q.tasks.find(x => x.id === task.id)
|
|
224
|
+
if (t) {
|
|
225
|
+
t.status = 'error'
|
|
226
|
+
if (t.result) t.result.message = 'PR closed without merging'
|
|
227
|
+
writeQueue(qPath, q)
|
|
228
|
+
}
|
|
229
|
+
process.stderr.write(`[ranni] PR closed without merge — ${task.id}\n`)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Comment relay only when babysitting
|
|
234
|
+
if (!needComments) continue
|
|
235
|
+
|
|
236
|
+
const cursor = task.pr_comment_cursor ?? '1970-01-01T00:00:00Z'
|
|
237
|
+
const newComments = (data.comments ?? []).filter(c => c.createdAt > cursor)
|
|
238
|
+
if (newComments.length === 0) continue
|
|
239
|
+
|
|
240
|
+
const latestTime = newComments.reduce((m, c) => (c.createdAt > m ? c.createdAt : m), cursor)
|
|
241
|
+
updatePrCommentCursor(qPath, task.id, latestTime)
|
|
242
|
+
|
|
243
|
+
const existingCorrections = queue.tasks.filter(t => t.parent_task_id === task.id).length
|
|
244
|
+
const correctionId = `${task.id}-correction-${existingCorrections + 1}`
|
|
245
|
+
const commentsText = newComments.map(c => `**${c.author.login}**: ${c.body}`).join('\n\n')
|
|
246
|
+
|
|
247
|
+
dispatch([{
|
|
248
|
+
id: correctionId,
|
|
249
|
+
dir: task.dir,
|
|
250
|
+
task: buildCorrectionPrompt(task, commentsText),
|
|
251
|
+
context: task.context,
|
|
252
|
+
parent_task_id: task.id
|
|
253
|
+
}])
|
|
254
|
+
|
|
255
|
+
process.stderr.write(`[ranni] ${newComments.length} new comment(s) on ${task.id} — dispatching ${correctionId}\n`)
|
|
256
|
+
} catch (err) {
|
|
257
|
+
process.stderr.write(`[ranni] Poll error for ${task.id}: ${err}\n`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ──────────────────────────────────────────────
|
|
263
|
+
// Helpers
|
|
264
|
+
// ──────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
function buildCorrectionPrompt(task: Task, commentsText: string): string {
|
|
267
|
+
const prUrl = task.result?.pr_url ?? '(see context)'
|
|
268
|
+
return `You are addressing reviewer feedback on an open pull request.
|
|
269
|
+
|
|
270
|
+
PR: ${prUrl}
|
|
271
|
+
|
|
272
|
+
## Reviewer Comments
|
|
273
|
+
${commentsText}
|
|
274
|
+
|
|
275
|
+
## Original Task
|
|
276
|
+
${task.task}
|
|
277
|
+
|
|
278
|
+
Apply only the changes needed to satisfy the reviewer's comments. Do not commit, push, or open a new PR — the orchestrator handles that.`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function buildPrBody(task: Task, result: WorkerResult): string {
|
|
282
|
+
const sections: string[] = []
|
|
283
|
+
sections.push(`## Task\n${task.task}`)
|
|
284
|
+
if (task.context) sections.push(`## Context\n${task.context}`)
|
|
285
|
+
if (result.files_changed.length) {
|
|
286
|
+
sections.push(`## Files Changed\n${result.files_changed.map(f => `- \`${f}\``).join('\n')}`)
|
|
287
|
+
}
|
|
288
|
+
if (result.message) sections.push(`## Notes\n${result.message}`)
|
|
289
|
+
sections.push(`_Generated by [ranni](https://github.com/filfp/ranni) · task \`${task.id}\`_`)
|
|
290
|
+
return sections.join('\n\n')
|
|
291
|
+
}
|
package/src/queue.ts
CHANGED
|
@@ -43,7 +43,12 @@ export function enqueue(
|
|
|
43
43
|
|
|
44
44
|
export function startNext(path: string): Task | null {
|
|
45
45
|
const queue = readQueue(path)
|
|
46
|
-
|
|
46
|
+
// A done task with a PR only satisfies depends_on after the PR lands in base_branch
|
|
47
|
+
const doneIds = new Set(
|
|
48
|
+
queue.tasks
|
|
49
|
+
.filter(t => t.status === 'done' && (!t.pr_branch || t.pr_merged === true))
|
|
50
|
+
.map(t => t.id)
|
|
51
|
+
)
|
|
47
52
|
|
|
48
53
|
const idx = queue.tasks.findIndex(t => {
|
|
49
54
|
if (t.status !== 'pending') return false
|
|
@@ -85,6 +90,43 @@ export function markConflict(path: string, id: string): void {
|
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
export function markAwaitingReview(path: string, id: string, prUrl: string, branch: string): void {
|
|
94
|
+
const queue = readQueue(path)
|
|
95
|
+
const task = queue.tasks.find(t => t.id === id)
|
|
96
|
+
if (!task) return
|
|
97
|
+
task.status = 'awaiting_review'
|
|
98
|
+
task.pr_branch = branch
|
|
99
|
+
task.pr_merged = false
|
|
100
|
+
if (task.result) task.result.pr_url = prUrl
|
|
101
|
+
writeQueue(path, queue)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Called when the PR lands. Moves awaiting_review → done; for already-done tasks just sets the flag.
|
|
105
|
+
export function markPRMerged(path: string, id: string): void {
|
|
106
|
+
const queue = readQueue(path)
|
|
107
|
+
const task = queue.tasks.find(t => t.id === id)
|
|
108
|
+
if (!task) return
|
|
109
|
+
task.pr_merged = true
|
|
110
|
+
if (task.status === 'awaiting_review') task.status = 'done'
|
|
111
|
+
writeQueue(path, queue)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function updatePrCommentCursor(path: string, id: string, cursor: string): void {
|
|
115
|
+
const queue = readQueue(path)
|
|
116
|
+
const task = queue.tasks.find(t => t.id === id)
|
|
117
|
+
if (!task) return
|
|
118
|
+
task.pr_comment_cursor = cursor
|
|
119
|
+
writeQueue(path, queue)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function autoAcknowledge(path: string, id: string): void {
|
|
123
|
+
const queue = readQueue(path)
|
|
124
|
+
const task = queue.tasks.find(t => t.id === id)
|
|
125
|
+
if (!task) return
|
|
126
|
+
task.acknowledged = true
|
|
127
|
+
writeQueue(path, queue)
|
|
128
|
+
}
|
|
129
|
+
|
|
88
130
|
export function resetInterrupted(path: string): void {
|
|
89
131
|
const queue = readQueue(path)
|
|
90
132
|
let changed = false
|
|
@@ -126,14 +168,12 @@ export function getPendingResults(path: string, drain: boolean): Task[] {
|
|
|
126
168
|
export function getSnapshot(path: string): {
|
|
127
169
|
running: Task[]
|
|
128
170
|
queued: Task[]
|
|
129
|
-
|
|
130
|
-
max_workers: number
|
|
171
|
+
awaiting: Task[]
|
|
131
172
|
} {
|
|
132
173
|
const queue = readQueue(path)
|
|
133
174
|
return {
|
|
134
175
|
running: queue.tasks.filter(t => t.status === 'running'),
|
|
135
176
|
queued: queue.tasks.filter(t => t.status === 'pending'),
|
|
136
|
-
|
|
137
|
-
max_workers: 0
|
|
177
|
+
awaiting: queue.tasks.filter(t => t.status === 'awaiting_review')
|
|
138
178
|
}
|
|
139
179
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
export type TaskStatus =
|
|
1
|
+
export type TaskStatus =
|
|
2
|
+
| 'pending'
|
|
3
|
+
| 'running'
|
|
4
|
+
| 'done'
|
|
5
|
+
| 'done_with_conflict'
|
|
6
|
+
| 'needs_help'
|
|
7
|
+
| 'error'
|
|
8
|
+
| 'cancelled'
|
|
9
|
+
| 'awaiting_review'
|
|
2
10
|
|
|
3
11
|
export type Task = {
|
|
4
12
|
id: string
|
|
@@ -14,6 +22,12 @@ export type Task = {
|
|
|
14
22
|
finished_at: string | null
|
|
15
23
|
result: WorkerResult | null
|
|
16
24
|
acknowledged: boolean
|
|
25
|
+
// PR tracking fields (set when auto_pr creates a branch)
|
|
26
|
+
pr_branch?: string
|
|
27
|
+
pr_merged?: boolean // true once the PR lands in base_branch; gates depends_on satisfaction
|
|
28
|
+
pr_comment_cursor?: string // ISO timestamp — comments after this are unread (await_merge only)
|
|
29
|
+
// Set on correction workers dispatched in response to review comments
|
|
30
|
+
parent_task_id?: string
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
export type WorkerResult = {
|
|
@@ -22,6 +36,7 @@ export type WorkerResult = {
|
|
|
22
36
|
files_changed: string[]
|
|
23
37
|
message?: string
|
|
24
38
|
exit_code: number
|
|
39
|
+
pr_url?: string
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
export type QueueFile = {
|
|
@@ -29,10 +44,18 @@ export type QueueFile = {
|
|
|
29
44
|
touched_files: Record<string, string>
|
|
30
45
|
}
|
|
31
46
|
|
|
47
|
+
export type GitConfig = {
|
|
48
|
+
auto_pr: boolean
|
|
49
|
+
branch_prefix: string
|
|
50
|
+
base_branch: string
|
|
51
|
+
await_merge: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
export type Config = {
|
|
33
55
|
worker: { command: string; args: string[] }
|
|
34
56
|
max_workers: number
|
|
35
57
|
persist_runs: boolean
|
|
58
|
+
git?: GitConfig
|
|
36
59
|
dirs: Record<string, string>
|
|
37
60
|
manager_context?: string
|
|
38
61
|
}
|
package/templates/.agents.yaml
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
worker:
|
|
2
|
-
command: claude
|
|
3
|
-
args: [--print, --dangerously-skip-permissions]
|
|
4
|
-
|
|
5
|
-
max_workers: 3
|
|
6
|
-
persist_runs: false
|
|
7
|
-
|
|
8
|
-
dirs:
|
|
9
|
-
root: .
|
|
10
|
-
# Add your project directories:
|
|
11
|
-
# backend: ./backend
|
|
12
|
-
# web: ./apps/web
|
|
13
|
-
# mobile: ./apps/mobile
|
|
14
|
-
|
|
15
|
-
# Optional — injected into every MCP tool response as a reminder to the manager
|
|
16
|
-
# manager_context: |
|
|
17
|
-
# Always call get_pending_results() before dispatching new tasks.
|