hilos-agent 0.1.15 → 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 +801 -51
- 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,7 +163,7 @@ 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} — ` : "";
|
|
107
169
|
// `parentId` here is the run's thread root: every outcome below is terminal, so
|
|
@@ -139,21 +201,47 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
139
201
|
return { status: "push-failed", branch };
|
|
140
202
|
}
|
|
141
203
|
const { title, body } = prTitleBody(task, branch);
|
|
142
|
-
|
|
143
|
-
|
|
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 = {
|
|
144
216
|
channelId,
|
|
145
|
-
parentId,
|
|
146
|
-
broadcast: true,
|
|
147
217
|
title: `Shipped: ${title}`,
|
|
148
218
|
summary:
|
|
149
219
|
pr.ok && pr.url
|
|
150
|
-
?
|
|
220
|
+
? existingPrUrl
|
|
221
|
+
? `${lead}pushed an update to \`${branch}\` and updated the pull request.`
|
|
222
|
+
: `${lead}pushed \`${branch}\` and opened a pull request.`
|
|
151
223
|
: `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
|
|
152
224
|
prUrl: pr.ok && pr.url ? pr.url : undefined,
|
|
153
225
|
caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
|
|
154
|
-
}
|
|
226
|
+
};
|
|
227
|
+
await tool(
|
|
228
|
+
"post_report",
|
|
229
|
+
settleId
|
|
230
|
+
? { ...reportArgs, messageId: settleId, broadcast: true }
|
|
231
|
+
: { ...reportArgs, parentId, broadcast: true },
|
|
232
|
+
);
|
|
155
233
|
// Work produced a PR → attach it to the channel so its live pill shows up.
|
|
156
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
|
+
}
|
|
157
245
|
return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
|
|
158
246
|
}
|
|
159
247
|
|
|
@@ -192,7 +280,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
192
280
|
* push the branch it's on (no-op if already pushed), reuse the PR it opened or
|
|
193
281
|
* open one, and report it. Used only when NOT gated (bias-to-action).
|
|
194
282
|
*/
|
|
195
|
-
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 }) {
|
|
196
284
|
const tag = requesterTag(requester);
|
|
197
285
|
const lead = tag ? `${tag} — ` : "";
|
|
198
286
|
// The agent may have committed on the daemon's branch or switched to one of its
|
|
@@ -207,10 +295,11 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
207
295
|
prUrl = pr.ok && pr.url ? pr.url : null;
|
|
208
296
|
}
|
|
209
297
|
const { title } = prTitleBody(task, cur);
|
|
210
|
-
|
|
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 = {
|
|
211
302
|
channelId,
|
|
212
|
-
parentId,
|
|
213
|
-
broadcast: true,
|
|
214
303
|
title: `Shipped: ${title}`,
|
|
215
304
|
summary: prUrl
|
|
216
305
|
? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
|
|
@@ -219,8 +308,23 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
219
308
|
: `${lead}the coding agent committed on \`${cur}\` locally, but pushing it failed.`,
|
|
220
309
|
prUrl: prUrl || undefined,
|
|
221
310
|
caveats: push.status === 0 ? [] : [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`],
|
|
222
|
-
}
|
|
311
|
+
};
|
|
312
|
+
await tool(
|
|
313
|
+
"post_report",
|
|
314
|
+
settleId
|
|
315
|
+
? { ...reportArgs, messageId: settleId, broadcast: true }
|
|
316
|
+
: { ...reportArgs, parentId, broadcast: true },
|
|
317
|
+
);
|
|
223
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
|
+
}
|
|
224
328
|
return { status: push.status === 0 ? "pushed" : "commit-local", branch: cur, prUrl };
|
|
225
329
|
}
|
|
226
330
|
|
|
@@ -248,9 +352,23 @@ const CODE_SIGNAL = "__CODE__";
|
|
|
248
352
|
* `error` is set when the model produced nothing (so the caller can be honest
|
|
249
353
|
* about a timeout vs a missing binary instead of inventing a reply).
|
|
250
354
|
*/
|
|
251
|
-
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal }) {
|
|
355
|
+
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false }) {
|
|
252
356
|
const cmd = cfg.chatCmd || cfg.codingCmd;
|
|
253
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
|
+
: "";
|
|
254
372
|
const prompt =
|
|
255
373
|
`You are ${name}, a teammate in a team chat (hilos) connected to the git repository ` +
|
|
256
374
|
`${repoFullName}. Read the conversation and judge what the LATEST message wants from you.\n\n` +
|
|
@@ -263,7 +381,8 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
263
381
|
`that lists who reacted with each emoji.\n\n` +
|
|
264
382
|
`Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
|
|
265
383
|
`yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
|
|
266
|
-
`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` +
|
|
267
386
|
`You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
|
|
268
387
|
`edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
|
|
269
388
|
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
@@ -275,8 +394,14 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
275
394
|
signal,
|
|
276
395
|
});
|
|
277
396
|
if (run.aborted || signal?.aborted) return { aborted: true };
|
|
278
|
-
const
|
|
279
|
-
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 };
|
|
280
405
|
// Accept the sentinel ANYWHERE, not just the first line — models sometimes add a
|
|
281
406
|
// line of preamble before it ("Got it, …\n__CODE__\n<brief>"). If it appears,
|
|
282
407
|
// it's a code run; the brief is whatever follows the sentinel's line and any
|
|
@@ -286,9 +411,9 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
286
411
|
if (idx >= 0) {
|
|
287
412
|
const after = out.slice(idx + CODE_SIGNAL.length);
|
|
288
413
|
const nl = after.indexOf("\n");
|
|
289
|
-
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 };
|
|
290
415
|
}
|
|
291
|
-
return { code: false, reply: out };
|
|
416
|
+
return { code: false, reply: out, followupSignal };
|
|
292
417
|
}
|
|
293
418
|
|
|
294
419
|
/**
|
|
@@ -495,6 +620,200 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
495
620
|
await deliver(body);
|
|
496
621
|
}
|
|
497
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
|
+
|
|
498
817
|
/** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
|
|
499
818
|
* cancels an in-flight run — the queue fires it when a human says "stop". */
|
|
500
819
|
export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
|
|
@@ -516,6 +835,34 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
516
835
|
// just said.
|
|
517
836
|
const context = await fetchContext({ channelId, tool, parentId });
|
|
518
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
|
+
|
|
519
866
|
// No repo linked → nothing to build; just reply.
|
|
520
867
|
if (!repoLink) {
|
|
521
868
|
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
|
|
@@ -523,10 +870,32 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
523
870
|
}
|
|
524
871
|
const repoFullName = repoLink.repo_full_name;
|
|
525
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
|
+
|
|
526
890
|
// Chat vs code is the LLM's call, not a word list: it reads the whole
|
|
527
891
|
// conversation and either returns a chat reply or signals a code run. Handles
|
|
528
892
|
// any phrasing, any language, and "go for it" obviously means "do the thing we
|
|
529
|
-
// 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.
|
|
530
899
|
const routed = await routeIntent({
|
|
531
900
|
name: me?.agentName || "an assistant",
|
|
532
901
|
repoFullName,
|
|
@@ -534,6 +903,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
534
903
|
workspaceMemory,
|
|
535
904
|
cfg,
|
|
536
905
|
signal,
|
|
906
|
+
hasActiveRun: activeRunOpen,
|
|
537
907
|
});
|
|
538
908
|
if (routed.aborted || signal?.aborted) {
|
|
539
909
|
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
@@ -552,6 +922,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
552
922
|
}
|
|
553
923
|
// routed.code → fall through to the coding flow below.
|
|
554
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
|
+
|
|
555
939
|
let repoPath = resolveRepoPath(cfg, repoFullName);
|
|
556
940
|
if (!repoPath) {
|
|
557
941
|
// Zero-config path: if the daemon is running INSIDE a checkout of this repo
|
|
@@ -627,23 +1011,95 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
627
1011
|
};
|
|
628
1012
|
|
|
629
1013
|
try {
|
|
630
|
-
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
631
1014
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
+
}
|
|
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
|
+
}
|
|
640
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);
|
|
641
1084
|
|
|
642
1085
|
// Instant acknowledgement: post a template in <1s so the channel shows life
|
|
643
1086
|
// before any model call. If the server supports edit_message and a fast chatCmd
|
|
644
1087
|
// is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
|
|
645
1088
|
// Best-effort — a slow or failed ack just leaves the template, never blocks.
|
|
646
|
-
|
|
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 });
|
|
647
1103
|
const ackId = ack?.messageId ?? null;
|
|
648
1104
|
// Where the run streams + reports. When the mention was already in a thread,
|
|
649
1105
|
// stay in it. When it was top-level, the ack becomes the thread anchor so the
|
|
@@ -652,7 +1108,40 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
652
1108
|
// The ack itself stays top-level (the channel-visible "on it"); terminal
|
|
653
1109
|
// outcomes + report cards broadcast back to the channel from the thread.
|
|
654
1110
|
const threadRoot = parentId ?? ackId;
|
|
655
|
-
|
|
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") {
|
|
656
1145
|
// Feed the ack the router's distilled brief AND the conversation — not the raw
|
|
657
1146
|
// mention — so it states a real plan instead of "what's the task?".
|
|
658
1147
|
const plan = await proposePlanAck({
|
|
@@ -669,9 +1158,49 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
669
1158
|
// then EDIT it on later beats — alive without thread spam. Needs edit_message;
|
|
670
1159
|
// without it we skip rather than post a fresh message every beat. lastLine
|
|
671
1160
|
// carries the CLI's latest output into the beat.
|
|
672
|
-
|
|
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;
|
|
673
1202
|
let lastLine = "";
|
|
674
|
-
let progressId = null; // the thread progress reply, once
|
|
1203
|
+
let progressId = null; // the thread progress/status reply, once one has posted
|
|
675
1204
|
const startHeartbeat = () => {
|
|
676
1205
|
if (!heartbeatOn) return () => {};
|
|
677
1206
|
const start = deps.now();
|
|
@@ -714,27 +1243,139 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
714
1243
|
// Run the CLI and stage everything it changed; return the diff stats (no post).
|
|
715
1244
|
// Workspace memory (the project's soul) is prepended so the coding agent has
|
|
716
1245
|
// the shared context before it starts.
|
|
717
|
-
const runAndStage = async (promptText) => {
|
|
1246
|
+
const runAndStage = async (promptText, { resume = true } = {}) => {
|
|
718
1247
|
console.log(
|
|
719
1248
|
` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
|
|
720
1249
|
);
|
|
721
|
-
|
|
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];
|
|
722
1313
|
let run;
|
|
723
1314
|
try {
|
|
724
1315
|
run = await runCli({
|
|
725
1316
|
cmd: parts[0],
|
|
726
|
-
args: [...
|
|
1317
|
+
args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
|
|
727
1318
|
cwd: repoPath,
|
|
728
1319
|
timeoutMs: cfg.runTimeoutMs,
|
|
729
1320
|
label: "coding",
|
|
730
1321
|
signal,
|
|
731
1322
|
onData: (c) => {
|
|
1323
|
+
// Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
|
|
732
1324
|
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
733
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
|
+
}
|
|
734
1333
|
},
|
|
735
1334
|
});
|
|
736
1335
|
} finally {
|
|
737
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
|
+
}
|
|
738
1379
|
}
|
|
739
1380
|
if (run.aborted || signal?.aborted) {
|
|
740
1381
|
console.log(" code → cancelled");
|
|
@@ -763,9 +1404,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
763
1404
|
// (commit/push/PR) despite being told only to edit. Detect commits it made
|
|
764
1405
|
// on this branch so we report + ship them instead of falsely claiming "no
|
|
765
1406
|
// changes" and deleting a branch that has real work.
|
|
766
|
-
const base = startRef.ref || cfg.defaultBranch;
|
|
767
1407
|
const ahead =
|
|
768
|
-
Number((git(repoPath, ["rev-list", "--count", `${
|
|
1408
|
+
Number((git(repoPath, ["rev-list", "--count", `${baseRef}..HEAD`]).stdout || "0").trim()) || 0;
|
|
769
1409
|
if (ahead > 0) {
|
|
770
1410
|
console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
|
|
771
1411
|
return { empty: true, failed: false, ahead };
|
|
@@ -779,6 +1419,36 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
779
1419
|
return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
|
|
780
1420
|
};
|
|
781
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
|
+
|
|
782
1452
|
// Post a proposal card (approve-before-push mode) from a staged change.
|
|
783
1453
|
const postProposal = async (staged) => {
|
|
784
1454
|
const report = buildProposalReport({
|
|
@@ -832,8 +1502,47 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
832
1502
|
return { status: "cancelled", branch };
|
|
833
1503
|
};
|
|
834
1504
|
|
|
835
|
-
|
|
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);
|
|
836
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;
|
|
837
1546
|
// The CLI committed on its own (clean tree, but commits ahead of base). Don't
|
|
838
1547
|
// report "no changes" or delete the branch — surface the real work.
|
|
839
1548
|
if (staged.empty && !staged.failed && staged.ahead > 0) {
|
|
@@ -852,8 +1561,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
852
1561
|
await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
|
|
853
1562
|
return { status: "self-committed-gated", branch };
|
|
854
1563
|
}
|
|
855
|
-
|
|
856
|
-
|
|
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({
|
|
857
1570
|
repoPath,
|
|
858
1571
|
branch,
|
|
859
1572
|
task: routed.task || message.body,
|
|
@@ -863,12 +1576,22 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
863
1576
|
channelId,
|
|
864
1577
|
deps,
|
|
865
1578
|
parentId: threadRoot,
|
|
1579
|
+
settleId: selfSettleId,
|
|
1580
|
+
runId,
|
|
866
1581
|
});
|
|
1582
|
+
await recordSession(selfResult.prUrl);
|
|
1583
|
+
return selfResult;
|
|
867
1584
|
}
|
|
868
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.`;
|
|
869
1592
|
let body;
|
|
870
1593
|
if (staged.failed && staged.errCode === "ENOENT") {
|
|
871
|
-
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}`;
|
|
872
1595
|
} else if (staged.failed) {
|
|
873
1596
|
const why = staged.errMessage
|
|
874
1597
|
? ` (${staged.errMessage})`
|
|
@@ -876,16 +1599,20 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
876
1599
|
? ` (the CLI exited ${staged.status})`
|
|
877
1600
|
: "";
|
|
878
1601
|
const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
|
|
879
|
-
body = `The run didn't finish${why}${tail}
|
|
1602
|
+
body = `The run didn't finish${why}${tail}.${retryTail}`;
|
|
880
1603
|
} else {
|
|
881
|
-
body =
|
|
1604
|
+
body = continuingPrUrl
|
|
1605
|
+
? `That feedback produced no further changes — \`${branch}\` is unchanged.`
|
|
1606
|
+
: `No changes were produced.${cleanupNote}`;
|
|
882
1607
|
}
|
|
883
1608
|
await tool("post_message", { channelId, parentId: threadRoot, broadcast: true, body });
|
|
884
1609
|
await finalizeProgress(
|
|
885
1610
|
staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
|
|
886
1611
|
);
|
|
887
|
-
|
|
888
|
-
|
|
1612
|
+
if (!continuingPrUrl) {
|
|
1613
|
+
git(repoPath, ["switch", "-"]);
|
|
1614
|
+
git(repoPath, ["branch", "-D", branch]);
|
|
1615
|
+
}
|
|
889
1616
|
return { status: staged.failed ? "run-failed" : "no-changes" };
|
|
890
1617
|
}
|
|
891
1618
|
|
|
@@ -895,20 +1622,38 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
895
1622
|
if (!cfg.gate) {
|
|
896
1623
|
// A "stop" that lands between the run finishing and the ship must still win.
|
|
897
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;
|
|
898
1632
|
const result = await applyDecision({
|
|
899
1633
|
decision: { kind: "approved" },
|
|
900
1634
|
repoPath,
|
|
901
1635
|
branch,
|
|
902
|
-
|
|
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,
|
|
903
1639
|
requester: message.author,
|
|
904
1640
|
cfg,
|
|
905
1641
|
tool,
|
|
906
1642
|
channelId,
|
|
907
1643
|
deps,
|
|
908
1644
|
parentId: threadRoot,
|
|
1645
|
+
existingPrUrl: continuingPrUrl,
|
|
1646
|
+
settleId,
|
|
1647
|
+
runId,
|
|
909
1648
|
});
|
|
910
|
-
|
|
911
|
-
|
|
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 };
|
|
912
1657
|
}
|
|
913
1658
|
|
|
914
1659
|
await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
|
|
@@ -948,16 +1693,21 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
948
1693
|
decision,
|
|
949
1694
|
repoPath,
|
|
950
1695
|
branch,
|
|
951
|
-
|
|
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,
|
|
952
1699
|
requester: message.author,
|
|
953
1700
|
cfg,
|
|
954
1701
|
tool,
|
|
955
1702
|
channelId,
|
|
956
1703
|
deps,
|
|
957
1704
|
parentId: threadRoot,
|
|
1705
|
+
existingPrUrl: continuingPrUrl,
|
|
1706
|
+
runId,
|
|
958
1707
|
});
|
|
959
1708
|
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
960
|
-
|
|
1709
|
+
await recordSession(result.prUrl);
|
|
1710
|
+
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
|
|
961
1711
|
} finally {
|
|
962
1712
|
// Whatever happened, give the user their uncommitted work back.
|
|
963
1713
|
await restoreStash();
|