hilos-agent 0.1.14 → 0.1.16
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/agent-events.mjs +371 -0
- package/src/config.mjs +11 -0
- package/src/daemon.mjs +18 -0
- package/src/followup.mjs +161 -0
- package/src/handler.mjs +833 -62
- package/src/memory.mjs +71 -0
- package/src/progress-emitter.mjs +270 -0
- package/src/resume.mjs +143 -0
- package/src/review.mjs +237 -0
- package/src/run.mjs +16 -1
package/src/handler.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
-
import { rmSync } from "node:fs";
|
|
12
|
+
import { rmSync, mkdtempSync } from "node:fs";
|
|
13
|
+
import { hostname, tmpdir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import {
|
|
15
16
|
branchSlug,
|
|
@@ -21,8 +22,57 @@ import {
|
|
|
21
22
|
commitMessage,
|
|
22
23
|
prTitleBody,
|
|
23
24
|
mentionHandle,
|
|
25
|
+
detectPrContinuation,
|
|
24
26
|
} from "./daemon.mjs";
|
|
25
27
|
import { runCli, buildHeartbeat, ackText, oneLine } from "./cli.mjs";
|
|
28
|
+
import { makeStreamParser } from "./agent-events.mjs";
|
|
29
|
+
import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
|
|
30
|
+
import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
|
|
31
|
+
import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
|
|
32
|
+
import {
|
|
33
|
+
buildReviewPrompt,
|
|
34
|
+
parseReviewOutput,
|
|
35
|
+
reviewDedupeKey,
|
|
36
|
+
shouldReview,
|
|
37
|
+
} from "./review.mjs";
|
|
38
|
+
import { buildMemoryBlock } from "./memory.mjs";
|
|
39
|
+
|
|
40
|
+
/** PR number from a github pull-request URL, or null. */
|
|
41
|
+
function prNumberFromUrl(url) {
|
|
42
|
+
const m = /\/pull\/(\d+)/.exec(String(url || ""));
|
|
43
|
+
return m ? Number(m[1]) : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// How many times ONE reviewer will review the SAME PR before it stops — the
|
|
47
|
+
// ping-pong bound. Keyed by reviewDedupeKey(prUrl, reviewerId), counted in-process
|
|
48
|
+
// on the long-lived daemon so an author<->reviewer pair can't loop forever. A
|
|
49
|
+
// human re-triggering the review is the escape hatch past the cap.
|
|
50
|
+
const reviewRounds = new Map();
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
// Sentinel the router model emits (only when a thread already owns a run) to
|
|
54
|
+
// classify a follow-up: change | new-scope | ambiguous. Parsed out of the router
|
|
55
|
+
// output and stripped from the coding brief.
|
|
56
|
+
const FOLLOWUP_SIGNAL = "__FOLLOWUP__";
|
|
57
|
+
|
|
58
|
+
/** Pull the follow-up classification out of the router's output and strip the
|
|
59
|
+
* token from the text, so the signal never leaks into the coding brief / a chat
|
|
60
|
+
* reply. Returns { followupSignal, stripped }.
|
|
61
|
+
*
|
|
62
|
+
* Strip ONLY the `__FOLLOWUP__ <value>` token (plus any surrounding whitespace),
|
|
63
|
+
* NOT the whole line: if the model ever emits `__CODE__` and `__FOLLOWUP__` on the
|
|
64
|
+
* SAME line, removing the line would drop `__CODE__` too and misroute a real code
|
|
65
|
+
* run into a chat reply. Removing just the token preserves `__CODE__` while still
|
|
66
|
+
* keeping the signal out of the brief. */
|
|
67
|
+
export function extractFollowup(text) {
|
|
68
|
+
const m = /__FOLLOWUP__[:\s]+([a-z][a-z-]*)/i.exec(text);
|
|
69
|
+
const followupSignal = m ? normalizeSignal(m[1]) : null;
|
|
70
|
+
const stripped = text
|
|
71
|
+
.replace(/\s*__FOLLOWUP__(?:[:\s]+[a-z][a-z-]*)?/gi, "")
|
|
72
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
73
|
+
.trim();
|
|
74
|
+
return { followupSignal, stripped };
|
|
75
|
+
}
|
|
26
76
|
|
|
27
77
|
/** `@handle` for the person who asked, so the report tags them. */
|
|
28
78
|
function requesterTag(author) {
|
|
@@ -54,6 +104,18 @@ function defaultDeps() {
|
|
|
54
104
|
const url = (r.stdout || "").trim();
|
|
55
105
|
return r.status === 0 && url ? url : null;
|
|
56
106
|
},
|
|
107
|
+
// The head branch of an existing PR (by URL or number), so a "request
|
|
108
|
+
// changes" rework can check it out and update the SAME PR rather than
|
|
109
|
+
// opening a duplicate.
|
|
110
|
+
prHeadRef: (cwd, ref) => {
|
|
111
|
+
const r = spawnSync(
|
|
112
|
+
"gh",
|
|
113
|
+
["pr", "view", String(ref), "--json", "headRefName", "--jq", ".headRefName // empty"],
|
|
114
|
+
{ cwd, encoding: "utf8" },
|
|
115
|
+
);
|
|
116
|
+
const out = (r.stdout || "").trim();
|
|
117
|
+
return r.status === 0 && out ? out : null;
|
|
118
|
+
},
|
|
57
119
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
58
120
|
now: () => Date.now(),
|
|
59
121
|
};
|
|
@@ -101,15 +163,19 @@ async function linkPrFromUrl({ tool, channelId, url }) {
|
|
|
101
163
|
await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
|
|
102
164
|
}
|
|
103
165
|
|
|
104
|
-
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
|
|
166
|
+
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId }) {
|
|
105
167
|
const tag = requesterTag(requester);
|
|
106
168
|
const lead = tag ? `${tag} — ` : "";
|
|
169
|
+
// `parentId` here is the run's thread root: every outcome below is terminal, so
|
|
170
|
+
// it broadcasts back to the channel (visible in both thread and channel) — the
|
|
171
|
+
// hosted runner's reply_broadcast. A no-op when there's no thread root.
|
|
107
172
|
if (decision.kind === "approved") {
|
|
108
173
|
// Guard: nothing staged → don't push an empty branch and falsely report "shipped".
|
|
109
174
|
if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
|
|
110
175
|
await tool("post_message", {
|
|
111
176
|
channelId,
|
|
112
177
|
parentId,
|
|
178
|
+
broadcast: true,
|
|
113
179
|
body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
|
|
114
180
|
});
|
|
115
181
|
return { status: "nothing-staged", branch };
|
|
@@ -119,6 +185,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
119
185
|
await tool("post_message", {
|
|
120
186
|
channelId,
|
|
121
187
|
parentId,
|
|
188
|
+
broadcast: true,
|
|
122
189
|
body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
|
|
123
190
|
});
|
|
124
191
|
return { status: "commit-failed", branch };
|
|
@@ -128,25 +195,53 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
128
195
|
await tool("post_message", {
|
|
129
196
|
channelId,
|
|
130
197
|
parentId,
|
|
198
|
+
broadcast: true,
|
|
131
199
|
body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
|
|
132
200
|
});
|
|
133
201
|
return { status: "push-failed", branch };
|
|
134
202
|
}
|
|
135
203
|
const { title, body } = prTitleBody(task, branch);
|
|
136
|
-
|
|
137
|
-
|
|
204
|
+
// Continuing an existing PR (request-changes rework): the push already
|
|
205
|
+
// updated it — reuse its URL instead of opening a duplicate. Otherwise open
|
|
206
|
+
// a fresh PR.
|
|
207
|
+
const pr = existingPrUrl
|
|
208
|
+
? { ok: true, url: existingPrUrl }
|
|
209
|
+
: deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
|
|
210
|
+
// Seamless single card (0289): when a live status message was streaming this
|
|
211
|
+
// run, SETTLE the report onto it in place (broadcast keeps it channel-visible)
|
|
212
|
+
// so the one card cross-fades from live run → report — no separate progress
|
|
213
|
+
// message + separate report card. Without a status id (older server / status
|
|
214
|
+
// post failed) fall back to posting a fresh broadcast report as before.
|
|
215
|
+
const reportArgs = {
|
|
138
216
|
channelId,
|
|
139
|
-
parentId,
|
|
140
217
|
title: `Shipped: ${title}`,
|
|
141
218
|
summary:
|
|
142
219
|
pr.ok && pr.url
|
|
143
|
-
?
|
|
220
|
+
? existingPrUrl
|
|
221
|
+
? `${lead}pushed an update to \`${branch}\` and updated the pull request.`
|
|
222
|
+
: `${lead}pushed \`${branch}\` and opened a pull request.`
|
|
144
223
|
: `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
|
|
145
224
|
prUrl: pr.ok && pr.url ? pr.url : undefined,
|
|
146
225
|
caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
|
|
147
|
-
}
|
|
226
|
+
};
|
|
227
|
+
await tool(
|
|
228
|
+
"post_report",
|
|
229
|
+
settleId
|
|
230
|
+
? { ...reportArgs, messageId: settleId, broadcast: true }
|
|
231
|
+
: { ...reportArgs, parentId, broadcast: true },
|
|
232
|
+
);
|
|
148
233
|
// Work produced a PR → attach it to the channel so its live pill shows up.
|
|
149
234
|
await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
|
|
235
|
+
// Bind the PR to the thread's run (0279/0280) so a follow-up continues it.
|
|
236
|
+
// Best-effort; only when a run was recorded (caps.runs on this server).
|
|
237
|
+
if (runId) {
|
|
238
|
+
await tool("update_run", {
|
|
239
|
+
runId,
|
|
240
|
+
...(pr.ok && pr.url ? { prUrl: pr.url } : {}),
|
|
241
|
+
status: "awaiting_review",
|
|
242
|
+
...(settleId ? { reportMsgId: settleId } : {}),
|
|
243
|
+
}).catch(() => {});
|
|
244
|
+
}
|
|
150
245
|
return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
|
|
151
246
|
}
|
|
152
247
|
|
|
@@ -155,7 +250,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
155
250
|
deps.git(repoPath, ["clean", "-fd"]);
|
|
156
251
|
deps.git(repoPath, ["switch", "-"]);
|
|
157
252
|
deps.git(repoPath, ["branch", "-D", branch]);
|
|
158
|
-
await tool("post_message", { channelId, parentId, body: `Rejected — discarded \`${branch}\`.` });
|
|
253
|
+
await tool("post_message", { channelId, parentId, broadcast: true, body: `Rejected — discarded \`${branch}\`.` });
|
|
159
254
|
return { status: "discarded", branch };
|
|
160
255
|
}
|
|
161
256
|
|
|
@@ -163,6 +258,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
163
258
|
await tool("post_message", {
|
|
164
259
|
channelId,
|
|
165
260
|
parentId,
|
|
261
|
+
broadcast: true,
|
|
166
262
|
body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
|
|
167
263
|
});
|
|
168
264
|
return { status: "changes", branch, note: decision.note };
|
|
@@ -171,6 +267,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
171
267
|
await tool("post_message", {
|
|
172
268
|
channelId,
|
|
173
269
|
parentId,
|
|
270
|
+
broadcast: true,
|
|
174
271
|
body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
|
|
175
272
|
});
|
|
176
273
|
return { status: "timeout", branch };
|
|
@@ -183,7 +280,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
183
280
|
* push the branch it's on (no-op if already pushed), reuse the PR it opened or
|
|
184
281
|
* open one, and report it. Used only when NOT gated (bias-to-action).
|
|
185
282
|
*/
|
|
186
|
-
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
|
|
283
|
+
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId }) {
|
|
187
284
|
const tag = requesterTag(requester);
|
|
188
285
|
const lead = tag ? `${tag} — ` : "";
|
|
189
286
|
// The agent may have committed on the daemon's branch or switched to one of its
|
|
@@ -198,9 +295,11 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
198
295
|
prUrl = pr.ok && pr.url ? pr.url : null;
|
|
199
296
|
}
|
|
200
297
|
const { title } = prTitleBody(task, cur);
|
|
201
|
-
|
|
298
|
+
// Seamless single card (0289): settle onto the live status message when one was
|
|
299
|
+
// streaming this run (broadcast keeps it channel-visible); otherwise a fresh
|
|
300
|
+
// broadcast report, exactly as before.
|
|
301
|
+
const reportArgs = {
|
|
202
302
|
channelId,
|
|
203
|
-
parentId,
|
|
204
303
|
title: `Shipped: ${title}`,
|
|
205
304
|
summary: prUrl
|
|
206
305
|
? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
|
|
@@ -209,8 +308,23 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
209
308
|
: `${lead}the coding agent committed on \`${cur}\` locally, but pushing it failed.`,
|
|
210
309
|
prUrl: prUrl || undefined,
|
|
211
310
|
caveats: push.status === 0 ? [] : [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`],
|
|
212
|
-
}
|
|
311
|
+
};
|
|
312
|
+
await tool(
|
|
313
|
+
"post_report",
|
|
314
|
+
settleId
|
|
315
|
+
? { ...reportArgs, messageId: settleId, broadcast: true }
|
|
316
|
+
: { ...reportArgs, parentId, broadcast: true },
|
|
317
|
+
);
|
|
213
318
|
if (prUrl) await linkPrFromUrl({ tool, channelId, url: prUrl });
|
|
319
|
+
// Bind the PR to the thread's run (0279/0280). Best-effort; only when recorded.
|
|
320
|
+
if (runId) {
|
|
321
|
+
await tool("update_run", {
|
|
322
|
+
runId,
|
|
323
|
+
...(prUrl ? { prUrl } : {}),
|
|
324
|
+
status: "awaiting_review",
|
|
325
|
+
...(settleId ? { reportMsgId: settleId } : {}),
|
|
326
|
+
}).catch(() => {});
|
|
327
|
+
}
|
|
214
328
|
return { status: push.status === 0 ? "pushed" : "commit-local", branch: cur, prUrl };
|
|
215
329
|
}
|
|
216
330
|
|
|
@@ -238,9 +352,23 @@ const CODE_SIGNAL = "__CODE__";
|
|
|
238
352
|
* `error` is set when the model produced nothing (so the caller can be honest
|
|
239
353
|
* about a timeout vs a missing binary instead of inventing a reply).
|
|
240
354
|
*/
|
|
241
|
-
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal }) {
|
|
355
|
+
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false }) {
|
|
242
356
|
const cmd = cfg.chatCmd || cfg.codingCmd;
|
|
243
357
|
const parts = cmd.split(" ").filter(Boolean);
|
|
358
|
+
// When this thread already owns an OPEN pull request (a follow-up reply), the
|
|
359
|
+
// router ALSO reads whether the latest message continues that PR, wants a
|
|
360
|
+
// separate one, or is unclear — the primary signal for same-PR continuation
|
|
361
|
+
// (0281). resolveFollowupMode turns it into iterate/redirect/new; the cue list
|
|
362
|
+
// in followup.mjs is only the fallback when the model says nothing.
|
|
363
|
+
const followupBlock = hasActiveRun
|
|
364
|
+
? `\n\nThis message is a reply in a thread that ALREADY owns an OPEN pull request. If (and ` +
|
|
365
|
+
`only if) you emitted ${CODE_SIGNAL} above, ALSO output on its OWN line the token ` +
|
|
366
|
+
`${FOLLOWUP_SIGNAL} followed by exactly one of: change | new-scope | ambiguous — ` +
|
|
367
|
+
`"change" if the latest message continues or adjusts THAT pull request's work ("also make ` +
|
|
368
|
+
`it blue", "actually revert that", "tweak the copy"), "new-scope" if it EXPLICITLY asks for ` +
|
|
369
|
+
`a separate or new pull request, "ambiguous" otherwise. Default a plain follow-up to ` +
|
|
370
|
+
`"change". This line is metadata — keep it OUT of the imperative spec.`
|
|
371
|
+
: "";
|
|
244
372
|
const prompt =
|
|
245
373
|
`You are ${name}, a teammate in a team chat (hilos) connected to the git repository ` +
|
|
246
374
|
`${repoFullName}. Read the conversation and judge what the LATEST message wants from you.\n\n` +
|
|
@@ -253,7 +381,8 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
253
381
|
`that lists who reacted with each emoji.\n\n` +
|
|
254
382
|
`Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
|
|
255
383
|
`yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
|
|
256
|
-
`headings). When unsure, prefer to act (${CODE_SIGNAL}); this is a build channel
|
|
384
|
+
`headings). When unsure, prefer to act (${CODE_SIGNAL}); this is a build channel.` +
|
|
385
|
+
`${followupBlock}\n\n` +
|
|
257
386
|
`You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
|
|
258
387
|
`edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
|
|
259
388
|
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
@@ -265,8 +394,14 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
265
394
|
signal,
|
|
266
395
|
});
|
|
267
396
|
if (run.aborted || signal?.aborted) return { aborted: true };
|
|
268
|
-
const
|
|
269
|
-
if (!
|
|
397
|
+
const raw = (run.stdout || "").trim();
|
|
398
|
+
if (!raw) return { code: false, reply: null, error: run.error || new Error("no output") };
|
|
399
|
+
// Lift the follow-up classification out (and strip its line) before parsing the
|
|
400
|
+
// brief, so the signal never pollutes the coding spec or a chat reply. Only
|
|
401
|
+
// meaningful when a run is active; otherwise there's no token to find.
|
|
402
|
+
const { followupSignal, stripped: out } = hasActiveRun
|
|
403
|
+
? extractFollowup(raw)
|
|
404
|
+
: { followupSignal: null, stripped: raw };
|
|
270
405
|
// Accept the sentinel ANYWHERE, not just the first line — models sometimes add a
|
|
271
406
|
// line of preamble before it ("Got it, …\n__CODE__\n<brief>"). If it appears,
|
|
272
407
|
// it's a code run; the brief is whatever follows the sentinel's line and any
|
|
@@ -276,9 +411,9 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
276
411
|
if (idx >= 0) {
|
|
277
412
|
const after = out.slice(idx + CODE_SIGNAL.length);
|
|
278
413
|
const nl = after.indexOf("\n");
|
|
279
|
-
return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim() };
|
|
414
|
+
return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim(), followupSignal };
|
|
280
415
|
}
|
|
281
|
-
return { code: false, reply: out };
|
|
416
|
+
return { code: false, reply: out, followupSignal };
|
|
282
417
|
}
|
|
283
418
|
|
|
284
419
|
/**
|
|
@@ -485,6 +620,200 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
485
620
|
await deliver(body);
|
|
486
621
|
}
|
|
487
622
|
|
|
623
|
+
/** True when this mention is a request for THIS agent to review a PR (0286/0288). */
|
|
624
|
+
export function isReviewRequest(message) {
|
|
625
|
+
return (
|
|
626
|
+
message?.kind === "review-request" ||
|
|
627
|
+
Boolean(message?.metadata?.reviewRequest) ||
|
|
628
|
+
Boolean(message?.reviewRequest)
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The PR url a review-request/review points at, from the mention's reviewOf or the
|
|
633
|
+
* channel's linked PR — whichever is present. */
|
|
634
|
+
function reviewTargetPrUrl(message) {
|
|
635
|
+
return message?.reviewOf?.prUrl || message?.pr?.url || null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Flatten get_reviews output into a compact, task-ready findings list the author's
|
|
639
|
+
* coding agent can act on (the review MESSAGE body only carries the summary). Uses
|
|
640
|
+
* the MOST RECENT review so a re-review supersedes stale findings. Returns "" when
|
|
641
|
+
* there's nothing structured to add. */
|
|
642
|
+
export function formatReviewFindings(got) {
|
|
643
|
+
const reviews = Array.isArray(got?.reviews) ? got.reviews : [];
|
|
644
|
+
if (!reviews.length) return "";
|
|
645
|
+
const latest = reviews[reviews.length - 1]; // get_reviews returns oldest→newest
|
|
646
|
+
const lines = [];
|
|
647
|
+
if (latest.summary) lines.push(latest.summary.trim());
|
|
648
|
+
for (const f of Array.isArray(latest.findings) ? latest.findings : []) {
|
|
649
|
+
if (!f || !f.note) continue;
|
|
650
|
+
const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""} — ` : "";
|
|
651
|
+
lines.push(`- [${f.severity || "info"}] ${loc}${f.note}`);
|
|
652
|
+
}
|
|
653
|
+
return lines.join("\n").slice(0, 4000);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* EXECUTE a cross-agent review (WS3, 0288). A review-request lands in this agent's
|
|
658
|
+
* mentions; here it actually reads the PR's diff and posts an ADVISORY review that
|
|
659
|
+
* @-tags the author. It is strictly READ-ONLY and never touches git:
|
|
660
|
+
* - the full diff comes from get_pr_diff (no checkout needed),
|
|
661
|
+
* - the CLI runs in a throwaway temp dir so any stray file write is contained and
|
|
662
|
+
* discarded (belt-and-suspenders with the prompt's edit/git ban), and
|
|
663
|
+
* - this function ONLY calls post_review — never post_report, a decision, a
|
|
664
|
+
* branch, a commit, a push, or a PR. The human stays the sole merge gate.
|
|
665
|
+
* Best-effort: any failure posts an honest note and returns; it never throws into
|
|
666
|
+
* the poll loop. Bounded by shouldReview()/reviewRounds so a reviewer can't
|
|
667
|
+
* re-review the same PR endlessly (the ping-pong bound).
|
|
668
|
+
*/
|
|
669
|
+
async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory, context, signal }) {
|
|
670
|
+
const parentId = message.parentId ?? null;
|
|
671
|
+
const prUrl = reviewTargetPrUrl(message);
|
|
672
|
+
const reviewOf =
|
|
673
|
+
message?.reviewOf && (message.reviewOf.prUrl || message.reviewOf.messageId)
|
|
674
|
+
? message.reviewOf
|
|
675
|
+
: prUrl
|
|
676
|
+
? { prUrl }
|
|
677
|
+
: null;
|
|
678
|
+
if (!prUrl || !reviewOf) {
|
|
679
|
+
await tool("post_message", {
|
|
680
|
+
channelId,
|
|
681
|
+
parentId,
|
|
682
|
+
body: `I was asked to review, but I couldn't find which PR — link the PR (or ask again with its URL) and I'll take a look.`,
|
|
683
|
+
}).catch(() => {});
|
|
684
|
+
return { status: "review-no-pr" };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Ping-pong bound: don't re-review the same PR past the round cap.
|
|
688
|
+
const reviewerId = me?.agentId || me?.id || me?.agentName || "reviewer";
|
|
689
|
+
const key = reviewDedupeKey(prUrl, reviewerId);
|
|
690
|
+
const rounds = reviewRounds.get(key) || 0;
|
|
691
|
+
const maxRounds = cfg.maxRounds || 3;
|
|
692
|
+
if (!shouldReview({ rounds, maxRounds })) {
|
|
693
|
+
await tool("post_message", {
|
|
694
|
+
channelId,
|
|
695
|
+
parentId,
|
|
696
|
+
body: `I've already reviewed this PR ${rounds} time(s) — I'll leave it to a human from here so we don't loop.`,
|
|
697
|
+
}).catch(() => {});
|
|
698
|
+
return { status: "review-capped", rounds };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const prNum = prNumberFromUrl(prUrl);
|
|
702
|
+
const label = prNum ? `PR #${prNum}` : "the PR";
|
|
703
|
+
// Instant, channel-visible ack; anchors the thread for the review card.
|
|
704
|
+
const ack = await tool("post_message", {
|
|
705
|
+
channelId,
|
|
706
|
+
parentId,
|
|
707
|
+
body: `Reviewing ${label} now — I'll post my read shortly.`,
|
|
708
|
+
}).catch(() => null);
|
|
709
|
+
const threadRoot = parentId ?? ack?.messageId ?? null;
|
|
710
|
+
|
|
711
|
+
// The diff — the whole review is fed off this (no clone, no gh creds).
|
|
712
|
+
// Capture the failure reason on BOTH error shapes: a soft failure returns
|
|
713
|
+
// { ok:false, error } (auth/no-token/fetch), a hard failure throws and we map its
|
|
714
|
+
// message here — so the honest note below can say WHY, not just that it couldn't.
|
|
715
|
+
const diff = await tool("get_pr_diff", { prUrl, channelId }).catch((e) => ({ ok: false, error: e?.message }));
|
|
716
|
+
if (!diff || diff.ok === false || !Array.isArray(diff.files) || diff.files.length === 0) {
|
|
717
|
+
const reason = diff?.error || diff?.message;
|
|
718
|
+
const why = reason ? ` (${reason})` : "";
|
|
719
|
+
await tool("post_message", {
|
|
720
|
+
channelId,
|
|
721
|
+
parentId: threadRoot,
|
|
722
|
+
broadcast: Boolean(threadRoot),
|
|
723
|
+
body: `I couldn't read ${label}'s diff to review it${why}. It may be private, merged, or not linked here.`,
|
|
724
|
+
}).catch(() => {});
|
|
725
|
+
return { status: "review-no-diff" };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The author's own summary (when the request pointed at their report message) —
|
|
729
|
+
// extra intent for the reviewer. Best-effort.
|
|
730
|
+
let reportSummary = null;
|
|
731
|
+
if (reviewOf.messageId) {
|
|
732
|
+
const gr = await tool("get_report", { messageId: reviewOf.messageId }).catch(() => null);
|
|
733
|
+
if (gr?.found && gr.report?.summary) reportSummary = gr.report.summary;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const prompt = buildReviewPrompt({
|
|
737
|
+
diffFiles: diff.files,
|
|
738
|
+
task: message.body,
|
|
739
|
+
reportSummary,
|
|
740
|
+
threadContext: context?.transcript,
|
|
741
|
+
workspaceMemory,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// Read-only run: a throwaway cwd contains any stray write; the daemon reads ONLY
|
|
745
|
+
// stdout and never stages/commits anything. Review uses the fast chat command
|
|
746
|
+
// when set (no acceptEdits → can't write), falling back to the coding command.
|
|
747
|
+
const cmd = cfg.reviewCmd || cfg.chatCmd || cfg.codingCmd;
|
|
748
|
+
const parts = String(cmd).split(" ").filter(Boolean);
|
|
749
|
+
let sandboxDir = null;
|
|
750
|
+
try {
|
|
751
|
+
sandboxDir = mkdtempSync(join(tmpdir(), "hilos-review-"));
|
|
752
|
+
} catch {
|
|
753
|
+
sandboxDir = undefined; // fall back to the daemon's cwd; the prompt still bans edits
|
|
754
|
+
}
|
|
755
|
+
console.log(` review → running \`${cmd}\` on ${label} (read-only)…`);
|
|
756
|
+
const run = await runCli({
|
|
757
|
+
cmd: parts[0],
|
|
758
|
+
args: [...parts.slice(1), prompt],
|
|
759
|
+
cwd: sandboxDir || undefined,
|
|
760
|
+
timeoutMs: cfg.chatTimeoutMs ? Math.max(cfg.chatTimeoutMs, 120000) : cfg.runTimeoutMs,
|
|
761
|
+
label: "reviewing",
|
|
762
|
+
signal,
|
|
763
|
+
});
|
|
764
|
+
if (sandboxDir) {
|
|
765
|
+
try {
|
|
766
|
+
rmSync(sandboxDir, { recursive: true, force: true });
|
|
767
|
+
} catch {
|
|
768
|
+
/* temp dir cleanup is best-effort */
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (run.aborted || signal?.aborted) {
|
|
773
|
+
await tool("post_message", { channelId, parentId: threadRoot, body: `Stopped the review of ${label}.` }).catch(() => {});
|
|
774
|
+
return { status: "review-cancelled" };
|
|
775
|
+
}
|
|
776
|
+
const text = (run.stdout || "").trim();
|
|
777
|
+
if (!text) {
|
|
778
|
+
const enoent = run.error?.code === "ENOENT";
|
|
779
|
+
await tool("post_message", {
|
|
780
|
+
channelId,
|
|
781
|
+
parentId: threadRoot,
|
|
782
|
+
broadcast: Boolean(threadRoot),
|
|
783
|
+
body: enoent
|
|
784
|
+
? `I couldn't start \`${cmd}\` to review ${label} — is it installed and on PATH?`
|
|
785
|
+
: `I couldn't get a read on ${label} that time — mention me again to retry the review.`,
|
|
786
|
+
}).catch(() => {});
|
|
787
|
+
return { status: "review-no-output" };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const { verdict, summary, findings } = parseReviewOutput(text);
|
|
791
|
+
// Advisory ONLY — post_review records a verdict + findings and @-tags the author.
|
|
792
|
+
// It NEVER merges, blocks, or writes a decision. The server rejects a self-review,
|
|
793
|
+
// so an agent reviewing its own PR degrades to an honest note here.
|
|
794
|
+
const posted = await tool("post_review", {
|
|
795
|
+
channelId,
|
|
796
|
+
reviewOf,
|
|
797
|
+
verdict,
|
|
798
|
+
summary: summary || `Reviewed ${label}.`,
|
|
799
|
+
findings,
|
|
800
|
+
parentId: threadRoot || undefined,
|
|
801
|
+
broadcast: Boolean(threadRoot),
|
|
802
|
+
}).catch((e) => ({ ok: false, error: e?.message }));
|
|
803
|
+
if (posted?.ok === false) {
|
|
804
|
+
// Most common: "You can't review your own work." — surface honestly, no crash.
|
|
805
|
+
await tool("post_message", {
|
|
806
|
+
channelId,
|
|
807
|
+
parentId: threadRoot,
|
|
808
|
+
body: `I looked at ${label} but couldn't post the review${posted?.error || posted?.message ? `: ${posted.error || posted.message}` : ""}.`,
|
|
809
|
+
}).catch(() => {});
|
|
810
|
+
return { status: "review-post-failed", error: posted?.error || posted?.message };
|
|
811
|
+
}
|
|
812
|
+
reviewRounds.set(key, rounds + 1);
|
|
813
|
+
console.log(` review → posted ${verdict} on ${label} (${findings.length} finding(s))`);
|
|
814
|
+
return { status: "reviewed", verdict, findings: findings.length, prUrl };
|
|
815
|
+
}
|
|
816
|
+
|
|
488
817
|
/** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
|
|
489
818
|
* cancels an in-flight run — the queue fires it when a human says "stop". */
|
|
490
819
|
export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
|
|
@@ -506,6 +835,34 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
506
835
|
// just said.
|
|
507
836
|
const context = await fetchContext({ channelId, tool, parentId });
|
|
508
837
|
|
|
838
|
+
// REVIEW EXECUTION (0288): a review-request routes to the read-only reviewer
|
|
839
|
+
// BEFORE the chat/code flow — it reads the PR's diff and posts an advisory review
|
|
840
|
+
// that @-tags the author. Gated on caps.review (post_review + get_pr_diff on this
|
|
841
|
+
// server); without it (older deploy) this is skipped and behavior is unchanged.
|
|
842
|
+
if (caps.review && isReviewRequest(message)) {
|
|
843
|
+
return await reviewTask({ message, channelId, tool, me, cfg, workspaceMemory, context, signal });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// AUTHOR SIDE (0288): a review that @-tags this agent about work it owns. The
|
|
847
|
+
// review body carries the verdict + summary; the full findings live in metadata,
|
|
848
|
+
// so pull them via get_reviews and fold them into the conversation the coding
|
|
849
|
+
// agent sees — then fall through to the normal follow-up machinery, which (0281)
|
|
850
|
+
// checks out the thread's PR branch and pushes onto the SAME human-gated PR. Best-
|
|
851
|
+
// effort; a failure just leaves the summary line to drive the iterate.
|
|
852
|
+
if (caps.review && message?.kind === "review") {
|
|
853
|
+
const target =
|
|
854
|
+
message?.reviewOf && (message.reviewOf.prUrl || message.reviewOf.messageId)
|
|
855
|
+
? message.reviewOf
|
|
856
|
+
: reviewTargetPrUrl(message)
|
|
857
|
+
? { prUrl: reviewTargetPrUrl(message) }
|
|
858
|
+
: null;
|
|
859
|
+
if (target) {
|
|
860
|
+
const got = await tool("get_reviews", { reviewOf: target }).catch(() => null);
|
|
861
|
+
const detail = formatReviewFindings(got);
|
|
862
|
+
if (detail) context.transcript = `${context.transcript}\n\nReviewer findings to address:\n${detail}`;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
509
866
|
// No repo linked → nothing to build; just reply.
|
|
510
867
|
if (!repoLink) {
|
|
511
868
|
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
|
|
@@ -513,10 +870,32 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
513
870
|
}
|
|
514
871
|
const repoFullName = repoLink.repo_full_name;
|
|
515
872
|
|
|
873
|
+
// Same-PR follow-up (0281): if this mention is a reply in a thread that already
|
|
874
|
+
// owns a run (branch + PR), we may continue THAT run instead of forking a
|
|
875
|
+
// duplicate. Look it up by the thread root — the parentId the reply carried.
|
|
876
|
+
// Best-effort + gated on the server supporting the runs entity (caps.runs); any
|
|
877
|
+
// failure falls back to a fresh run (mode 'new'), never breaking the task.
|
|
878
|
+
let activeRun = null;
|
|
879
|
+
if (caps.runs && parentId) {
|
|
880
|
+
const r = await tool("get_active_run", { channelId, threadRootId: parentId }).catch(() => null);
|
|
881
|
+
if (r && r.found) activeRun = r;
|
|
882
|
+
}
|
|
883
|
+
// Only an OPEN PR is iterable: awaiting_review (the review gate) or
|
|
884
|
+
// changes_requested (a rework in flight). A merged/closed run is never
|
|
885
|
+
// continued (the resolver's recency guard).
|
|
886
|
+
const activeRunOpen = Boolean(
|
|
887
|
+
activeRun && (activeRun.status === "awaiting_review" || activeRun.status === "changes_requested"),
|
|
888
|
+
);
|
|
889
|
+
|
|
516
890
|
// Chat vs code is the LLM's call, not a word list: it reads the whole
|
|
517
891
|
// conversation and either returns a chat reply or signals a code run. Handles
|
|
518
892
|
// any phrasing, any language, and "go for it" obviously means "do the thing we
|
|
519
|
-
// just discussed". When it replies (chat), post that and we're done.
|
|
893
|
+
// just discussed". When it replies (chat), post that and we're done. When the
|
|
894
|
+
// thread owns an OPEN PR it ALSO classifies the follow-up (change / new-scope /
|
|
895
|
+
// ambiguous). Gate on activeRunOpen, not merely Boolean(activeRun): the follow-up
|
|
896
|
+
// signal is only ever acted on when the PR is open (resolveFollowupMode → 'new'
|
|
897
|
+
// otherwise), so telling the router the thread "owns an open PR" when the run is
|
|
898
|
+
// merged/closed would just mislead the model and waste a classification.
|
|
520
899
|
const routed = await routeIntent({
|
|
521
900
|
name: me?.agentName || "an assistant",
|
|
522
901
|
repoFullName,
|
|
@@ -524,6 +903,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
524
903
|
workspaceMemory,
|
|
525
904
|
cfg,
|
|
526
905
|
signal,
|
|
906
|
+
hasActiveRun: activeRunOpen,
|
|
527
907
|
});
|
|
528
908
|
if (routed.aborted || signal?.aborted) {
|
|
529
909
|
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
@@ -542,6 +922,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
542
922
|
}
|
|
543
923
|
// routed.code → fall through to the coding flow below.
|
|
544
924
|
|
|
925
|
+
// How this code follow-up routes against the thread's run. The LLM's read
|
|
926
|
+
// (routed.followupSignal) is primary; the cue list is the fallback when it said
|
|
927
|
+
// nothing. resolveFollowupMode enforces the safety table — 'iterate' ONLY onto
|
|
928
|
+
// an open PR, an explicit new-scope forks ('redirect'), everything else is a
|
|
929
|
+
// fresh run ('new'). A chat reply never reaches here, so a chat is never an
|
|
930
|
+
// iterate. (Recomputed as `effectiveMode` after branch-prep in case an intended
|
|
931
|
+
// iterate can't check out its branch.)
|
|
932
|
+
const followupSignal = routed.followupSignal || classifyFollowupCue(message.body);
|
|
933
|
+
const followupMode = resolveFollowupMode({
|
|
934
|
+
hasActiveRun: Boolean(activeRun),
|
|
935
|
+
prOpen: activeRunOpen,
|
|
936
|
+
signal: followupSignal,
|
|
937
|
+
});
|
|
938
|
+
|
|
545
939
|
let repoPath = resolveRepoPath(cfg, repoFullName);
|
|
546
940
|
if (!repoPath) {
|
|
547
941
|
// Zero-config path: if the daemon is running INSIDE a checkout of this repo
|
|
@@ -617,25 +1011,137 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
617
1011
|
};
|
|
618
1012
|
|
|
619
1013
|
try {
|
|
620
|
-
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
621
1014
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1015
|
+
|
|
1016
|
+
// Same-branch iteration: continue an existing PR on its own branch and push
|
|
1017
|
+
// onto it, so the PR updates in place instead of a duplicate opening. Two ways
|
|
1018
|
+
// in, and an explicit token WINS when both apply (they agree — same PR):
|
|
1019
|
+
// 1. detectPrContinuation — an explicit "PR <url>" in the text (the server's
|
|
1020
|
+
// "request changes" rework ping, or a person naming a PR). The fallback.
|
|
1021
|
+
// 2. the run resolver's 'iterate' (0281) — a plain follow-up reply in a thread
|
|
1022
|
+
// whose run already owns an OPEN PR. The PRIMARY path.
|
|
1023
|
+
// Otherwise, a fresh branch off the default (today's behavior).
|
|
1024
|
+
const cont = detectPrContinuation(message.body, repoFullName);
|
|
1025
|
+
const iterateUrl = cont?.url || (followupMode === "iterate" && activeRun?.prUrl) || null;
|
|
1026
|
+
let branch = null;
|
|
1027
|
+
let continuingPrUrl = null;
|
|
1028
|
+
if (iterateUrl && deps.prHeadRef) {
|
|
1029
|
+
// Resolve the PR's head branch (source of truth); for a run-based iterate the
|
|
1030
|
+
// run's recorded branch is a fallback if gh can't view the PR.
|
|
1031
|
+
const headRef =
|
|
1032
|
+
deps.prHeadRef(repoPath, iterateUrl) ||
|
|
1033
|
+
(followupMode === "iterate" ? activeRun?.branch || null : null);
|
|
1034
|
+
if (headRef) {
|
|
1035
|
+
// Fetch the PR branch, then point a local branch at its tip via FETCH_HEAD
|
|
1036
|
+
// (works on single-branch clones where `origin/<headRef>` isn't tracked).
|
|
1037
|
+
// `-C` repoints whether or not a stale local copy exists — the daemon is
|
|
1038
|
+
// stateless across runs, so origin is the source of truth.
|
|
1039
|
+
const fetched = git(repoPath, ["fetch", "origin", headRef]);
|
|
1040
|
+
const sw =
|
|
1041
|
+
fetched.status === 0 ? git(repoPath, ["switch", "-C", headRef, "FETCH_HEAD"]) : fetched;
|
|
1042
|
+
if (fetched.status === 0 && sw.status === 0) {
|
|
1043
|
+
branch = headRef;
|
|
1044
|
+
continuingPrUrl = iterateUrl;
|
|
1045
|
+
console.log(` code → continuing existing PR ${iterateUrl} on \`${headRef}\``);
|
|
1046
|
+
} else {
|
|
1047
|
+
console.log(
|
|
1048
|
+
` ! couldn't check out PR branch \`${headRef}\` (falling back to a new branch): ${(sw.stderr || "").trim().slice(0, 200)}`,
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
630
1052
|
}
|
|
1053
|
+
// An explicit PR token (detectPrContinuation) that names a DIFFERENT PR than the
|
|
1054
|
+
// thread's active run must NOT reuse/update that run for the terminal
|
|
1055
|
+
// update_run/report: we push to the TOKEN's PR (via existingPrUrl below), so
|
|
1056
|
+
// binding the unrelated active run would push to the token PR (#99) yet mark the
|
|
1057
|
+
// active run (#142) awaiting_review — a mismatch. Treat this as 'new' for
|
|
1058
|
+
// run-binding: the token still WINS for which PR gets the push; only the
|
|
1059
|
+
// bookkeeping refuses to touch the mismatched run (start_run records a fresh run
|
|
1060
|
+
// for the token PR instead). A token naming the SAME PR the run owns keeps the
|
|
1061
|
+
// iterate-reuse.
|
|
1062
|
+
const explicitTokenMismatch = Boolean(cont?.url && activeRun?.prUrl && cont.url !== activeRun.prUrl);
|
|
1063
|
+
// If we INTENDED to iterate but couldn't check out the PR's branch, we're on a
|
|
1064
|
+
// fresh branch now — so don't reuse the old run either; treat it as a new run.
|
|
1065
|
+
const effectiveMode =
|
|
1066
|
+
explicitTokenMismatch || (followupMode === "iterate" && !continuingPrUrl) ? "new" : followupMode;
|
|
1067
|
+
if (!branch) {
|
|
1068
|
+
branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
1069
|
+
const co = git(repoPath, ["switch", "-c", branch]);
|
|
1070
|
+
if (co.status !== 0) {
|
|
1071
|
+
await tool("post_message", {
|
|
1072
|
+
channelId,
|
|
1073
|
+
parentId,
|
|
1074
|
+
body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
|
|
1075
|
+
});
|
|
1076
|
+
return { status: "branch-error" };
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
// The branch tip BEFORE the agent runs — the base for detecting commits the
|
|
1080
|
+
// CLI makes itself. For a continuation this is the PR's existing tip (not the
|
|
1081
|
+
// default branch), so its prior commits aren't misread as "the agent
|
|
1082
|
+
// self-committed".
|
|
1083
|
+
const baseRef = (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() || (startRef.ref || cfg.defaultBranch);
|
|
631
1084
|
|
|
632
1085
|
// Instant acknowledgement: post a template in <1s so the channel shows life
|
|
633
1086
|
// before any model call. If the server supports edit_message and a fast chatCmd
|
|
634
1087
|
// is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
|
|
635
1088
|
// Best-effort — a slow or failed ack just leaves the template, never blocks.
|
|
636
|
-
|
|
1089
|
+
//
|
|
1090
|
+
// INSPECTABLE ACK (0281): when we're continuing or forking off an existing run,
|
|
1091
|
+
// SAY so plainly (with the PR number + branch) so a human can correct the LLM's
|
|
1092
|
+
// routing before any code lands. This is a key safety mitigation for a wrong
|
|
1093
|
+
// 'iterate'. A plain new run keeps the standard template.
|
|
1094
|
+
let ackBody = ackText(repoFullName);
|
|
1095
|
+
if (continuingPrUrl) {
|
|
1096
|
+
const n = prNumberFromUrl(continuingPrUrl);
|
|
1097
|
+
ackBody = `Continuing ${n ? `PR #${n}` : "the existing pull request"} on \`${branch}\` — I'll push this change there and update the report.`;
|
|
1098
|
+
} else if (effectiveMode === "redirect") {
|
|
1099
|
+
const n = activeRun?.prNumber || prNumberFromUrl(activeRun?.prUrl);
|
|
1100
|
+
ackBody = `Starting a new PR${n ? ` (separate from #${n})` : ""} on \`${branch}\`.`;
|
|
1101
|
+
}
|
|
1102
|
+
const ack = await tool("post_message", { channelId, parentId, body: ackBody });
|
|
637
1103
|
const ackId = ack?.messageId ?? null;
|
|
638
|
-
|
|
1104
|
+
// Where the run streams + reports. When the mention was already in a thread,
|
|
1105
|
+
// stay in it. When it was top-level, the ack becomes the thread anchor so the
|
|
1106
|
+
// progress worklog and the report card live UNDER it — one tidy thread instead
|
|
1107
|
+
// of three separate top-level posts — exactly like the hosted/native runner.
|
|
1108
|
+
// The ack itself stays top-level (the channel-visible "on it"); terminal
|
|
1109
|
+
// outcomes + report cards broadcast back to the channel from the thread.
|
|
1110
|
+
const threadRoot = parentId ?? ackId;
|
|
1111
|
+
// Bind this run to the thread root (0279/0280), now follow-up-aware (0281):
|
|
1112
|
+
// iterate → REUSE the thread's existing run (don't record a second one); the
|
|
1113
|
+
// same PR updates in place and its status stays awaiting_review.
|
|
1114
|
+
// redirect → supersede the old run (so a future follow-up no longer points at
|
|
1115
|
+
// it), then record a FRESH run for the new, separate PR.
|
|
1116
|
+
// new → record a fresh run, exactly as before.
|
|
1117
|
+
// Best-effort throughout — a bookkeeping failure (or an older server without the
|
|
1118
|
+
// runs tools) must NEVER break the run, so it proceeds without a runId.
|
|
1119
|
+
let runId = effectiveMode === "iterate" ? activeRun?.runId ?? null : null;
|
|
1120
|
+
if (caps.runs && threadRoot && effectiveMode !== "iterate") {
|
|
1121
|
+
try {
|
|
1122
|
+
if (effectiveMode === "redirect" && activeRun?.runId) {
|
|
1123
|
+
await tool("update_run", { runId: activeRun.runId, status: "superseded" }).catch(() => {});
|
|
1124
|
+
}
|
|
1125
|
+
const r = await tool("start_run", {
|
|
1126
|
+
channelId,
|
|
1127
|
+
threadRootId: threadRoot,
|
|
1128
|
+
taskText: message.body,
|
|
1129
|
+
branch,
|
|
1130
|
+
// Map an unrecognized command to null rather than the off-vocabulary
|
|
1131
|
+
// "unknown" — provider is documented as claude_code|codex|cursor|hilos.
|
|
1132
|
+
provider: (() => {
|
|
1133
|
+
const v = detectVendor(cfg.codingCmd);
|
|
1134
|
+
return v === "unknown" ? null : v;
|
|
1135
|
+
})(),
|
|
1136
|
+
});
|
|
1137
|
+
runId = r?.runId ?? null;
|
|
1138
|
+
} catch {
|
|
1139
|
+
/* never break the run on a run-record failure */
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// Keep the inspectable continuation/redirect statement (don't overwrite it with
|
|
1143
|
+
// an LLM plan) so a human can correct the routing before code lands.
|
|
1144
|
+
if (ackId && caps.editMessage && cfg.chatCmd && !continuingPrUrl && effectiveMode !== "redirect") {
|
|
639
1145
|
// Feed the ack the router's distilled brief AND the conversation — not the raw
|
|
640
1146
|
// mention — so it states a real plan instead of "what's the task?".
|
|
641
1147
|
const plan = await proposePlanAck({
|
|
@@ -652,9 +1158,49 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
652
1158
|
// then EDIT it on later beats — alive without thread spam. Needs edit_message;
|
|
653
1159
|
// without it we skip rather than post a fresh message every beat. lastLine
|
|
654
1160
|
// carries the CLI's latest output into the beat.
|
|
655
|
-
|
|
1161
|
+
// Live progress (0274): when the server supports post_progress, the code run
|
|
1162
|
+
// STREAMS a coalesced "what I'm doing right now" card onto one thread status
|
|
1163
|
+
// message (via the 0272 stream parser) instead of the 3-min edit heartbeat. On
|
|
1164
|
+
// older servers (no post_progress) we fall back to the exact legacy behavior.
|
|
1165
|
+
const streamOn = Boolean(caps.postProgress);
|
|
1166
|
+
const vendor = detectVendor(cfg.codingCmd);
|
|
1167
|
+
const streamArgs = codeStreamArgs(vendor);
|
|
1168
|
+
let runSessionId = null; // captured from the stream for 0282 (resume)
|
|
1169
|
+
const machine = hostname();
|
|
1170
|
+
|
|
1171
|
+
// Session resume (0282): on an ITERATE we can resume the coding agent's SESSION so
|
|
1172
|
+
// the follow-up continues with the first run's reasoning/plan — not just its code
|
|
1173
|
+
// (which the branch checkout already restores). GATE on LOCAL confidence: only
|
|
1174
|
+
// resume when THIS machine's ~/.hilos/state.json recorded that thread's session
|
|
1175
|
+
// AND it matches this hostname. A providerSessionId that came ONLY from the server
|
|
1176
|
+
// (get_active_run) with no local match means the session likely lives on another
|
|
1177
|
+
// machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
|
|
1178
|
+
// run, so we DON'T resume and degrade to today's branch+feedback (never worse).
|
|
1179
|
+
// Only claude_code has a proven resume flag; codex/cursor/unknown → [] anyway.
|
|
1180
|
+
let resumeSessionId = null;
|
|
1181
|
+
if (effectiveMode === "iterate" && vendor === "claude_code") {
|
|
1182
|
+
const local = (() => {
|
|
1183
|
+
try {
|
|
1184
|
+
return readStateEntry(HILOS_DIR, threadRoot);
|
|
1185
|
+
} catch {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
})();
|
|
1189
|
+
const serverSid = activeRun?.providerSessionId || null;
|
|
1190
|
+
// Confidence = a local record, for THIS machine, with a session id. If the server
|
|
1191
|
+
// also has one it must AGREE (a mismatch means the run moved — don't resume a
|
|
1192
|
+
// stale/foreign id). A server-only id (no local record) is never resumed.
|
|
1193
|
+
if (local && local.machine === machine && local.sessionId && (!serverSid || serverSid === local.sessionId)) {
|
|
1194
|
+
resumeSessionId = local.sessionId;
|
|
1195
|
+
console.log(` code → resuming session ${local.sessionId} for this iterate (local + machine match)`);
|
|
1196
|
+
} else if (serverSid) {
|
|
1197
|
+
console.log(" code → not resuming (session recorded on another machine or unverified locally); using branch + feedback");
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const heartbeatOn = Boolean(caps.editMessage) && cfg.heartbeatMs > 0 && !streamOn;
|
|
656
1202
|
let lastLine = "";
|
|
657
|
-
let progressId = null; // the thread progress reply, once
|
|
1203
|
+
let progressId = null; // the thread progress/status reply, once one has posted
|
|
658
1204
|
const startHeartbeat = () => {
|
|
659
1205
|
if (!heartbeatOn) return () => {};
|
|
660
1206
|
const start = deps.now();
|
|
@@ -666,7 +1212,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
666
1212
|
const body = buildHeartbeat({ repoFullName, branch, elapsedMs: deps.now() - start, lastLine });
|
|
667
1213
|
try {
|
|
668
1214
|
if (!progressId) {
|
|
669
|
-
|
|
1215
|
+
// Progress is the worklog — it streams in the thread only (no broadcast).
|
|
1216
|
+
const r = await tool("post_message", { channelId, parentId: threadRoot, body });
|
|
670
1217
|
progressId = r?.messageId ?? null;
|
|
671
1218
|
} else {
|
|
672
1219
|
await tool("edit_message", { messageId: progressId, body });
|
|
@@ -696,27 +1243,139 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
696
1243
|
// Run the CLI and stage everything it changed; return the diff stats (no post).
|
|
697
1244
|
// Workspace memory (the project's soul) is prepended so the coding agent has
|
|
698
1245
|
// the shared context before it starts.
|
|
699
|
-
const runAndStage = async (promptText) => {
|
|
1246
|
+
const runAndStage = async (promptText, { resume = true } = {}) => {
|
|
700
1247
|
console.log(
|
|
701
1248
|
` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
|
|
702
1249
|
);
|
|
703
|
-
|
|
1250
|
+
// Streaming path: post ONE thread status message up front, then stream a live
|
|
1251
|
+
// card into it via post_progress. Legacy path: the 3-min edit heartbeat. Only
|
|
1252
|
+
// one of them runs — never both.
|
|
1253
|
+
let stopHeartbeat = () => {};
|
|
1254
|
+
let emitter = null;
|
|
1255
|
+
// Every post_progress write we dispatch, chained so the finally can await ALL
|
|
1256
|
+
// of them before the run returns. This closes the read-modify-write race with
|
|
1257
|
+
// the in-place settle (0289): if a trailing progress write were still landing
|
|
1258
|
+
// when post_report settles the report, it would re-write metadata WITHOUT the
|
|
1259
|
+
// report (it read the pre-settle snapshot) and clobber it. Draining every send
|
|
1260
|
+
// here guarantees no progress write is in flight once we settle.
|
|
1261
|
+
let progressInflight = Promise.resolve();
|
|
1262
|
+
if (streamOn) {
|
|
1263
|
+
if (!progressId) {
|
|
1264
|
+
try {
|
|
1265
|
+
const r = await tool("post_message", {
|
|
1266
|
+
channelId,
|
|
1267
|
+
parentId: threadRoot,
|
|
1268
|
+
body: buildHeartbeat({ repoFullName, branch, elapsedMs: 0, lastLine: "" }),
|
|
1269
|
+
});
|
|
1270
|
+
progressId = r?.messageId ?? null;
|
|
1271
|
+
} catch {
|
|
1272
|
+
progressId = null; // no status card → the run still proceeds
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
const statusId = progressId;
|
|
1276
|
+
if (statusId) {
|
|
1277
|
+
emitter = createProgressEmitter({
|
|
1278
|
+
parser: makeStreamParser(vendor),
|
|
1279
|
+
now: deps.now,
|
|
1280
|
+
throttleMs: cfg.progressMs,
|
|
1281
|
+
// The module never imports MCP — the send fn is injected here. It must
|
|
1282
|
+
// never throw into the run (createProgressEmitter also guards).
|
|
1283
|
+
send: (p) => {
|
|
1284
|
+
try {
|
|
1285
|
+
const r = tool("post_progress", { messageId: statusId, progress: p });
|
|
1286
|
+
if (r && typeof r.then === "function") {
|
|
1287
|
+
const done = r.then(() => {}, () => {});
|
|
1288
|
+
progressInflight = Promise.all([progressInflight, done]).then(
|
|
1289
|
+
() => {},
|
|
1290
|
+
() => {},
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
} catch {
|
|
1294
|
+
/* a progress send must never break the run */
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
} else {
|
|
1300
|
+
stopHeartbeat = startHeartbeat();
|
|
1301
|
+
}
|
|
1302
|
+
// The code run gets the vendor's stream flags appended to its ARGV (not the
|
|
1303
|
+
// display string), and ONLY the code run — chat/routing/plan-ack stay plain.
|
|
1304
|
+
// On an iterate we ALSO insert `--resume <id>` (0282) into the code ARGV
|
|
1305
|
+
// (after the base args, before the stream flags + the trailing prompt) so the
|
|
1306
|
+
// follow-up continues the first run's session; `resume:false` (the never-worse
|
|
1307
|
+
// fallback) suppresses it. buildResumeArgs is [] unless vendor+session make
|
|
1308
|
+
// resume safe, so a non-resume run is byte-identical to the pre-0282 ARGV.
|
|
1309
|
+
const resumeArgs = resume ? buildResumeArgs(vendor, resumeSessionId) : [];
|
|
1310
|
+
const codeArgs = streamOn
|
|
1311
|
+
? [...parts.slice(1), ...resumeArgs, ...streamArgs]
|
|
1312
|
+
: [...parts.slice(1), ...resumeArgs];
|
|
704
1313
|
let run;
|
|
705
1314
|
try {
|
|
706
1315
|
run = await runCli({
|
|
707
1316
|
cmd: parts[0],
|
|
708
|
-
args: [...
|
|
1317
|
+
args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
|
|
709
1318
|
cwd: repoPath,
|
|
710
1319
|
timeoutMs: cfg.runTimeoutMs,
|
|
711
1320
|
label: "coding",
|
|
712
1321
|
signal,
|
|
713
1322
|
onData: (c) => {
|
|
1323
|
+
// Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
|
|
714
1324
|
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
715
1325
|
if (lines.length) lastLine = lines[lines.length - 1];
|
|
1326
|
+
if (emitter) {
|
|
1327
|
+
try {
|
|
1328
|
+
emitter.feed(c);
|
|
1329
|
+
} catch {
|
|
1330
|
+
/* a progress fold must never break the run */
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
716
1333
|
},
|
|
717
1334
|
});
|
|
718
1335
|
} finally {
|
|
719
1336
|
stopHeartbeat();
|
|
1337
|
+
if (emitter) {
|
|
1338
|
+
// Terminal state: flip the status card off "working" (state 'done'/'error')
|
|
1339
|
+
// so it stops claiming the agent is alive. For a run that produced work,
|
|
1340
|
+
// the gate:false path then SETTLES the report onto this same card in place
|
|
1341
|
+
// (0289) — the card cross-fades run → report; for no-changes/failed/gate
|
|
1342
|
+
// outcomes this terminal 'done'/'error' is the card's final state.
|
|
1343
|
+
const errored = Boolean(run && (run.aborted || run.error || run.status !== 0));
|
|
1344
|
+
// On error, carry an honest reason onto the card (0294) — the same signal
|
|
1345
|
+
// the chat note uses: spawn error message, else exit status, plus a short
|
|
1346
|
+
// stderr tail. Sanitized again server-side; an old server just drops it.
|
|
1347
|
+
let reason = "";
|
|
1348
|
+
if (errored && run && !run.aborted) {
|
|
1349
|
+
const stderrTail = oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 200);
|
|
1350
|
+
if (run.error?.code === "ENOENT") {
|
|
1351
|
+
reason = `Couldn't start ${parts[0]} — is it installed and on PATH?`;
|
|
1352
|
+
} else if (run.error?.message) {
|
|
1353
|
+
reason = run.error.message;
|
|
1354
|
+
} else if (run.status != null) {
|
|
1355
|
+
reason = `The CLI exited ${run.status}`;
|
|
1356
|
+
}
|
|
1357
|
+
if (stderrTail) reason = reason ? `${reason}: ${stderrTail}` : stderrTail;
|
|
1358
|
+
}
|
|
1359
|
+
try {
|
|
1360
|
+
emitter.done(errored ? "error" : "done", reason);
|
|
1361
|
+
} catch {
|
|
1362
|
+
/* ignore */
|
|
1363
|
+
}
|
|
1364
|
+
// Wait for every progress write (including the terminal one above) to land
|
|
1365
|
+
// BEFORE runAndStage returns, so nothing is in flight when the caller
|
|
1366
|
+
// settles the report onto this card — no lost-update clobber (0289).
|
|
1367
|
+
try {
|
|
1368
|
+
await progressInflight;
|
|
1369
|
+
} catch {
|
|
1370
|
+
/* a drain failure must never break the run */
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
const snap = emitter.snapshot();
|
|
1374
|
+
if (snap && snap.sessionId) runSessionId = snap.sessionId;
|
|
1375
|
+
} catch {
|
|
1376
|
+
/* ignore */
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
720
1379
|
}
|
|
721
1380
|
if (run.aborted || signal?.aborted) {
|
|
722
1381
|
console.log(" code → cancelled");
|
|
@@ -745,9 +1404,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
745
1404
|
// (commit/push/PR) despite being told only to edit. Detect commits it made
|
|
746
1405
|
// on this branch so we report + ship them instead of falsely claiming "no
|
|
747
1406
|
// changes" and deleting a branch that has real work.
|
|
748
|
-
const base = startRef.ref || cfg.defaultBranch;
|
|
749
1407
|
const ahead =
|
|
750
|
-
Number((git(repoPath, ["rev-list", "--count", `${
|
|
1408
|
+
Number((git(repoPath, ["rev-list", "--count", `${baseRef}..HEAD`]).stdout || "0").trim()) || 0;
|
|
751
1409
|
if (ahead > 0) {
|
|
752
1410
|
console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
|
|
753
1411
|
return { empty: true, failed: false, ahead };
|
|
@@ -761,6 +1419,36 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
761
1419
|
return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
|
|
762
1420
|
};
|
|
763
1421
|
|
|
1422
|
+
// Persist the run's coding-agent session (0282) so a LATER iterate can resume it.
|
|
1423
|
+
// Two records, both best-effort — a failure here NEVER breaks the run:
|
|
1424
|
+
// - update_run({ providerSessionId }) → the server's durable record (survives a
|
|
1425
|
+
// daemon restart; also read by the hosted UI). Only when a runId was recorded.
|
|
1426
|
+
// - ~/.hilos/state.json for this thread root, stamped with THIS machine — the
|
|
1427
|
+
// LOCAL proof the session lives here, which the resume gate above requires.
|
|
1428
|
+
// Called after each run (a resumed run yields a possibly-new sessionId, so the
|
|
1429
|
+
// chain continues across multiple iterations) and again post-ship to fill prUrl.
|
|
1430
|
+
const recordSession = async (prUrl) => {
|
|
1431
|
+
const sid = runSessionId;
|
|
1432
|
+
if (!sid) return;
|
|
1433
|
+
if (runId) {
|
|
1434
|
+
await tool("update_run", { runId, providerSessionId: sid }).catch(() => {});
|
|
1435
|
+
}
|
|
1436
|
+
try {
|
|
1437
|
+
if (threadRoot) {
|
|
1438
|
+
writeState(HILOS_DIR, threadRoot, {
|
|
1439
|
+
runId: runId || null,
|
|
1440
|
+
branch,
|
|
1441
|
+
prUrl: prUrl || continuingPrUrl || null,
|
|
1442
|
+
sessionId: sid,
|
|
1443
|
+
machine,
|
|
1444
|
+
updatedAt: new Date().toISOString(),
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
} catch {
|
|
1448
|
+
/* a state write must never break the run */
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
|
|
764
1452
|
// Post a proposal card (approve-before-push mode) from a staged change.
|
|
765
1453
|
const postProposal = async (staged) => {
|
|
766
1454
|
const report = buildProposalReport({
|
|
@@ -774,7 +1462,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
774
1462
|
stat: staged.stat,
|
|
775
1463
|
runFailed: staged.runFailed,
|
|
776
1464
|
});
|
|
777
|
-
const res = await tool("post_report", { channelId, parentId, ...report });
|
|
1465
|
+
const res = await tool("post_report", { channelId, parentId: threadRoot, broadcast: true, ...report });
|
|
778
1466
|
return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
|
|
779
1467
|
};
|
|
780
1468
|
|
|
@@ -805,7 +1493,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
805
1493
|
const clean = discardBranch();
|
|
806
1494
|
await tool("post_message", {
|
|
807
1495
|
channelId,
|
|
808
|
-
parentId,
|
|
1496
|
+
parentId: threadRoot,
|
|
1497
|
+
broadcast: true,
|
|
809
1498
|
body: clean
|
|
810
1499
|
? `Stopped — discarded \`${branch}\`.`
|
|
811
1500
|
: `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
|
|
@@ -813,8 +1502,47 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
813
1502
|
return { status: "cancelled", branch };
|
|
814
1503
|
};
|
|
815
1504
|
|
|
816
|
-
|
|
1505
|
+
// Team memory recall (0297): best-effort fetch accumulated learnings for this
|
|
1506
|
+
// channel before the coding run so the coding agent has the team's conventions
|
|
1507
|
+
// and gotchas from the start. Capability-gated: older servers without the
|
|
1508
|
+
// `recall` tool degrade silently (caps.recall=false → skip, no change in
|
|
1509
|
+
// behavior). A recall failure — network hiccup, server error — NEVER fails the
|
|
1510
|
+
// task; it is caught and the run proceeds without the block.
|
|
1511
|
+
let teamMemoryBlock = "";
|
|
1512
|
+
if (caps.recall) {
|
|
1513
|
+
try {
|
|
1514
|
+
const recalled = await tool("recall", { channelId, limit: 20 }).catch(() => null);
|
|
1515
|
+
const mems = Array.isArray(recalled?.memories) ? recalled.memories : [];
|
|
1516
|
+
teamMemoryBlock = buildMemoryBlock(mems);
|
|
1517
|
+
} catch {
|
|
1518
|
+
/* recall failure must never break the coding task */
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const codePrompt =
|
|
1523
|
+
(teamMemoryBlock ? teamMemoryBlock + "\n\n" : "") +
|
|
1524
|
+
codeTaskPrompt({ message, context, brief: routed.task, repoFullName });
|
|
1525
|
+
let staged = await runAndStage(codePrompt);
|
|
817
1526
|
if (staged.aborted) return await postStopped();
|
|
1527
|
+
// Never-worse-than-today (0282): if we RESUMED a session and that run FAILED (a
|
|
1528
|
+
// stale/foreign `--resume` id makes claude error out → empty diff → failed), retry
|
|
1529
|
+
// ONCE without --resume — the exact branch+feedback iterate we'd have run before
|
|
1530
|
+
// 0282. A dead session can therefore never dead-end an iteration. Gated on
|
|
1531
|
+
// staged.failed (the reliable resume-error signal) so a legitimate no-op run isn't
|
|
1532
|
+
// re-run. Adopt the retry regardless — it IS the pre-0282 baseline.
|
|
1533
|
+
if (resumeSessionId && staged.empty && staged.failed) {
|
|
1534
|
+
console.log(" code → resumed run failed; retrying once without --resume (branch + feedback)");
|
|
1535
|
+
const retry = await runAndStage(codePrompt, { resume: false });
|
|
1536
|
+
if (retry.aborted) return await postStopped();
|
|
1537
|
+
staged = retry;
|
|
1538
|
+
}
|
|
1539
|
+
// Persist this run's session (best-effort) so the NEXT iterate can resume it and
|
|
1540
|
+
// machine-match. prUrl is filled in again at ship time.
|
|
1541
|
+
await recordSession(continuingPrUrl);
|
|
1542
|
+
// Resume is a ONE-shot for the first run of this iterate: the gate:true feedback
|
|
1543
|
+
// rounds below are intra-run refinements (same working tree, same PR turn), not a
|
|
1544
|
+
// fresh cross-turn resume, so they must NOT replay the original `--resume` id.
|
|
1545
|
+
resumeSessionId = null;
|
|
818
1546
|
// The CLI committed on its own (clean tree, but commits ahead of base). Don't
|
|
819
1547
|
// report "no changes" or delete the branch — surface the real work.
|
|
820
1548
|
if (staged.empty && !staged.failed && staged.ahead > 0) {
|
|
@@ -823,7 +1551,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
823
1551
|
// honest and let the human review instead of auto-shipping. Leave the branch.
|
|
824
1552
|
await tool("post_message", {
|
|
825
1553
|
channelId,
|
|
826
|
-
parentId,
|
|
1554
|
+
parentId: threadRoot,
|
|
1555
|
+
broadcast: true,
|
|
827
1556
|
body:
|
|
828
1557
|
`The coding agent committed on \`${branch}\` itself (I asked it to only edit files). ` +
|
|
829
1558
|
`Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
|
|
@@ -832,8 +1561,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
832
1561
|
await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
|
|
833
1562
|
return { status: "self-committed-gated", branch };
|
|
834
1563
|
}
|
|
835
|
-
|
|
836
|
-
|
|
1564
|
+
// When a live status card was streaming, settle the report onto it (0289) —
|
|
1565
|
+
// the card cross-fades run → report. Skip finalizeProgress then (it edits the
|
|
1566
|
+
// card's body, which the settle overwrites with the report anyway).
|
|
1567
|
+
const selfSettleId = streamOn && progressId ? progressId : null;
|
|
1568
|
+
if (!selfSettleId) await finalizeProgress(`Agent shipped \`${branch}\` itself — report below.`);
|
|
1569
|
+
const selfResult = await shipSelfDriven({
|
|
837
1570
|
repoPath,
|
|
838
1571
|
branch,
|
|
839
1572
|
task: routed.task || message.body,
|
|
@@ -842,13 +1575,23 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
842
1575
|
tool,
|
|
843
1576
|
channelId,
|
|
844
1577
|
deps,
|
|
845
|
-
parentId,
|
|
1578
|
+
parentId: threadRoot,
|
|
1579
|
+
settleId: selfSettleId,
|
|
1580
|
+
runId,
|
|
846
1581
|
});
|
|
1582
|
+
await recordSession(selfResult.prUrl);
|
|
1583
|
+
return selfResult;
|
|
847
1584
|
}
|
|
848
1585
|
if (staged.empty) {
|
|
1586
|
+
// When continuing an existing PR, never delete that branch — it's the open
|
|
1587
|
+
// PR — and don't say "cleaning up". Just report and leave the PR as it was.
|
|
1588
|
+
const cleanupNote = continuingPrUrl ? "" : ` Cleaning up \`${branch}\`.`;
|
|
1589
|
+
const retryTail = continuingPrUrl
|
|
1590
|
+
? ` — \`${branch}\` is unchanged; mention me to retry.`
|
|
1591
|
+
: ` Cleaning up \`${branch}\` — mention me to retry.`;
|
|
849
1592
|
let body;
|
|
850
1593
|
if (staged.failed && staged.errCode === "ENOENT") {
|
|
851
|
-
body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH
|
|
1594
|
+
body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH?${cleanupNote}`;
|
|
852
1595
|
} else if (staged.failed) {
|
|
853
1596
|
const why = staged.errMessage
|
|
854
1597
|
? ` (${staged.errMessage})`
|
|
@@ -856,16 +1599,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
856
1599
|
? ` (the CLI exited ${staged.status})`
|
|
857
1600
|
: "";
|
|
858
1601
|
const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
|
|
859
|
-
body = `The run didn't finish${why}${tail}
|
|
1602
|
+
body = `The run didn't finish${why}${tail}.${retryTail}`;
|
|
860
1603
|
} else {
|
|
861
|
-
body =
|
|
1604
|
+
body = continuingPrUrl
|
|
1605
|
+
? `That feedback produced no further changes — \`${branch}\` is unchanged.`
|
|
1606
|
+
: `No changes were produced.${cleanupNote}`;
|
|
862
1607
|
}
|
|
863
|
-
await tool("post_message", { channelId, parentId, body });
|
|
1608
|
+
await tool("post_message", { channelId, parentId: threadRoot, broadcast: true, body });
|
|
864
1609
|
await finalizeProgress(
|
|
865
1610
|
staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
|
|
866
1611
|
);
|
|
867
|
-
|
|
868
|
-
|
|
1612
|
+
if (!continuingPrUrl) {
|
|
1613
|
+
git(repoPath, ["switch", "-"]);
|
|
1614
|
+
git(repoPath, ["branch", "-D", branch]);
|
|
1615
|
+
}
|
|
869
1616
|
return { status: staged.failed ? "run-failed" : "no-changes" };
|
|
870
1617
|
}
|
|
871
1618
|
|
|
@@ -875,32 +1622,50 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
875
1622
|
if (!cfg.gate) {
|
|
876
1623
|
// A "stop" that lands between the run finishing and the ship must still win.
|
|
877
1624
|
if (signal?.aborted) return await postStopped();
|
|
1625
|
+
// Seamless single card (0289): when a live status card streamed this run,
|
|
1626
|
+
// settle the report onto it in place instead of posting a separate report
|
|
1627
|
+
// message. Only the DEFAULT gate:false report settles in place; gate:true's
|
|
1628
|
+
// proposal → decision → report lifecycle stays on separate messages (a
|
|
1629
|
+
// separate ticket). No status card (older server / status post failed) →
|
|
1630
|
+
// settleId is null and applyDecision posts a fresh broadcast report as before.
|
|
1631
|
+
const settleId = streamOn && progressId ? progressId : null;
|
|
878
1632
|
const result = await applyDecision({
|
|
879
1633
|
decision: { kind: "approved" },
|
|
880
1634
|
repoPath,
|
|
881
1635
|
branch,
|
|
882
|
-
|
|
1636
|
+
// For a continuation the raw mention is the rework ping ("please update the
|
|
1637
|
+
// PR …"); use the router's distilled brief for a clean commit/PR title.
|
|
1638
|
+
task: continuingPrUrl ? routed.task || message.body : message.body,
|
|
883
1639
|
requester: message.author,
|
|
884
1640
|
cfg,
|
|
885
1641
|
tool,
|
|
886
1642
|
channelId,
|
|
887
1643
|
deps,
|
|
888
|
-
parentId,
|
|
1644
|
+
parentId: threadRoot,
|
|
1645
|
+
existingPrUrl: continuingPrUrl,
|
|
1646
|
+
settleId,
|
|
1647
|
+
runId,
|
|
889
1648
|
});
|
|
890
|
-
|
|
891
|
-
|
|
1649
|
+
// The settle already flipped the card to the report (body + metadata); editing
|
|
1650
|
+
// the body again would clobber the report summary, so only finalize when we
|
|
1651
|
+
// posted a separate report.
|
|
1652
|
+
if (!settleId) await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
1653
|
+
// Re-record with the now-known PR url so the local session record + the run row
|
|
1654
|
+
// carry the PR the session belongs to (best-effort; session id unchanged).
|
|
1655
|
+
await recordSession(result.prUrl);
|
|
1656
|
+
return { ...result, stat: staged.stat, sessionId: runSessionId };
|
|
892
1657
|
}
|
|
893
1658
|
|
|
894
1659
|
await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
|
|
895
1660
|
let proposal = await postProposal(staged);
|
|
896
|
-
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
|
|
1661
|
+
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
|
|
897
1662
|
if (decision.kind === "cancelled") return await postStopped();
|
|
898
1663
|
const maxRounds = cfg.maxRounds || 3;
|
|
899
1664
|
let round = 1;
|
|
900
1665
|
while (decision.kind === "changes" && round < maxRounds) {
|
|
901
1666
|
await tool("post_message", {
|
|
902
1667
|
channelId,
|
|
903
|
-
parentId,
|
|
1668
|
+
parentId: threadRoot,
|
|
904
1669
|
body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
|
|
905
1670
|
});
|
|
906
1671
|
const refined = await runAndStage(
|
|
@@ -910,13 +1675,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
910
1675
|
if (refined.empty) {
|
|
911
1676
|
await tool("post_message", {
|
|
912
1677
|
channelId,
|
|
913
|
-
parentId,
|
|
1678
|
+
parentId: threadRoot,
|
|
1679
|
+
broadcast: true,
|
|
914
1680
|
body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
|
|
915
1681
|
});
|
|
916
1682
|
break;
|
|
917
1683
|
}
|
|
918
1684
|
proposal = await postProposal(refined);
|
|
919
|
-
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
|
|
1685
|
+
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
|
|
920
1686
|
if (decision.kind === "cancelled") return await postStopped();
|
|
921
1687
|
round += 1;
|
|
922
1688
|
}
|
|
@@ -927,16 +1693,21 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
927
1693
|
decision,
|
|
928
1694
|
repoPath,
|
|
929
1695
|
branch,
|
|
930
|
-
|
|
1696
|
+
// A gate:true approve on a continuation already has an open PR — reuse it so
|
|
1697
|
+
// we update in place instead of a failing `gh pr create`.
|
|
1698
|
+
task: continuingPrUrl ? routed.task || message.body : message.body,
|
|
931
1699
|
requester: message.author,
|
|
932
1700
|
cfg,
|
|
933
1701
|
tool,
|
|
934
1702
|
channelId,
|
|
935
1703
|
deps,
|
|
936
|
-
parentId,
|
|
1704
|
+
parentId: threadRoot,
|
|
1705
|
+
existingPrUrl: continuingPrUrl,
|
|
1706
|
+
runId,
|
|
937
1707
|
});
|
|
938
1708
|
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
939
|
-
|
|
1709
|
+
await recordSession(result.prUrl);
|
|
1710
|
+
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
|
|
940
1711
|
} finally {
|
|
941
1712
|
// Whatever happened, give the user their uncommitted work back.
|
|
942
1713
|
await restoreStash();
|