hilos-agent 0.10.1 → 0.11.2
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/LICENSE +21 -0
- package/README.md +54 -10
- package/bin/hilos-agent.mjs +87 -14
- package/package.json +7 -5
- package/src/acp-session.mjs +16 -1
- package/src/cli.mjs +34 -1
- package/src/codex-mcp-session.mjs +36 -0
- package/src/config.mjs +39 -5
- package/src/conflicts.mjs +156 -0
- package/src/cursor-store.mjs +43 -8
- package/src/handler.mjs +1197 -127
- package/src/iterate-claim-recovery.mjs +574 -0
- package/src/local-harness-events.mjs +370 -0
- package/src/local-harness-transport.mjs +157 -0
- package/src/mcp.mjs +411 -34
- package/src/model-resolve.mjs +3 -1
- package/src/opencode-session.mjs +22 -2
- package/src/progress-emitter.mjs +1 -1
- package/src/queue.mjs +70 -3
- package/src/redact.mjs +360 -7
- package/src/run-inputs.mjs +211 -0
- package/src/run.mjs +462 -76
- package/src/thread-pr.mjs +26 -4
- package/src/webmcp-bridge.mjs +77 -4
package/src/handler.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// and pushes only after approval. Your code + credentials stay local.
|
|
9
9
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
|
-
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
12
12
|
import { rmSync, mkdtempSync, existsSync } from "node:fs";
|
|
13
13
|
import { hostname, tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
@@ -37,7 +37,15 @@ import {
|
|
|
37
37
|
scrubHilosEnv,
|
|
38
38
|
envForCwd,
|
|
39
39
|
} from "./cli.mjs";
|
|
40
|
+
import { commandArgv } from "./argv.mjs";
|
|
40
41
|
import { makeStreamParser, createUsageFold } from "./agent-events.mjs";
|
|
42
|
+
import {
|
|
43
|
+
createDirectionInbox,
|
|
44
|
+
directionTurnPrompt,
|
|
45
|
+
directionTurnRan,
|
|
46
|
+
MAX_DIRECTION_TURNS,
|
|
47
|
+
readPendingInputs,
|
|
48
|
+
} from "./run-inputs.mjs";
|
|
41
49
|
import {
|
|
42
50
|
detectVendor,
|
|
43
51
|
chatWebArgs,
|
|
@@ -54,6 +62,8 @@ import {
|
|
|
54
62
|
webToolEnv,
|
|
55
63
|
} from "./progress-emitter.mjs";
|
|
56
64
|
import { createTranscriptTap } from "./transcript.mjs";
|
|
65
|
+
import { createLocalHarnessEmitter } from "./local-harness-transport.mjs";
|
|
66
|
+
import { releaseDaemonIterateClaim } from "./iterate-claim-recovery.mjs";
|
|
57
67
|
import { imagePromptNote, renderAttachmentLine } from "./attachments.mjs";
|
|
58
68
|
import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
|
|
59
69
|
import {
|
|
@@ -87,6 +97,15 @@ import {
|
|
|
87
97
|
} from "./codex-mcp-session.mjs";
|
|
88
98
|
import { createUngatedRunNotice } from "./permission-gate.mjs";
|
|
89
99
|
import { webMcpAgentPrompt } from "./webmcp-bridge.mjs";
|
|
100
|
+
import {
|
|
101
|
+
conflictCommitTitle,
|
|
102
|
+
conflictPromptBlock,
|
|
103
|
+
conflictRepairBrief,
|
|
104
|
+
remainingConflictFiles,
|
|
105
|
+
startConflictMerge,
|
|
106
|
+
validateConflictMergeState,
|
|
107
|
+
validConflictRecovery,
|
|
108
|
+
} from "./conflicts.mjs";
|
|
90
109
|
|
|
91
110
|
/**
|
|
92
111
|
* The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
|
|
@@ -250,6 +269,12 @@ function defaultDeps() {
|
|
|
250
269
|
const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
|
|
251
270
|
return { ok: r.status === 0, url, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
252
271
|
},
|
|
272
|
+
closePR: (cwd, url) =>
|
|
273
|
+
spawnSync("gh", ["pr", "close", url], {
|
|
274
|
+
cwd,
|
|
275
|
+
env: cwdEnv(cwd),
|
|
276
|
+
encoding: "utf8",
|
|
277
|
+
}),
|
|
253
278
|
// The open PR for a branch, if one already exists — so when the CLI opened a
|
|
254
279
|
// PR itself we report THAT instead of opening a duplicate.
|
|
255
280
|
findPR: (cwd, branch) => {
|
|
@@ -392,13 +417,31 @@ function openCodePermissionCallbacks({
|
|
|
392
417
|
* `onStopRequested` is called at most once per sender and must never throw into
|
|
393
418
|
* the run — it is the daemon's cue to tear the process group down.
|
|
394
419
|
*
|
|
420
|
+
* `onPendingInputs` (1182) receives the room directions a reply lists for a
|
|
421
|
+
* run-bound sender; it must never throw into the run either.
|
|
422
|
+
*
|
|
395
423
|
* @param {{ tool: Function, statusId: string, runId?: string|null,
|
|
396
|
-
* onStopRequested?: (info: {by: string|null}) => any
|
|
424
|
+
* onStopRequested?: (info: {by: string|null}) => any,
|
|
425
|
+
* onPendingInputs?: (list: Array<{inputId: string, ordinal: number, body: string}>) => any }} o
|
|
397
426
|
*/
|
|
398
|
-
export function createProgressSender({ tool, statusId, runId = null, onStopRequested }) {
|
|
427
|
+
export function createProgressSender({ tool, statusId, runId = null, onStopRequested, onPendingInputs }) {
|
|
399
428
|
let inflight = Promise.resolve();
|
|
400
429
|
let stopSeen = false;
|
|
430
|
+
// 1182 — a room direction rides home on the same beat as the stop. Handed
|
|
431
|
+
// over on every reply that carries one (the inbox deduplicates), and never
|
|
432
|
+
// allowed to throw into the run.
|
|
433
|
+
const takeInputs = (res) => {
|
|
434
|
+
if (!onPendingInputs || !runId) return;
|
|
435
|
+
const list = readPendingInputs(res);
|
|
436
|
+
if (list.length === 0) return;
|
|
437
|
+
try {
|
|
438
|
+
onPendingInputs(list);
|
|
439
|
+
} catch {
|
|
440
|
+
/* a direction hand-off must never break the run */
|
|
441
|
+
}
|
|
442
|
+
};
|
|
401
443
|
const takeStop = (res) => {
|
|
444
|
+
takeInputs(res);
|
|
402
445
|
if (!res || res.stopRequested !== true) return false;
|
|
403
446
|
if (stopSeen || !onStopRequested) return true;
|
|
404
447
|
stopSeen = true;
|
|
@@ -606,6 +649,7 @@ async function runClaudeGatedCli({
|
|
|
606
649
|
permissionCallbacks,
|
|
607
650
|
sessionId = "",
|
|
608
651
|
resolveSessionId,
|
|
652
|
+
beforeSpawn,
|
|
609
653
|
log = console,
|
|
610
654
|
}) {
|
|
611
655
|
const server = await deps.startClaudePermissionServer({
|
|
@@ -630,6 +674,7 @@ async function runClaudeGatedCli({
|
|
|
630
674
|
signal,
|
|
631
675
|
env: claudeGateEnv(cfg),
|
|
632
676
|
onData,
|
|
677
|
+
beforeSpawn,
|
|
633
678
|
// 0785 — a CLI that rejects the gate flags is retried without them. This
|
|
634
679
|
// fires BEFORE that ungated retry is spawned, so the room hears about the
|
|
635
680
|
// downgrade first rather than after the fact.
|
|
@@ -657,7 +702,7 @@ function codexRunIsGated(cfg, caps) {
|
|
|
657
702
|
return shouldGateCodexPermissions({
|
|
658
703
|
vendor: detectVendor(cfg?.codingCmd),
|
|
659
704
|
runtimePermissions: caps?.runtimePermissions,
|
|
660
|
-
codeArgs: String(cfg?.codingCmd || "")
|
|
705
|
+
codeArgs: commandArgv(String(cfg?.codingCmd || "")).slice(1),
|
|
661
706
|
});
|
|
662
707
|
}
|
|
663
708
|
|
|
@@ -695,8 +740,19 @@ async function runCodexGatedSession({
|
|
|
695
740
|
signal,
|
|
696
741
|
onData,
|
|
697
742
|
onEvent,
|
|
743
|
+
beforeSpawn,
|
|
698
744
|
permissionCallbacks,
|
|
699
745
|
}) {
|
|
746
|
+
if (typeof beforeSpawn === "function" && (await beforeSpawn()) === false) {
|
|
747
|
+
return {
|
|
748
|
+
status: null,
|
|
749
|
+
stdout: "",
|
|
750
|
+
stderr: "",
|
|
751
|
+
aborted: true,
|
|
752
|
+
authorityLost: true,
|
|
753
|
+
error: new Error("execution authority lost before spawn"),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
700
756
|
const run = await deps.runCodexMcpSession({
|
|
701
757
|
cmd,
|
|
702
758
|
cwd,
|
|
@@ -713,6 +769,7 @@ async function runCodexGatedSession({
|
|
|
713
769
|
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
714
770
|
onData,
|
|
715
771
|
onEvent,
|
|
772
|
+
beforeSpawn,
|
|
716
773
|
...permissionCallbacks,
|
|
717
774
|
});
|
|
718
775
|
if (!codexMcpTransportUnavailable(run)) return run;
|
|
@@ -736,6 +793,7 @@ async function runCodexGatedSession({
|
|
|
736
793
|
signal,
|
|
737
794
|
env: codingChildEnv(cfg),
|
|
738
795
|
onData,
|
|
796
|
+
beforeSpawn,
|
|
739
797
|
});
|
|
740
798
|
return { ...fallback, permissionGateDropped: true };
|
|
741
799
|
}
|
|
@@ -820,7 +878,339 @@ function githubProvenance({ cfg, me, message, channelId, runId, summary }) {
|
|
|
820
878
|
};
|
|
821
879
|
}
|
|
822
880
|
|
|
823
|
-
|
|
881
|
+
/**
|
|
882
|
+
* Capture the exact local commit and remote branch value that a daemon push is
|
|
883
|
+
* allowed to replace. A failed probe is not "branch absent": without a known
|
|
884
|
+
* before-value there is no safe force-with-lease compensation, so callers must
|
|
885
|
+
* refuse the push.
|
|
886
|
+
*/
|
|
887
|
+
export function captureRemoteRefLease(git, repoPath, branch) {
|
|
888
|
+
const ref = `refs/heads/${branch}`;
|
|
889
|
+
const local = git(repoPath, ["rev-parse", "--verify", "HEAD"]);
|
|
890
|
+
const headSha = (local.stdout || "").trim();
|
|
891
|
+
if (local.status !== 0 || !headSha || /\s/.test(headSha)) {
|
|
892
|
+
return { ok: false, error: "could not resolve the exact local HEAD" };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const remote = git(repoPath, ["ls-remote", "--heads", "origin", ref]);
|
|
896
|
+
if (remote.status !== 0) {
|
|
897
|
+
return {
|
|
898
|
+
ok: false,
|
|
899
|
+
error: `could not read ${ref} from origin: ${(remote.stderr || "git ls-remote failed").trim()}`,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
const lines = (remote.stdout || "")
|
|
903
|
+
.split("\n")
|
|
904
|
+
.map((line) => line.trim())
|
|
905
|
+
.filter(Boolean);
|
|
906
|
+
if (lines.length === 0) {
|
|
907
|
+
return { ok: true, ref, headSha, priorSha: null };
|
|
908
|
+
}
|
|
909
|
+
if (lines.length !== 1) {
|
|
910
|
+
return { ok: false, error: `origin returned an ambiguous value for ${ref}` };
|
|
911
|
+
}
|
|
912
|
+
const [priorSha, remoteRef, ...extra] = lines[0].split(/\s+/);
|
|
913
|
+
if (!priorSha || remoteRef !== ref || extra.length > 0) {
|
|
914
|
+
return { ok: false, error: `origin returned an invalid value for ${ref}` };
|
|
915
|
+
}
|
|
916
|
+
// `ls-remote` proves the ref value but does not guarantee its object exists
|
|
917
|
+
// locally. Fetch the advertised ref without moving a local branch so a later
|
|
918
|
+
// restore can name the exact prior commit.
|
|
919
|
+
const fetched = git(repoPath, ["fetch", "--no-tags", "origin", ref]);
|
|
920
|
+
if (fetched.status !== 0) {
|
|
921
|
+
return { ok: false, error: `could not fetch the exact prior value of ${ref}` };
|
|
922
|
+
}
|
|
923
|
+
const priorObject = git(repoPath, ["cat-file", "-e", `${priorSha}^{commit}`]);
|
|
924
|
+
if (priorObject.status !== 0) {
|
|
925
|
+
return { ok: false, error: `the exact prior value of ${ref} is unavailable locally` };
|
|
926
|
+
}
|
|
927
|
+
// An exact lease must not become permission to rewrite somebody else's
|
|
928
|
+
// branch. Only a fast-forward from the value we observed may ship.
|
|
929
|
+
const fastForward = git(repoPath, ["merge-base", "--is-ancestor", priorSha, headSha]);
|
|
930
|
+
if (fastForward.status !== 0) {
|
|
931
|
+
return { ok: false, error: `local HEAD is not a descendant of the exact ${ref} value` };
|
|
932
|
+
}
|
|
933
|
+
return { ok: true, ref, headSha, priorSha };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function pushWithRemoteRefLease(git, repoPath, args, lease) {
|
|
937
|
+
const exactLease = `--force-with-lease=${lease.ref}:${lease.priorSha || ""}`;
|
|
938
|
+
return git(repoPath, [args[0], exactLease, ...args.slice(1)]);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Undo only the ref value written by this run. If another writer has advanced
|
|
943
|
+
* the branch, the lease fails and their work is left untouched.
|
|
944
|
+
*/
|
|
945
|
+
export function compensateRemoteRef(git, repoPath, lease) {
|
|
946
|
+
const restore = lease.priorSha ? `${lease.priorSha}:${lease.ref}` : `:${lease.ref}`;
|
|
947
|
+
let lastPush = null;
|
|
948
|
+
let lastProbe = null;
|
|
949
|
+
let repairable = true;
|
|
950
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
951
|
+
lastPush = git(repoPath, [
|
|
952
|
+
"push",
|
|
953
|
+
`--force-with-lease=${lease.ref}:${lease.headSha}`,
|
|
954
|
+
"origin",
|
|
955
|
+
restore,
|
|
956
|
+
]);
|
|
957
|
+
if (lastPush.status === 0) {
|
|
958
|
+
return { ...lastPush, confirmed: true, repairable: false };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// A transport failure can lose the successful response. Read the exact ref
|
|
962
|
+
// before deciding that rollback failed; repeating the same exact lease is
|
|
963
|
+
// safe, but a failed response by itself is not proof that Stop completed.
|
|
964
|
+
lastProbe = git(repoPath, ["ls-remote", "--heads", "origin", lease.ref]);
|
|
965
|
+
if (lastProbe.status === 0) {
|
|
966
|
+
const refs = (lastProbe.stdout || "")
|
|
967
|
+
.split("\n")
|
|
968
|
+
.map((line) => line.trim())
|
|
969
|
+
.filter(Boolean);
|
|
970
|
+
const [remoteSha, remoteRef, ...extra] = refs.length === 1
|
|
971
|
+
? refs[0].split(/\s+/)
|
|
972
|
+
: [];
|
|
973
|
+
const isExpected = lease.priorSha
|
|
974
|
+
? remoteSha === lease.priorSha && remoteRef === lease.ref && extra.length === 0
|
|
975
|
+
: refs.length === 0;
|
|
976
|
+
if (isExpected) {
|
|
977
|
+
return {
|
|
978
|
+
...lastPush,
|
|
979
|
+
status: 0,
|
|
980
|
+
confirmed: true,
|
|
981
|
+
repairable: false,
|
|
982
|
+
verifiedAfterAmbiguousPush: true,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
// Another writer won after our push. The exact lease protects their ref,
|
|
986
|
+
// so do not keep retrying a restoration that can no longer be ours.
|
|
987
|
+
if (
|
|
988
|
+
refs.length !== 1 ||
|
|
989
|
+
remoteSha !== lease.headSha ||
|
|
990
|
+
remoteRef !== lease.ref ||
|
|
991
|
+
extra.length > 0
|
|
992
|
+
) {
|
|
993
|
+
repairable = false;
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
...(lastPush || { status: 1, stdout: "", stderr: "compensation did not run" }),
|
|
1000
|
+
confirmed: false,
|
|
1001
|
+
// Replaying this same exact lease can never overwrite another writer. The
|
|
1002
|
+
// receipt below gives the daemon (and its operator) everything needed to
|
|
1003
|
+
// retry or repair without guessing which ref this run owned.
|
|
1004
|
+
repairable,
|
|
1005
|
+
receipt: {
|
|
1006
|
+
ref: lease.ref,
|
|
1007
|
+
headSha: lease.headSha,
|
|
1008
|
+
priorSha: lease.priorSha,
|
|
1009
|
+
},
|
|
1010
|
+
probeError:
|
|
1011
|
+
lastProbe?.status === 0
|
|
1012
|
+
? null
|
|
1013
|
+
: (lastProbe?.stderr || "could not verify the remote ref after compensation").trim(),
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function stopAfterRemoteRefCompensation(
|
|
1018
|
+
git,
|
|
1019
|
+
repoPath,
|
|
1020
|
+
branch,
|
|
1021
|
+
lease,
|
|
1022
|
+
{ closePR, prUrl } = {},
|
|
1023
|
+
) {
|
|
1024
|
+
const compensation = compensateRemoteRef(git, repoPath, lease);
|
|
1025
|
+
if (!compensation.confirmed) {
|
|
1026
|
+
return {
|
|
1027
|
+
status: "stop-compensation-pending",
|
|
1028
|
+
branch,
|
|
1029
|
+
compensation: {
|
|
1030
|
+
...compensation,
|
|
1031
|
+
receipt: { ...compensation.receipt, ...(prUrl ? { prUrl } : {}) },
|
|
1032
|
+
},
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
if (prUrl) {
|
|
1036
|
+
let closure = null;
|
|
1037
|
+
if (typeof closePR === "function") {
|
|
1038
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1039
|
+
try {
|
|
1040
|
+
closure = closePR(repoPath, prUrl);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
closure = {
|
|
1043
|
+
status: null,
|
|
1044
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
if (closure?.status === 0) return { status: "stopped", branch };
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
status: "stop-compensation-pending",
|
|
1052
|
+
branch,
|
|
1053
|
+
compensation: {
|
|
1054
|
+
confirmed: false,
|
|
1055
|
+
branchRestored: true,
|
|
1056
|
+
prClosed: false,
|
|
1057
|
+
repairable: true,
|
|
1058
|
+
stderr: closure?.stderr || "pull request closure could not be confirmed",
|
|
1059
|
+
receipt: {
|
|
1060
|
+
ref: lease.ref,
|
|
1061
|
+
headSha: lease.headSha,
|
|
1062
|
+
priorSha: lease.priorSha,
|
|
1063
|
+
prUrl,
|
|
1064
|
+
},
|
|
1065
|
+
},
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
return { status: "stopped", branch };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async function postFinalReportWithReplay({
|
|
1072
|
+
tool,
|
|
1073
|
+
args,
|
|
1074
|
+
runId,
|
|
1075
|
+
finishStatus,
|
|
1076
|
+
rollbackIfStopped,
|
|
1077
|
+
}) {
|
|
1078
|
+
try {
|
|
1079
|
+
return { result: await tool("post_report", args) };
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
// A transport can lose the successful response after the server atomically
|
|
1082
|
+
// commits a run-bound final report. Re-read Stop before doing anything:
|
|
1083
|
+
// once the person has stopped the run, compensate shipping rather than
|
|
1084
|
+
// replaying or publishing another report.
|
|
1085
|
+
const stopped = await rollbackIfStopped();
|
|
1086
|
+
if (stopped) return { stopped };
|
|
1087
|
+
|
|
1088
|
+
// Run-bound final reports are exact-replay safe in the server RPC. Reuse
|
|
1089
|
+
// the very same object so run id, finish status, receipt, and card identity
|
|
1090
|
+
// cannot drift between the uncertain attempt and its one replay. Legacy
|
|
1091
|
+
// reports have no atomic run settlement and remain single-attempt.
|
|
1092
|
+
if (!runId || !finishStatus) throw error;
|
|
1093
|
+
|
|
1094
|
+
try {
|
|
1095
|
+
return { result: await tool("post_report", args) };
|
|
1096
|
+
} catch (retryError) {
|
|
1097
|
+
const stoppedAfterRetry = await rollbackIfStopped();
|
|
1098
|
+
if (stoppedAfterRetry) return { stopped: stoppedAfterRetry };
|
|
1099
|
+
throw retryError;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* @param {{ tool: Function, runId: string, expectedIterateTurn: number,
|
|
1106
|
+
* claimId?: string, recoveryStore?: {
|
|
1107
|
+
* persist: (record: { runId: string, expectedIterateTurn: number, claimId: string }) => boolean,
|
|
1108
|
+
* remove: (claimId: string) => boolean
|
|
1109
|
+
* } }} options
|
|
1110
|
+
*/
|
|
1111
|
+
export async function claimDaemonIterate({
|
|
1112
|
+
tool,
|
|
1113
|
+
runId,
|
|
1114
|
+
expectedIterateTurn,
|
|
1115
|
+
claimId = randomUUID(),
|
|
1116
|
+
recoveryStore,
|
|
1117
|
+
}) {
|
|
1118
|
+
const record = { runId, expectedIterateTurn, claimId };
|
|
1119
|
+
const args = {
|
|
1120
|
+
runId,
|
|
1121
|
+
status: "running",
|
|
1122
|
+
claimIterate: true,
|
|
1123
|
+
expectedIterateTurn,
|
|
1124
|
+
claimId,
|
|
1125
|
+
};
|
|
1126
|
+
// Rolling upgrade compatibility: the preceding server supported the
|
|
1127
|
+
// idempotent claim token but not exact confirm/release. Without a recovery
|
|
1128
|
+
// store (created only when the full schema is advertised), preserve that
|
|
1129
|
+
// bounded same-token replay byte-for-byte and do not enable later guards.
|
|
1130
|
+
if (!recoveryStore) {
|
|
1131
|
+
try {
|
|
1132
|
+
return await tool("update_run", args);
|
|
1133
|
+
} catch {
|
|
1134
|
+
return await tool("update_run", args);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
if (!recoveryStore.persist(record)) {
|
|
1138
|
+
throw new Error("the Iterate claim recovery record could not be persisted");
|
|
1139
|
+
}
|
|
1140
|
+
let lastError;
|
|
1141
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1142
|
+
try {
|
|
1143
|
+
const result = await tool("update_run", args);
|
|
1144
|
+
if (
|
|
1145
|
+
Number.isSafeInteger(result?.iterateTurn) &&
|
|
1146
|
+
Number(result.iterateTurn) === expectedIterateTurn + 1
|
|
1147
|
+
) {
|
|
1148
|
+
const claimedTurn = Number(result.iterateTurn);
|
|
1149
|
+
const confirmed = await confirmDaemonIterateClaim({
|
|
1150
|
+
tool,
|
|
1151
|
+
runId,
|
|
1152
|
+
iterateTurn: claimedTurn,
|
|
1153
|
+
claimId,
|
|
1154
|
+
});
|
|
1155
|
+
if (!confirmed) {
|
|
1156
|
+
lastError = new Error("the Iterate claim could not be confirmed");
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
// Keep the exact receipt durable for the whole queue-job lifecycle.
|
|
1160
|
+
// The poller defers reconciliation while this job is active, then
|
|
1161
|
+
// exact-releases (or observes final settlement) before accepting more
|
|
1162
|
+
// work. A crash anywhere before/during the child leaves the receipt for
|
|
1163
|
+
// the next daemon instead of stranding a running generation.
|
|
1164
|
+
return { ...result, claimId };
|
|
1165
|
+
}
|
|
1166
|
+
lastError = new Error("the server did not return the claimed Iterate generation");
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
lastError = error;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Neither bounded response proved execution authority. Hand only this exact
|
|
1173
|
+
// token back; a lost release response leaves the durable record for startup.
|
|
1174
|
+
try {
|
|
1175
|
+
await releaseDaemonIterateClaim({ tool, recoveryStore, record });
|
|
1176
|
+
} catch {
|
|
1177
|
+
/* the durable record is the retry */
|
|
1178
|
+
}
|
|
1179
|
+
throw lastError;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Prove that this worker still owns one exact Iterate generation. Stop,
|
|
1184
|
+
* release, a final report, or a newer generation all make the replay-safe
|
|
1185
|
+
* confirmation return `not-owned` without changing the run.
|
|
1186
|
+
*/
|
|
1187
|
+
export async function confirmDaemonIterateClaim({ tool, runId, iterateTurn, claimId }) {
|
|
1188
|
+
const args = {
|
|
1189
|
+
runId,
|
|
1190
|
+
confirmIterateClaim: true,
|
|
1191
|
+
expectedIterateTurn: iterateTurn,
|
|
1192
|
+
claimId,
|
|
1193
|
+
};
|
|
1194
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1195
|
+
try {
|
|
1196
|
+
const result = await tool("update_run", args);
|
|
1197
|
+
if (
|
|
1198
|
+
result?.iterateClaimKind === "confirmed" &&
|
|
1199
|
+
Number.isSafeInteger(result?.iterateTurn) &&
|
|
1200
|
+
Number(result.iterateTurn) === iterateTurn
|
|
1201
|
+
) {
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
return false;
|
|
1205
|
+
} catch {
|
|
1206
|
+
// Read-only and exact-token fenced: one replay is safe. Two missing
|
|
1207
|
+
// responses mean execution authority is unproven, so fail closed.
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId, iterateTurn = 0, provenance = {}, usageArgs = {}, shouldContinue }) {
|
|
824
1214
|
const tag = requesterTag(requester);
|
|
825
1215
|
const lead = tag ? `${tag} — ` : "";
|
|
826
1216
|
// `parentId` here is the run's thread root. Terminal outcomes ask the server
|
|
@@ -837,6 +1227,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
837
1227
|
});
|
|
838
1228
|
return { status: "nothing-staged", branch };
|
|
839
1229
|
}
|
|
1230
|
+
if (shouldContinue && !(await shouldContinue())) return { status: "stopped", branch };
|
|
840
1231
|
const commit = deps.git(repoPath, ["commit", "-m", commitMessage(task, provenance)]);
|
|
841
1232
|
if (commit.status !== 0) {
|
|
842
1233
|
await tool("post_message", {
|
|
@@ -847,7 +1238,28 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
847
1238
|
});
|
|
848
1239
|
return { status: "commit-failed", branch };
|
|
849
1240
|
}
|
|
850
|
-
|
|
1241
|
+
// Re-check at the irreversible external boundary. The final progress poll
|
|
1242
|
+
// is not enough: Stop can land while the local commit is being created.
|
|
1243
|
+
if (shouldContinue && !(await shouldContinue())) return { status: "stopped", branch };
|
|
1244
|
+
const pushLease = captureRemoteRefLease(deps.git, repoPath, branch);
|
|
1245
|
+
if (!pushLease.ok) {
|
|
1246
|
+
await tool("post_message", {
|
|
1247
|
+
channelId,
|
|
1248
|
+
parentId,
|
|
1249
|
+
broadcast: true,
|
|
1250
|
+
body: `Approved, but hilos refused to push because branch ownership could not be established: ${pushLease.error}`,
|
|
1251
|
+
});
|
|
1252
|
+
return { status: "push-ownership-unknown", branch };
|
|
1253
|
+
}
|
|
1254
|
+
// The remote probe may itself take long enough for Stop to arrive. Do not
|
|
1255
|
+
// let a lease captured before that Stop authorize a later push.
|
|
1256
|
+
if (shouldContinue && !(await shouldContinue())) return { status: "stopped", branch };
|
|
1257
|
+
const push = pushWithRemoteRefLease(
|
|
1258
|
+
deps.git,
|
|
1259
|
+
repoPath,
|
|
1260
|
+
["push", "-u", "origin", branch],
|
|
1261
|
+
pushLease,
|
|
1262
|
+
);
|
|
851
1263
|
if (push.status !== 0) {
|
|
852
1264
|
await tool("post_message", {
|
|
853
1265
|
channelId,
|
|
@@ -857,6 +1269,13 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
857
1269
|
});
|
|
858
1270
|
return { status: "push-failed", branch };
|
|
859
1271
|
}
|
|
1272
|
+
// A remote ref update is atomic outside hilos, so Stop cannot interrupt a
|
|
1273
|
+
// push already in flight. Fence the next external effect immediately: a
|
|
1274
|
+
// Stop that landed during push must never go on to create/reuse a PR or
|
|
1275
|
+
// publish a completion report.
|
|
1276
|
+
if (shouldContinue && !(await shouldContinue())) {
|
|
1277
|
+
return stopAfterRemoteRefCompensation(deps.git, repoPath, branch, pushLease);
|
|
1278
|
+
}
|
|
860
1279
|
const { title, body } = prTitleBody(task, branch, provenance);
|
|
861
1280
|
// Continuing an existing PR (request-changes rework): the push already
|
|
862
1281
|
// updated it — reuse its URL instead of opening a duplicate. Otherwise open
|
|
@@ -864,12 +1283,34 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
864
1283
|
const pr = existingPrUrl
|
|
865
1284
|
? { ok: true, url: existingPrUrl }
|
|
866
1285
|
: deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
|
|
1286
|
+
const rollbackIfStopped = async () => {
|
|
1287
|
+
if (!shouldContinue || (await shouldContinue())) return null;
|
|
1288
|
+
return stopAfterRemoteRefCompensation(deps.git, repoPath, branch, pushLease, {
|
|
1289
|
+
...(!existingPrUrl && pr.ok && pr.url
|
|
1290
|
+
? { closePR: deps.closePR, prUrl: pr.url }
|
|
1291
|
+
: {}),
|
|
1292
|
+
});
|
|
1293
|
+
};
|
|
1294
|
+
const stoppedAfterPr = await rollbackIfStopped();
|
|
1295
|
+
if (stoppedAfterPr) return stoppedAfterPr;
|
|
867
1296
|
// Seamless single card (0289): when a live status message was streaming this
|
|
868
1297
|
// run, SETTLE the report onto it in place; the workspace setting decides
|
|
869
1298
|
// whether that final report is also channel-visible. Without a status id
|
|
870
1299
|
// (older server / status post failed) fall back to posting a fresh report.
|
|
871
1300
|
const reportArgs = {
|
|
872
1301
|
channelId,
|
|
1302
|
+
...(runId ? { runId } : {}),
|
|
1303
|
+
...(runId ? { iterateTurn } : {}),
|
|
1304
|
+
...(runId ? { reportRequestId: randomUUID() } : {}),
|
|
1305
|
+
...(runId
|
|
1306
|
+
? {
|
|
1307
|
+
finishStatus: pr.ok && pr.url ? "awaiting_review" : "failed",
|
|
1308
|
+
finishBranch: branch,
|
|
1309
|
+
...(!(pr.ok && pr.url)
|
|
1310
|
+
? { finishReason: oneLine((pr.stderr || "GitHub did not return a pull request URL").trim(), 300) }
|
|
1311
|
+
: {}),
|
|
1312
|
+
}
|
|
1313
|
+
: {}),
|
|
873
1314
|
title: `Shipped: ${title}`,
|
|
874
1315
|
summary:
|
|
875
1316
|
pr.ok && pr.url
|
|
@@ -887,28 +1328,28 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
887
1328
|
// 0792 — keep the id of the card this run settled onto, so the caller can
|
|
888
1329
|
// mark it with the run's transcript. `settleId` is that card when the run
|
|
889
1330
|
// streamed onto a live status message; otherwise it is the fresh report.
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1331
|
+
const stoppedBeforeReport = await rollbackIfStopped();
|
|
1332
|
+
if (stoppedBeforeReport) return stoppedBeforeReport;
|
|
1333
|
+
const finalReportArgs = settleId
|
|
1334
|
+
? { ...reportArgs, messageId: settleId, broadcast: true }
|
|
1335
|
+
: { ...reportArgs, parentId, broadcast: true };
|
|
1336
|
+
const reportAttempt = await postFinalReportWithReplay({
|
|
1337
|
+
tool,
|
|
1338
|
+
args: finalReportArgs,
|
|
1339
|
+
runId,
|
|
1340
|
+
finishStatus: reportArgs.finishStatus,
|
|
1341
|
+
rollbackIfStopped,
|
|
1342
|
+
});
|
|
1343
|
+
if (reportAttempt.stopped) return reportAttempt.stopped;
|
|
1344
|
+
const reportRes = reportAttempt.result;
|
|
1345
|
+
const stoppedAfterReport = await rollbackIfStopped();
|
|
1346
|
+
if (stoppedAfterReport) return stoppedAfterReport;
|
|
896
1347
|
const reportMsgId = settleId || reportRes?.messageId || null;
|
|
897
1348
|
// Work produced a PR → attach it to the channel so its live pill shows up.
|
|
898
1349
|
await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
|
|
902
|
-
await tool("update_run", {
|
|
903
|
-
runId,
|
|
904
|
-
...(pr.ok && pr.url ? { prUrl: pr.url } : {}),
|
|
905
|
-
status: "awaiting_review",
|
|
906
|
-
// 0792: record the card even when it was a fresh report rather than a
|
|
907
|
-
// settle — it is the run's card either way, and the transcript upload
|
|
908
|
-
// falls back to this exact field when it is not handed an id.
|
|
909
|
-
...(reportMsgId ? { reportMsgId } : {}),
|
|
910
|
-
}).catch(() => {});
|
|
911
|
-
}
|
|
1350
|
+
// The run gate, PR/branch receipt, and report pointer were committed by
|
|
1351
|
+
// post_report under one run-row lock. A second update_run here used to
|
|
1352
|
+
// leave a window where the card existed while Stop still looked current.
|
|
912
1353
|
return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null, reportMsgId };
|
|
913
1354
|
}
|
|
914
1355
|
|
|
@@ -949,14 +1390,34 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
949
1390
|
* switched to `main` would otherwise make `gh` try to open main → main. Used
|
|
950
1391
|
* only when NOT gated (bias-to-action).
|
|
951
1392
|
*/
|
|
952
|
-
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId, provenance = {}, usageArgs = {} }) {
|
|
1393
|
+
export async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId, iterateTurn = 0, provenance = {}, usageArgs = {}, shouldContinue }) {
|
|
953
1394
|
const tag = requesterTag(requester);
|
|
954
1395
|
const lead = tag ? `${tag} — ` : "";
|
|
955
1396
|
const currentBranch =
|
|
956
1397
|
(deps.git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout || "").trim();
|
|
957
1398
|
const ship = selfDrivenShipPlan(currentBranch, branch);
|
|
958
|
-
|
|
959
|
-
|
|
1399
|
+
if (shouldContinue && !(await shouldContinue())) return { status: "stopped", branch };
|
|
1400
|
+
const pushLease = captureRemoteRefLease(deps.git, repoPath, ship.headBranch);
|
|
1401
|
+
if (pushLease.ok && shouldContinue && !(await shouldContinue())) {
|
|
1402
|
+
return { status: "stopped", branch };
|
|
1403
|
+
}
|
|
1404
|
+
const push = pushLease.ok
|
|
1405
|
+
? pushWithRemoteRefLease(deps.git, repoPath, ship.pushArgs, pushLease)
|
|
1406
|
+
: {
|
|
1407
|
+
status: 1,
|
|
1408
|
+
stdout: "",
|
|
1409
|
+
stderr: `hilos refused to push because branch ownership could not be established: ${pushLease.error}`,
|
|
1410
|
+
};
|
|
1411
|
+
if (shouldContinue && !(await shouldContinue())) {
|
|
1412
|
+
if (push.status === 0 && pushLease.ok) {
|
|
1413
|
+
return stopAfterRemoteRefCompensation(deps.git, repoPath, branch, pushLease);
|
|
1414
|
+
}
|
|
1415
|
+
return { status: "stopped", branch };
|
|
1416
|
+
}
|
|
1417
|
+
// A failed/declined push cannot ship an existing PR either: its remote head
|
|
1418
|
+
// is not this run's verified commit.
|
|
1419
|
+
let prUrl = push.status === 0 && deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
|
|
1420
|
+
const existingPrUrl = prUrl;
|
|
960
1421
|
let prAttempt = null;
|
|
961
1422
|
if (!prUrl && push.status === 0) {
|
|
962
1423
|
const { title, body } = prTitleBody(task, ship.headBranch, provenance);
|
|
@@ -968,6 +1429,16 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
968
1429
|
});
|
|
969
1430
|
prUrl = prAttempt.ok && prAttempt.url ? prAttempt.url : null;
|
|
970
1431
|
}
|
|
1432
|
+
const rollbackIfStopped = async () => {
|
|
1433
|
+
if (!shouldContinue || (await shouldContinue())) return null;
|
|
1434
|
+
return push.status === 0 && pushLease.ok
|
|
1435
|
+
? stopAfterRemoteRefCompensation(deps.git, repoPath, branch, pushLease, {
|
|
1436
|
+
...(!existingPrUrl && prUrl ? { closePR: deps.closePR, prUrl } : {}),
|
|
1437
|
+
})
|
|
1438
|
+
: { status: "stopped", branch };
|
|
1439
|
+
};
|
|
1440
|
+
const stoppedAfterPr = await rollbackIfStopped();
|
|
1441
|
+
if (stoppedAfterPr) return stoppedAfterPr;
|
|
971
1442
|
const { title } = prTitleBody(task, ship.headBranch, provenance);
|
|
972
1443
|
const recoveredNote = ship.recoveredFrom
|
|
973
1444
|
? ` The child had switched to \`${ship.recoveredFrom}\`; hilos recovered its HEAD onto \`${ship.headBranch}\`.`
|
|
@@ -984,6 +1455,23 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
984
1455
|
// Otherwise post a fresh final report.
|
|
985
1456
|
const reportArgs = {
|
|
986
1457
|
channelId,
|
|
1458
|
+
...(runId ? { runId } : {}),
|
|
1459
|
+
...(runId ? { iterateTurn } : {}),
|
|
1460
|
+
...(runId ? { reportRequestId: randomUUID() } : {}),
|
|
1461
|
+
...(runId
|
|
1462
|
+
? {
|
|
1463
|
+
finishStatus: prUrl ? "awaiting_review" : "failed",
|
|
1464
|
+
finishBranch: ship.headBranch,
|
|
1465
|
+
...(!prUrl
|
|
1466
|
+
? {
|
|
1467
|
+
finishReason:
|
|
1468
|
+
push.status !== 0
|
|
1469
|
+
? oneLine((push.stderr || "git push failed").trim(), 300)
|
|
1470
|
+
: prFailure || "GitHub did not return a pull request URL",
|
|
1471
|
+
}
|
|
1472
|
+
: {}),
|
|
1473
|
+
}
|
|
1474
|
+
: {}),
|
|
987
1475
|
title: prUrl
|
|
988
1476
|
? `Shipped: ${title}`
|
|
989
1477
|
: push.status === 0
|
|
@@ -1004,32 +1492,26 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
1004
1492
|
// 0787 — an autonomous run's receipt rides its one and only report.
|
|
1005
1493
|
...usageArgs,
|
|
1006
1494
|
};
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1495
|
+
const stoppedBeforeReport = await rollbackIfStopped();
|
|
1496
|
+
if (stoppedBeforeReport) return stoppedBeforeReport;
|
|
1497
|
+
const finalReportArgs = settleId
|
|
1498
|
+
? { ...reportArgs, messageId: settleId, broadcast: true }
|
|
1499
|
+
: { ...reportArgs, parentId, broadcast: true };
|
|
1500
|
+
const reportAttempt = await postFinalReportWithReplay({
|
|
1501
|
+
tool,
|
|
1502
|
+
args: finalReportArgs,
|
|
1503
|
+
runId,
|
|
1504
|
+
finishStatus: reportArgs.finishStatus,
|
|
1505
|
+
rollbackIfStopped,
|
|
1506
|
+
});
|
|
1507
|
+
if (reportAttempt.stopped) return reportAttempt.stopped;
|
|
1508
|
+
const reportRes = reportAttempt.result;
|
|
1509
|
+
const stoppedAfterReport = await rollbackIfStopped();
|
|
1510
|
+
if (stoppedAfterReport) return stoppedAfterReport;
|
|
1013
1511
|
// 0792 — the card this run settled onto, for the transcript stamp.
|
|
1014
1512
|
const reportMsgId = settleId || reportRes?.messageId || null;
|
|
1015
1513
|
if (prUrl) await linkPrFromUrl({ tool, channelId, url: prUrl });
|
|
1016
|
-
//
|
|
1017
|
-
if (runId) {
|
|
1018
|
-
await tool("update_run", {
|
|
1019
|
-
runId,
|
|
1020
|
-
...(prUrl ? { prUrl } : {}),
|
|
1021
|
-
status: prUrl ? "awaiting_review" : "failed",
|
|
1022
|
-
...(!prUrl
|
|
1023
|
-
? {
|
|
1024
|
-
reason:
|
|
1025
|
-
push.status !== 0
|
|
1026
|
-
? oneLine((push.stderr || "git push failed").trim(), 300)
|
|
1027
|
-
: prFailure || "GitHub did not return a pull request URL",
|
|
1028
|
-
}
|
|
1029
|
-
: {}),
|
|
1030
|
-
...(reportMsgId ? { reportMsgId } : {}),
|
|
1031
|
-
}).catch(() => {});
|
|
1032
|
-
}
|
|
1514
|
+
// post_report atomically bound this card, branch/PR receipt, and final gate.
|
|
1033
1515
|
const status =
|
|
1034
1516
|
push.status !== 0 ? "commit-local" : prUrl ? "pushed" : "pr-failed";
|
|
1035
1517
|
return {
|
|
@@ -1117,10 +1599,21 @@ export function dmJudgmentBlock(implicitDm) {
|
|
|
1117
1599
|
* `error` is set when the model produced nothing (so the caller can be honest
|
|
1118
1600
|
* about a timeout vs a missing binary instead of inventing a reply).
|
|
1119
1601
|
*/
|
|
1120
|
-
export async function routeIntent({
|
|
1121
|
-
|
|
1602
|
+
export async function routeIntent({
|
|
1603
|
+
name,
|
|
1604
|
+
repoFullName,
|
|
1605
|
+
transcript,
|
|
1606
|
+
workspaceMemory,
|
|
1607
|
+
cfg,
|
|
1608
|
+
signal,
|
|
1609
|
+
hasActiveRun = false,
|
|
1610
|
+
runCliFn,
|
|
1611
|
+
implicitDm = false,
|
|
1612
|
+
cwd = /** @type {string | undefined} */ (undefined),
|
|
1613
|
+
}) {
|
|
1614
|
+
const doRun = runCliFn || runCli;
|
|
1122
1615
|
const cmd = chatCmdFor(cfg);
|
|
1123
|
-
const parts = cmd
|
|
1616
|
+
const parts = commandArgv(cmd);
|
|
1124
1617
|
// When this thread already owns an OPEN pull request (a follow-up reply), the
|
|
1125
1618
|
// router ALSO reads whether the latest message continues that PR, wants a
|
|
1126
1619
|
// separate one, or is unclear — the primary signal for same-PR continuation
|
|
@@ -1160,6 +1653,7 @@ export async function routeIntent({ name, repoFullName, transcript, workspaceMem
|
|
|
1160
1653
|
timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
|
|
1161
1654
|
label: "thinking",
|
|
1162
1655
|
signal,
|
|
1656
|
+
cwd,
|
|
1163
1657
|
});
|
|
1164
1658
|
if (run.aborted || signal?.aborted) return { aborted: true };
|
|
1165
1659
|
const raw = (run.stdout || "").trim();
|
|
@@ -1342,18 +1836,29 @@ export function planAckPrompt(o) {
|
|
|
1342
1836
|
* CLI. Bounded (≤45s) + best-effort: returns trimmed text, or null on
|
|
1343
1837
|
* empty/timeout/error so the run keeps the instant template and never stalls.
|
|
1344
1838
|
*/
|
|
1345
|
-
async function proposePlanAck({
|
|
1839
|
+
export async function proposePlanAck({
|
|
1840
|
+
task,
|
|
1841
|
+
transcript,
|
|
1842
|
+
repoFullName,
|
|
1843
|
+
cfg,
|
|
1844
|
+
signal,
|
|
1845
|
+
cwd = /** @type {string | undefined} */ (undefined),
|
|
1846
|
+
beforeSpawn = /** @type {(() => boolean | Promise<boolean>) | undefined} */ (undefined),
|
|
1847
|
+
runCliFn = runCli,
|
|
1848
|
+
}) {
|
|
1346
1849
|
const cmd = chatCmdFor(cfg);
|
|
1347
1850
|
if (!cmd) return null;
|
|
1348
|
-
const parts = cmd
|
|
1851
|
+
const parts = commandArgv(cmd);
|
|
1349
1852
|
const prompt = planAckPrompt({ task, transcript, repoFullName });
|
|
1350
|
-
const run = await
|
|
1853
|
+
const run = await runCliFn({
|
|
1351
1854
|
cmd: parts[0],
|
|
1352
1855
|
args: [...parts.slice(1), prompt],
|
|
1353
1856
|
timeoutMs: Math.min(cfg.chatTimeoutMs || 90000, 45000),
|
|
1354
1857
|
label: "thinking",
|
|
1355
1858
|
signal,
|
|
1859
|
+
cwd,
|
|
1356
1860
|
env: codingChildEnv(cfg),
|
|
1861
|
+
beforeSpawn,
|
|
1357
1862
|
});
|
|
1358
1863
|
if (run.aborted || signal?.aborted) return null;
|
|
1359
1864
|
const text = (run.stdout || "").trim();
|
|
@@ -1372,7 +1877,21 @@ async function reactQuietly({ tool, caps, message, emoji }) {
|
|
|
1372
1877
|
}
|
|
1373
1878
|
|
|
1374
1879
|
/** Run the FAST chat CLI to produce a reply, using recent channel context. */
|
|
1375
|
-
async function respondConversationally({
|
|
1880
|
+
async function respondConversationally({
|
|
1881
|
+
message,
|
|
1882
|
+
channelId,
|
|
1883
|
+
tool,
|
|
1884
|
+
me,
|
|
1885
|
+
cfg,
|
|
1886
|
+
repoLink,
|
|
1887
|
+
parentId,
|
|
1888
|
+
workspaceMemory,
|
|
1889
|
+
signal,
|
|
1890
|
+
context,
|
|
1891
|
+
caps = {},
|
|
1892
|
+
runCliFn = runCli,
|
|
1893
|
+
pathExistsFn = existsSync,
|
|
1894
|
+
}) {
|
|
1376
1895
|
const implicitDm = message?.implicit === "dm";
|
|
1377
1896
|
const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
|
|
1378
1897
|
const name = me?.agentName || "an assistant";
|
|
@@ -1381,10 +1900,20 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
1381
1900
|
// "repository" line never claims a folder path is a repo. When there's no repo
|
|
1382
1901
|
// but this channel maps to a local folder on THIS machine, say that instead.
|
|
1383
1902
|
const folderPath = !repoLink ? resolveFolderPath(cfg, channelId) : null;
|
|
1903
|
+
const repoPath = repoLink ? resolveRepoPath(cfg, repoLink.repo_full_name) : null;
|
|
1904
|
+
const mappedProjectPath = repoPath || folderPath || null;
|
|
1905
|
+
const projectCwd =
|
|
1906
|
+
mappedProjectPath && pathExistsFn(mappedProjectPath)
|
|
1907
|
+
? mappedProjectPath
|
|
1908
|
+
: undefined;
|
|
1384
1909
|
const repoLine = repoLink
|
|
1385
|
-
?
|
|
1910
|
+
? repoPath && !projectCwd
|
|
1911
|
+
? `This channel is connected to the repository ${repoLink.repo_full_name}, but its configured local checkout ${repoPath} is unavailable on this machine. Answer from the conversation and don't claim you inspected it.`
|
|
1912
|
+
: `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
|
|
1386
1913
|
: folderPath
|
|
1387
|
-
?
|
|
1914
|
+
? projectCwd
|
|
1915
|
+
? `This channel is connected to the local folder ${folderPath} on this machine; assume that folder for any file work — don't ask which one.`
|
|
1916
|
+
: `This channel is mapped to the local folder ${folderPath}, but that folder is unavailable on this machine. Answer from the conversation and don't claim you inspected it.`
|
|
1388
1917
|
: `If asked to change code, note that a repo isn't linked to this channel yet.`;
|
|
1389
1918
|
const prompt =
|
|
1390
1919
|
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
@@ -1398,7 +1927,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
1398
1927
|
// chatCmd is unset) bounded by chatTimeoutMs, so a casual reply lands in seconds
|
|
1399
1928
|
// and a stalled model can't dead-air the channel for the full coding timeout.
|
|
1400
1929
|
const cmd = chatCmdFor(cfg);
|
|
1401
|
-
const parts = cmd
|
|
1930
|
+
const parts = commandArgv(cmd);
|
|
1402
1931
|
const chatVendor = detectVendor(cmd);
|
|
1403
1932
|
const webArgs = chatWebArgs(chatVendor, parts.slice(1), {
|
|
1404
1933
|
enabled: cfg.webSearch !== false,
|
|
@@ -1431,12 +1960,13 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
1431
1960
|
|
|
1432
1961
|
let run;
|
|
1433
1962
|
try {
|
|
1434
|
-
run = await
|
|
1963
|
+
run = await runCliFn({
|
|
1435
1964
|
cmd: parts[0],
|
|
1436
1965
|
args: [...parts.slice(1), ...webArgs, prompt],
|
|
1437
1966
|
timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
|
|
1438
1967
|
label: "thinking",
|
|
1439
1968
|
signal,
|
|
1969
|
+
cwd: projectCwd,
|
|
1440
1970
|
env: codingChildWebEnv(cfg, chatVendor),
|
|
1441
1971
|
});
|
|
1442
1972
|
} finally {
|
|
@@ -1625,7 +2155,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
|
|
|
1625
2155
|
// stdout and never stages/commits anything. Review uses the fast chat command
|
|
1626
2156
|
// when set (no acceptEdits → can't write), falling back to the coding command.
|
|
1627
2157
|
const cmd = cfg.reviewCmd || chatCmdFor(cfg);
|
|
1628
|
-
const parts = String(cmd)
|
|
2158
|
+
const parts = commandArgv(String(cmd));
|
|
1629
2159
|
let sandboxDir = null;
|
|
1630
2160
|
try {
|
|
1631
2161
|
sandboxDir = mkdtempSync(join(tmpdir(), "hilos-review-"));
|
|
@@ -2022,7 +2552,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
2022
2552
|
// Run the coding CLI in the folder, streaming progress + honoring timeouts and
|
|
2023
2553
|
// the abort signal (reuses runCli via deps + the progress-emitter machinery).
|
|
2024
2554
|
const runFolderCli = async (promptText) => {
|
|
2025
|
-
const parts = cfg.codingCmd
|
|
2555
|
+
const parts = commandArgv(cfg.codingCmd);
|
|
2026
2556
|
const vendor = detectVendor(cfg.codingCmd);
|
|
2027
2557
|
const streamOn = Boolean(caps.postProgress);
|
|
2028
2558
|
const streamArgs = streamOn ? codeStreamArgs(vendor) : [];
|
|
@@ -2556,7 +3086,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
2556
3086
|
|
|
2557
3087
|
/** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
|
|
2558
3088
|
* cancels an in-flight run — the queue fires it when a human says "stop". */
|
|
2559
|
-
export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
|
|
3089
|
+
export async function handleTask({ message, channelId, tool, me, caps = {}, iterateClaimRecoveryStore = null }, cfg, depsOverride, opts = {}) {
|
|
2560
3090
|
if (message.dispatch?.briefMarkdown) {
|
|
2561
3091
|
message = { ...message, body: `${message.body}\n\n${message.dispatch.briefMarkdown}` };
|
|
2562
3092
|
}
|
|
@@ -2572,12 +3102,32 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2572
3102
|
// 0860 — an untagged DM wake: addressed by the room, not by an @-handle. The
|
|
2573
3103
|
// one context where the router/chat prompts offer the quiet option.
|
|
2574
3104
|
const implicitDm = message.implicit === "dm";
|
|
3105
|
+
// Set only when merge_pr returned the typed 0991 recovery hand-off. It turns
|
|
3106
|
+
// the same human merge instruction into an existing-PR coding turn — no
|
|
3107
|
+
// second prompt and no semantic router are allowed between those two facts.
|
|
3108
|
+
// A report-card click reaches the same slot through signed list_mentions
|
|
3109
|
+
// metadata instead of first calling merge_pr in this process.
|
|
3110
|
+
let forcedConflictRecovery = null;
|
|
2575
3111
|
|
|
2576
3112
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
2577
3113
|
// A folder link (0324) carries the folder PATH in repo_full_name for display —
|
|
2578
3114
|
// it is NOT a GitHub repo, so it must never be picked as the channel's repo.
|
|
2579
3115
|
// Exclude kind='folder'; pr/branch/repo rows with repo_full_name still count.
|
|
2580
3116
|
const repoLink = links.find((l) => l.kind !== "folder" && l.repo_full_name);
|
|
3117
|
+
if (message.conflictRecovery) {
|
|
3118
|
+
forcedConflictRecovery = validConflictRecovery(
|
|
3119
|
+
message.conflictRecovery,
|
|
3120
|
+
repoLink?.repo_full_name,
|
|
3121
|
+
);
|
|
3122
|
+
if (!forcedConflictRecovery) {
|
|
3123
|
+
await tool("post_message", {
|
|
3124
|
+
channelId,
|
|
3125
|
+
parentId,
|
|
3126
|
+
body: "I couldn't verify the conflict-repair target, so I left the repository unchanged.",
|
|
3127
|
+
}).catch(() => {});
|
|
3128
|
+
return { status: "conflict-target-refused" };
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
2581
3131
|
// Workspace memory ("soul") — shared project context the agent should know.
|
|
2582
3132
|
const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
|
|
2583
3133
|
memory: null,
|
|
@@ -2634,7 +3184,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2634
3184
|
mode,
|
|
2635
3185
|
// A manager-routed dispatch is a brief to implement, not an instruction to
|
|
2636
3186
|
// land something — its body is assembled text, never a person's sentence.
|
|
2637
|
-
canRelay: Boolean(caps.prActions) && !message.dispatch,
|
|
3187
|
+
canRelay: Boolean(caps.prActions) && !message.dispatch && !forcedConflictRecovery,
|
|
2638
3188
|
});
|
|
2639
3189
|
if (relay.relay) {
|
|
2640
3190
|
const verb = relay.action;
|
|
@@ -2650,7 +3200,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2650
3200
|
if (reply.post) {
|
|
2651
3201
|
await tool("post_message", { channelId, parentId, body: reply.post }).catch(() => {});
|
|
2652
3202
|
}
|
|
2653
|
-
|
|
3203
|
+
const recovery = validConflictRecovery(reply.conflict, repoLink?.repo_full_name);
|
|
3204
|
+
if (verb === "merge" && recovery) {
|
|
3205
|
+
forcedConflictRecovery = recovery;
|
|
3206
|
+
console.log(
|
|
3207
|
+
` merge → PR #${recovery.prNumber} conflicts with ${recovery.baseBranch}; continuing as a repair run`,
|
|
3208
|
+
);
|
|
3209
|
+
} else {
|
|
3210
|
+
return { status: reply.ok ? `pr-${verb}d` : "pr-action-refused", action: verb };
|
|
3211
|
+
}
|
|
2654
3212
|
}
|
|
2655
3213
|
}
|
|
2656
3214
|
|
|
@@ -2660,6 +3218,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2660
3218
|
if (!repoLink) {
|
|
2661
3219
|
const folderPath = resolveFolderPath(cfg, channelId);
|
|
2662
3220
|
if (folderPath) {
|
|
3221
|
+
const folderCwd = deps.pathExists(folderPath) ? folderPath : undefined;
|
|
2663
3222
|
// Durable report-card fallback: list_mentions projects a still-pending
|
|
2664
3223
|
// in-place decision into the queue. Deploy only while that decision
|
|
2665
3224
|
// remains pending; a settled report is a silent no-op, which prevents a
|
|
@@ -2756,15 +3315,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2756
3315
|
// CLI router. There is still no keyword/regex intent list.
|
|
2757
3316
|
if (!routed) {
|
|
2758
3317
|
routed = await routeIntent({
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
3318
|
+
name: me?.agentName || "an assistant",
|
|
3319
|
+
repoFullName: folderPath,
|
|
3320
|
+
transcript: context.transcript,
|
|
3321
|
+
workspaceMemory,
|
|
3322
|
+
cfg,
|
|
3323
|
+
signal,
|
|
3324
|
+
runCliFn: deps.runCli,
|
|
3325
|
+
implicitDm,
|
|
3326
|
+
cwd: folderCwd,
|
|
3327
|
+
});
|
|
2768
3328
|
}
|
|
2769
3329
|
if (routed.aborted || signal?.aborted) {
|
|
2770
3330
|
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
@@ -2795,13 +3355,46 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2795
3355
|
// Not a coding task → the classifier is finished. The separate responder
|
|
2796
3356
|
// below owns the answer and keeps its normal tools.
|
|
2797
3357
|
}
|
|
2798
|
-
await respondConversationally({
|
|
3358
|
+
await respondConversationally({
|
|
3359
|
+
message,
|
|
3360
|
+
channelId,
|
|
3361
|
+
tool,
|
|
3362
|
+
me,
|
|
3363
|
+
cfg,
|
|
3364
|
+
repoLink,
|
|
3365
|
+
parentId,
|
|
3366
|
+
workspaceMemory,
|
|
3367
|
+
signal,
|
|
3368
|
+
context,
|
|
3369
|
+
caps,
|
|
3370
|
+
runCliFn: deps.runCli,
|
|
3371
|
+
pathExistsFn: deps.pathExists,
|
|
3372
|
+
});
|
|
2799
3373
|
return { status: "chat" };
|
|
2800
3374
|
}
|
|
2801
3375
|
const repoFullName = repoLink.repo_full_name;
|
|
3376
|
+
const mappedRepoPath = resolveRepoPath(cfg, repoFullName);
|
|
3377
|
+
const mappedRepoCwd =
|
|
3378
|
+
mappedRepoPath && deps.pathExists(mappedRepoPath)
|
|
3379
|
+
? mappedRepoPath
|
|
3380
|
+
: undefined;
|
|
2802
3381
|
|
|
2803
|
-
if (mode === "ask") {
|
|
2804
|
-
await respondConversationally({
|
|
3382
|
+
if (mode === "ask" && !forcedConflictRecovery) {
|
|
3383
|
+
await respondConversationally({
|
|
3384
|
+
message,
|
|
3385
|
+
channelId,
|
|
3386
|
+
tool,
|
|
3387
|
+
me,
|
|
3388
|
+
cfg,
|
|
3389
|
+
repoLink,
|
|
3390
|
+
parentId,
|
|
3391
|
+
workspaceMemory,
|
|
3392
|
+
signal,
|
|
3393
|
+
context,
|
|
3394
|
+
caps,
|
|
3395
|
+
runCliFn: deps.runCli,
|
|
3396
|
+
pathExistsFn: deps.pathExists,
|
|
3397
|
+
});
|
|
2805
3398
|
return { status: "chat" };
|
|
2806
3399
|
}
|
|
2807
3400
|
|
|
@@ -2832,7 +3425,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2832
3425
|
// otherwise), so telling the router the thread "owns an open PR" when the run is
|
|
2833
3426
|
// merged/closed would just mislead the model and waste a classification.
|
|
2834
3427
|
const routed =
|
|
2835
|
-
|
|
3428
|
+
forcedConflictRecovery
|
|
3429
|
+
? {
|
|
3430
|
+
code: true,
|
|
3431
|
+
task: conflictRepairBrief(forcedConflictRecovery),
|
|
3432
|
+
followupSignal: "change",
|
|
3433
|
+
}
|
|
3434
|
+
: mode === "ship"
|
|
2836
3435
|
? { code: true, task: message.body, followupSignal: null }
|
|
2837
3436
|
: await routeIntent({
|
|
2838
3437
|
name: me?.agentName || "an assistant",
|
|
@@ -2842,7 +3441,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2842
3441
|
cfg,
|
|
2843
3442
|
signal,
|
|
2844
3443
|
hasActiveRun: activeRunOpen,
|
|
3444
|
+
runCliFn: deps.runCli,
|
|
2845
3445
|
implicitDm,
|
|
3446
|
+
cwd: mappedRepoCwd,
|
|
2846
3447
|
});
|
|
2847
3448
|
if (routed.aborted || signal?.aborted) {
|
|
2848
3449
|
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
@@ -2865,6 +3466,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2865
3466
|
signal,
|
|
2866
3467
|
context,
|
|
2867
3468
|
caps,
|
|
3469
|
+
runCliFn: deps.runCli,
|
|
3470
|
+
pathExistsFn: deps.pathExists,
|
|
2868
3471
|
});
|
|
2869
3472
|
return { status: "chat" };
|
|
2870
3473
|
}
|
|
@@ -2884,7 +3487,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2884
3487
|
signal: followupSignal,
|
|
2885
3488
|
});
|
|
2886
3489
|
|
|
2887
|
-
let repoPath =
|
|
3490
|
+
let repoPath = mappedRepoPath;
|
|
2888
3491
|
if (!repoPath) {
|
|
2889
3492
|
// Zero-config path: if the daemon is running INSIDE a checkout of this repo
|
|
2890
3493
|
// (its origin matches), just use the current directory — no repos map needed.
|
|
@@ -2958,6 +3561,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2958
3561
|
}
|
|
2959
3562
|
};
|
|
2960
3563
|
|
|
3564
|
+
let activeConflictMerge = null;
|
|
2961
3565
|
try {
|
|
2962
3566
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
2963
3567
|
|
|
@@ -2974,7 +3578,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2974
3578
|
// settled, still amends THAT PR. Same shape as the server's own anchor
|
|
2975
3579
|
// fallback, and it never overrides 1 or 2.
|
|
2976
3580
|
// Otherwise, a fresh branch off the default (today's behavior).
|
|
2977
|
-
const cont =
|
|
3581
|
+
const cont = forcedConflictRecovery
|
|
3582
|
+
? null
|
|
3583
|
+
: detectPrContinuation(message.body, repoFullName);
|
|
2978
3584
|
const runIterateUrl = (followupMode === "iterate" && activeRun?.prUrl) || null;
|
|
2979
3585
|
// The anchor is a HYPOTHESIS (its recorded state may be stale or unknown);
|
|
2980
3586
|
// `gh` is the proof. No proof, no adoption — the run cuts a fresh branch and
|
|
@@ -3011,7 +3617,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3011
3617
|
anchorNote = staleAnchorNote({ reason: decision.reason, anchor: decision.anchor });
|
|
3012
3618
|
}
|
|
3013
3619
|
}
|
|
3014
|
-
|
|
3620
|
+
// A typed conflict target is stronger than heuristic text/run/anchor
|
|
3621
|
+
// evidence: GitHub just returned this exact live PR head to merge_pr.
|
|
3622
|
+
const iterateUrl =
|
|
3623
|
+
forcedConflictRecovery?.prUrl || cont?.url || runIterateUrl || anchorAdopt?.prUrl || null;
|
|
3015
3624
|
let branch = null;
|
|
3016
3625
|
let continuingPrUrl = null;
|
|
3017
3626
|
if (iterateUrl && (deps.prHeadRef || anchorAdopt)) {
|
|
@@ -3019,6 +3628,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3019
3628
|
// run's recorded branch is a fallback if gh can't view the PR. An adopted
|
|
3020
3629
|
// anchor already has its head branch straight from the live PR.
|
|
3021
3630
|
const headRef =
|
|
3631
|
+
(forcedConflictRecovery?.prUrl === iterateUrl
|
|
3632
|
+
? forcedConflictRecovery.branch
|
|
3633
|
+
: null) ||
|
|
3022
3634
|
(anchorAdopt?.prUrl === iterateUrl ? anchorAdopt.branch : null) ||
|
|
3023
3635
|
deps.prHeadRef?.(repoPath, iterateUrl) ||
|
|
3024
3636
|
(followupMode === "iterate" ? activeRun?.branch || null : null);
|
|
@@ -3047,6 +3659,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3047
3659
|
}
|
|
3048
3660
|
}
|
|
3049
3661
|
}
|
|
3662
|
+
if (forcedConflictRecovery && !continuingPrUrl) {
|
|
3663
|
+
await tool("post_message", {
|
|
3664
|
+
channelId,
|
|
3665
|
+
parentId,
|
|
3666
|
+
body: `PR #${forcedConflictRecovery.prNumber} needs conflict repair, but I couldn't check out its existing branch \`${forcedConflictRecovery.branch}\`. I left the pull request unchanged.`,
|
|
3667
|
+
});
|
|
3668
|
+
return { status: "conflict-checkout-failed" };
|
|
3669
|
+
}
|
|
3050
3670
|
// An explicit PR token (detectPrContinuation) that names a DIFFERENT PR than the
|
|
3051
3671
|
// thread's active run must NOT reuse/update that run for the terminal
|
|
3052
3672
|
// update_run/report: we push to the TOKEN's PR (via existingPrUrl below), so
|
|
@@ -3079,6 +3699,41 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3079
3699
|
// self-committed".
|
|
3080
3700
|
const baseRef = (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() || (startRef.ref || cfg.defaultBranch);
|
|
3081
3701
|
|
|
3702
|
+
// 0991 — own the Git transition OUTSIDE the model. A merge conflict is a
|
|
3703
|
+
// prepared worktree state, not prose in a prompt. Only after this succeeds do
|
|
3704
|
+
// we post an ack or launch the coding CLI.
|
|
3705
|
+
if (forcedConflictRecovery) {
|
|
3706
|
+
const started = startConflictMerge({
|
|
3707
|
+
git,
|
|
3708
|
+
cwd: repoPath,
|
|
3709
|
+
conflict: forcedConflictRecovery,
|
|
3710
|
+
});
|
|
3711
|
+
if (!started.ok) {
|
|
3712
|
+
await tool("post_message", {
|
|
3713
|
+
channelId,
|
|
3714
|
+
parentId,
|
|
3715
|
+
body: `PR #${forcedConflictRecovery.prNumber} conflicts with \`${forcedConflictRecovery.baseBranch}\`, but I couldn't prepare the repair checkout: ${started.reason} The pull request branch is unchanged.`,
|
|
3716
|
+
});
|
|
3717
|
+
return { status: "conflict-setup-failed" };
|
|
3718
|
+
}
|
|
3719
|
+
if (started.state === "up-to-date") {
|
|
3720
|
+
// The synthetic repair row is durable execution authority, but it is not
|
|
3721
|
+
// a second merge approval. Never cite its compact display body to
|
|
3722
|
+
// `merge_pr`; ask for a fresh click after GitHub recomputes mergeability.
|
|
3723
|
+
await tool("post_message", {
|
|
3724
|
+
channelId,
|
|
3725
|
+
parentId,
|
|
3726
|
+
body: `PR #${forcedConflictRecovery.prNumber} already contains \`${forcedConflictRecovery.baseBranch}\`. GitHub had still refused the merge, so I left it unchanged. Review its current status and approve it again if it is ready.`,
|
|
3727
|
+
}).catch(() => {});
|
|
3728
|
+
return { status: "conflict-already-current", action: "merge" };
|
|
3729
|
+
}
|
|
3730
|
+
activeConflictMerge = {
|
|
3731
|
+
baseBranch: forcedConflictRecovery.baseBranch,
|
|
3732
|
+
files: started.files,
|
|
3733
|
+
prNumber: forcedConflictRecovery.prNumber,
|
|
3734
|
+
};
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3082
3737
|
// Instant acknowledgement: post a template in <1s so the channel shows life
|
|
3083
3738
|
// before any model call. If the server supports edit_message and a fast chatCmd
|
|
3084
3739
|
// is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
|
|
@@ -3089,7 +3744,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3089
3744
|
// routing before any code lands. This is a key safety mitigation for a wrong
|
|
3090
3745
|
// 'iterate'. A plain new run keeps the standard template.
|
|
3091
3746
|
let ackBody = ackText(repoFullName);
|
|
3092
|
-
if (
|
|
3747
|
+
if (activeConflictMerge) {
|
|
3748
|
+
ackBody = `PR #${activeConflictMerge.prNumber} conflicts with \`${activeConflictMerge.baseBranch}\`. I started the merge on \`${branch}\` and I'm resolving it now; the updated PR will come back for review.`;
|
|
3749
|
+
} else if (continuingPrUrl) {
|
|
3093
3750
|
const n = prNumberFromUrl(continuingPrUrl);
|
|
3094
3751
|
ackBody = `Continuing ${n ? `PR #${n}` : "the existing pull request"} on \`${branch}\` — I'll push this change there and update the report.`;
|
|
3095
3752
|
} else if (effectiveMode === "redirect") {
|
|
@@ -3124,6 +3781,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3124
3781
|
// Best-effort throughout — a bookkeeping failure (or an older server without the
|
|
3125
3782
|
// runs tools) must NEVER break the run, so it proceeds without a runId.
|
|
3126
3783
|
let runId = message.dispatch?.runId ?? (effectiveMode === "iterate" ? activeRun?.runId ?? null : null);
|
|
3784
|
+
// Immutable generation for every report attempt in this daemon turn. A reused
|
|
3785
|
+
// run claims the next value atomically below; fresh and dispatch-adopted rows
|
|
3786
|
+
// begin on the server's current value (normally zero).
|
|
3787
|
+
let iterateTurn = Number.isSafeInteger(message.dispatch?.iterateTurn)
|
|
3788
|
+
? Number(message.dispatch.iterateTurn)
|
|
3789
|
+
: Number.isSafeInteger(activeRun?.iterateTurn)
|
|
3790
|
+
? Number(activeRun.iterateTurn)
|
|
3791
|
+
: 0;
|
|
3792
|
+
let iterateClaimId = null;
|
|
3793
|
+
let iterateAuthorityLost = false;
|
|
3127
3794
|
if (message.dispatch?.runId && caps.runs) {
|
|
3128
3795
|
// Adopt the dispatch's canonical run. If the server already SETTLED it (the
|
|
3129
3796
|
// reaper/outbox closed a dispatch we were slow to pick up), leave it alone
|
|
@@ -3131,17 +3798,51 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3131
3798
|
// waiting on. Any OTHER failure (network, an older server without the guard)
|
|
3132
3799
|
// keeps today's best-effort behavior: proceed with the run.
|
|
3133
3800
|
try {
|
|
3134
|
-
await tool("update_run", { runId: message.dispatch.runId, status: "running" });
|
|
3801
|
+
const adopted = await tool("update_run", { runId: message.dispatch.runId, status: "running" });
|
|
3802
|
+
if (Number.isSafeInteger(adopted?.iterateTurn)) iterateTurn = Number(adopted.iterateTurn);
|
|
3135
3803
|
} catch (e) {
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3804
|
+
const alreadySettled = String(e?.message || "").includes("already settled");
|
|
3805
|
+
await tool("post_message", {
|
|
3806
|
+
channelId,
|
|
3807
|
+
parentId,
|
|
3808
|
+
body: alreadySettled
|
|
3809
|
+
? "This dispatch was already closed on the server, so I left it alone. Approve it again if it's still wanted."
|
|
3810
|
+
: "I could not confirm this dispatch's current run generation, so I did not start or ship it. Try the dispatch again.",
|
|
3811
|
+
}).catch(() => {});
|
|
3812
|
+
// A dispatch already names the canonical run. Continuing after an
|
|
3813
|
+
// uncertain adoption would report with a guessed turn (usually zero)
|
|
3814
|
+
// and can push before the exact-generation fence refuses the card.
|
|
3815
|
+
return {
|
|
3816
|
+
status: alreadySettled ? "dispatch-settled" : "dispatch-adoption-uncertain",
|
|
3817
|
+
runId: message.dispatch.runId,
|
|
3818
|
+
};
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
if (caps.runs && effectiveMode === "iterate" && runId && !message.dispatch?.runId) {
|
|
3822
|
+
try {
|
|
3823
|
+
const claimed = await claimDaemonIterate({
|
|
3824
|
+
tool,
|
|
3825
|
+
runId,
|
|
3826
|
+
expectedIterateTurn: iterateTurn,
|
|
3827
|
+
recoveryStore: iterateClaimRecoveryStore,
|
|
3828
|
+
});
|
|
3829
|
+
const claimedTurn = claimed?.iterateTurn;
|
|
3830
|
+
if (!Number.isSafeInteger(claimedTurn) || Number(claimedTurn) !== iterateTurn + 1) {
|
|
3831
|
+
throw new Error("the server did not return the claimed Iterate generation");
|
|
3143
3832
|
}
|
|
3144
|
-
|
|
3833
|
+
iterateTurn = Number(claimedTurn);
|
|
3834
|
+
iterateClaimId = claimed.claimId;
|
|
3835
|
+
} catch {
|
|
3836
|
+
// Unlike optional bookkeeping, the generation claim is execution
|
|
3837
|
+
// authority. Continuing after losing it would let two daemons ship one
|
|
3838
|
+
// reused PR and let an old report settle the winner's newer work.
|
|
3839
|
+
await tool("post_message", {
|
|
3840
|
+
channelId,
|
|
3841
|
+
parentId: threadRoot,
|
|
3842
|
+
broadcast: true,
|
|
3843
|
+
body: "This Iterate turn was already claimed or left its review gate, so I did not run or ship it. Re-mention me from the current report if the change is still needed.",
|
|
3844
|
+
}).catch(() => {});
|
|
3845
|
+
return { status: "iterate-claim-lost", runId };
|
|
3145
3846
|
}
|
|
3146
3847
|
}
|
|
3147
3848
|
if (caps.runs && threadRoot && effectiveMode !== "iterate" && !message.dispatch?.runId) {
|
|
@@ -3166,12 +3867,48 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3166
3867
|
const v = detectVendor(cfg.codingCmd);
|
|
3167
3868
|
return v === "unknown" ? null : v;
|
|
3168
3869
|
})(),
|
|
3870
|
+
// 1182 — this daemon takes a room direction at its next turn: it reads
|
|
3871
|
+
// `pendingInputs` off its heartbeat and runs one more CLI turn with
|
|
3872
|
+
// them after the coding turn. Declared only when the server can take
|
|
3873
|
+
// the receipt, so the room never offers a composer nothing will read.
|
|
3874
|
+
...(caps.runInputs ? { inputTransport: "next_turn" } : {}),
|
|
3169
3875
|
});
|
|
3170
3876
|
runId = r?.runId ?? null;
|
|
3877
|
+
if (Number.isSafeInteger(r?.iterateTurn)) iterateTurn = Number(r.iterateTurn);
|
|
3171
3878
|
} catch {
|
|
3172
3879
|
/* never break the run on a run-record failure */
|
|
3173
3880
|
}
|
|
3174
3881
|
}
|
|
3882
|
+
// 1182 — the run's direction inbox, fed by every heartbeat reply. Only a run
|
|
3883
|
+
// the server knows (start_run answered) and only when it can take the
|
|
3884
|
+
// receipt; otherwise there is no door and nothing is ever listed.
|
|
3885
|
+
const directions =
|
|
3886
|
+
caps.runInputs && runId
|
|
3887
|
+
? createDirectionInbox({ tool, runId, log: (line) => console.log(line) })
|
|
3888
|
+
: null;
|
|
3889
|
+
// All subprocesses launched after an Iterate claim — including the fast plan
|
|
3890
|
+
// acknowledgement — share this exact authority fence. Non-Iterate runs take
|
|
3891
|
+
// the true fast path and preserve their existing behavior.
|
|
3892
|
+
const beforeCodingSpawn = async () => {
|
|
3893
|
+
if (signal?.aborted) return false;
|
|
3894
|
+
if (!iterateClaimId) return true;
|
|
3895
|
+
const ownsClaim = await confirmDaemonIterateClaim({
|
|
3896
|
+
tool,
|
|
3897
|
+
runId,
|
|
3898
|
+
iterateTurn,
|
|
3899
|
+
claimId: iterateClaimId,
|
|
3900
|
+
});
|
|
3901
|
+
if (!ownsClaim) iterateAuthorityLost = true;
|
|
3902
|
+
return ownsClaim && !signal?.aborted;
|
|
3903
|
+
};
|
|
3904
|
+
const settleConflictFailure = async (reason) => {
|
|
3905
|
+
if (!activeConflictMerge || !runId || !caps.runs) return;
|
|
3906
|
+
await tool("update_run", {
|
|
3907
|
+
runId,
|
|
3908
|
+
status: effectiveMode === "iterate" ? "awaiting_review" : "failed",
|
|
3909
|
+
reason,
|
|
3910
|
+
}).catch(() => {});
|
|
3911
|
+
};
|
|
3175
3912
|
// Keep the inspectable continuation/redirect statement (don't overwrite it with
|
|
3176
3913
|
// an LLM plan) so a human can correct the routing before code lands.
|
|
3177
3914
|
if (
|
|
@@ -3190,6 +3927,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3190
3927
|
repoFullName,
|
|
3191
3928
|
cfg,
|
|
3192
3929
|
signal,
|
|
3930
|
+
cwd: repoPath,
|
|
3931
|
+
beforeSpawn: beforeCodingSpawn,
|
|
3193
3932
|
}).catch(() => null);
|
|
3194
3933
|
if (plan && !signal?.aborted) await tool("edit_message", { messageId: ackId, body: plan }).catch(() => {});
|
|
3195
3934
|
}
|
|
@@ -3321,11 +4060,32 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3321
4060
|
* heartbeat does (the room hears who stopped it; the job's signal aborts).
|
|
3322
4061
|
*/
|
|
3323
4062
|
const stoppedBeforeShip = async (state = "done") => {
|
|
4063
|
+
if (iterateClaimId) {
|
|
4064
|
+
const ownsClaim = await confirmDaemonIterateClaim({
|
|
4065
|
+
tool,
|
|
4066
|
+
runId,
|
|
4067
|
+
iterateTurn,
|
|
4068
|
+
claimId: iterateClaimId,
|
|
4069
|
+
});
|
|
4070
|
+
if (!ownsClaim) {
|
|
4071
|
+
iterateAuthorityLost = true;
|
|
4072
|
+
return true;
|
|
4073
|
+
}
|
|
4074
|
+
}
|
|
3324
4075
|
// Only the streaming card carries progress metadata. The legacy heartbeat's
|
|
3325
4076
|
// message is plain text, and stamping a lifecycle onto it would turn a
|
|
3326
4077
|
// status line into a run card — so that path simply has no pre-ship check.
|
|
3327
4078
|
if (!streamOn || !progressId) return false;
|
|
3328
|
-
|
|
4079
|
+
// 1182 — this is the one beat between the last turn and the ship, so it
|
|
4080
|
+
// is also where a direction typed during the review wait is harvested;
|
|
4081
|
+
// the inbox then answers it at close, before the report.
|
|
4082
|
+
const sender = createProgressSender({
|
|
4083
|
+
tool,
|
|
4084
|
+
statusId: progressId,
|
|
4085
|
+
runId,
|
|
4086
|
+
onStopRequested,
|
|
4087
|
+
...(directions ? { onPendingInputs: (list) => directions.absorb(list) } : {}),
|
|
4088
|
+
});
|
|
3329
4089
|
return await sender.poll(state);
|
|
3330
4090
|
};
|
|
3331
4091
|
// Once the run ends, retire the "still working…" progress reply so it doesn't
|
|
@@ -3348,15 +4108,25 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3348
4108
|
const settleRunUsage = async () => {
|
|
3349
4109
|
const args = reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId });
|
|
3350
4110
|
if (!args.usage || !progressId) return;
|
|
3351
|
-
|
|
4111
|
+
// 1182 — a run-bound beat like any other: a direction it lists is handed
|
|
4112
|
+
// to the inbox (which answers it at close, right after this on the exits
|
|
4113
|
+
// that call it), so no beat throws a listing away.
|
|
4114
|
+
const res = await tool("post_progress", {
|
|
3352
4115
|
messageId: progressId,
|
|
3353
4116
|
...(runId ? { runId } : {}),
|
|
3354
4117
|
progress: { state: lastTerminalState },
|
|
3355
4118
|
usage: args.usage,
|
|
3356
|
-
}).catch(() =>
|
|
4119
|
+
}).catch(() => null);
|
|
4120
|
+
if (directions && res) {
|
|
4121
|
+
try {
|
|
4122
|
+
directions.absorb(readPendingInputs(res));
|
|
4123
|
+
} catch {
|
|
4124
|
+
/* never break the settlement over a listing */
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
3357
4127
|
};
|
|
3358
4128
|
|
|
3359
|
-
const parts = cfg.codingCmd
|
|
4129
|
+
const parts = commandArgv(cfg.codingCmd);
|
|
3360
4130
|
// Run the CLI and stage everything it changed; return the diff stats (no post).
|
|
3361
4131
|
// Workspace memory (the project's soul) is prepended so the coding agent has
|
|
3362
4132
|
// the shared context before it starts.
|
|
@@ -3402,7 +4172,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3402
4172
|
// the sender also carries the stop signal home on this same beat, bound
|
|
3403
4173
|
// to THIS run's id so a stop can't be read off a neighbouring thread.
|
|
3404
4174
|
const sender = statusId
|
|
3405
|
-
? createProgressSender({
|
|
4175
|
+
? createProgressSender({
|
|
4176
|
+
tool,
|
|
4177
|
+
statusId,
|
|
4178
|
+
runId,
|
|
4179
|
+
onStopRequested,
|
|
4180
|
+
...(directions ? { onPendingInputs: (list) => directions.absorb(list) } : {}),
|
|
4181
|
+
})
|
|
3406
4182
|
: null;
|
|
3407
4183
|
// The emitter is built whether or not the status card posted (0787). The
|
|
3408
4184
|
// stream flags are already on the argv either way, so the CLI is speaking
|
|
@@ -3466,7 +4242,24 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3466
4242
|
const codeArgs = streamOn
|
|
3467
4243
|
? [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs, ...streamArgs]
|
|
3468
4244
|
: [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs];
|
|
4245
|
+
// One HEP stream per spawned coding-runtime process. This projection is
|
|
4246
|
+
// automatic but deliberately best-effort: it is bounded, secret-redacted
|
|
4247
|
+
// again on the server, and can never interrupt the coding process.
|
|
4248
|
+
const harnessEmitter =
|
|
4249
|
+
caps.hepEvents &&
|
|
4250
|
+
runId &&
|
|
4251
|
+
me?.agentId &&
|
|
4252
|
+
["claude_code", "codex", "cursor", "opencode"].includes(vendor)
|
|
4253
|
+
? createLocalHarnessEmitter({
|
|
4254
|
+
tool,
|
|
4255
|
+
runId,
|
|
4256
|
+
runtime: vendor,
|
|
4257
|
+
streamId: randomUUID(),
|
|
4258
|
+
actorId: me.agentId,
|
|
4259
|
+
})
|
|
4260
|
+
: null;
|
|
3469
4261
|
const handleCliData = (c) => {
|
|
4262
|
+
harnessEmitter?.push(c);
|
|
3470
4263
|
// Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
|
|
3471
4264
|
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
3472
4265
|
if (lines.length) lastLine = lines[lines.length - 1];
|
|
@@ -3494,6 +4287,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3494
4287
|
// live card has always read this; the transcript has to as well, or a gated
|
|
3495
4288
|
// run uploads a page of narration with no execution anywhere in it.
|
|
3496
4289
|
const handleCliEvent = (ev) => {
|
|
4290
|
+
harnessEmitter?.pushEvent(ev);
|
|
3497
4291
|
if (transcriptTap) {
|
|
3498
4292
|
try {
|
|
3499
4293
|
transcriptTap.pushEvent(ev);
|
|
@@ -3566,6 +4360,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3566
4360
|
env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
|
|
3567
4361
|
onData: handleCliData,
|
|
3568
4362
|
onEvent: handleCliEvent,
|
|
4363
|
+
beforeSpawn: beforeCodingSpawn,
|
|
3569
4364
|
...openCodePermissionCallbacks({
|
|
3570
4365
|
tool,
|
|
3571
4366
|
channelId,
|
|
@@ -3588,6 +4383,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3588
4383
|
// after this scrub and dies with the process group.
|
|
3589
4384
|
env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
|
|
3590
4385
|
onData: handleCliData,
|
|
4386
|
+
beforeSpawn: beforeCodingSpawn,
|
|
3591
4387
|
...openCodePermissionCallbacks({
|
|
3592
4388
|
tool,
|
|
3593
4389
|
channelId,
|
|
@@ -3612,6 +4408,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3612
4408
|
signal,
|
|
3613
4409
|
onData: handleCliData,
|
|
3614
4410
|
onEvent: handleCliEvent,
|
|
4411
|
+
beforeSpawn: beforeCodingSpawn,
|
|
3615
4412
|
permissionCallbacks: openCodePermissionCallbacks({
|
|
3616
4413
|
tool,
|
|
3617
4414
|
channelId,
|
|
@@ -3634,6 +4431,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3634
4431
|
cwd: repoPath,
|
|
3635
4432
|
signal,
|
|
3636
4433
|
onData: handleCliData,
|
|
4434
|
+
beforeSpawn: beforeCodingSpawn,
|
|
3637
4435
|
sessionId: resume ? resumeSessionId ?? "" : "",
|
|
3638
4436
|
resolveSessionId: () => emitter?.snapshot()?.sessionId ?? null,
|
|
3639
4437
|
permissionCallbacks: openCodePermissionCallbacks({
|
|
@@ -3659,6 +4457,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3659
4457
|
signal,
|
|
3660
4458
|
env: codingChildWebEnv(cfg, vendor),
|
|
3661
4459
|
onData: handleCliData,
|
|
4460
|
+
beforeSpawn: beforeCodingSpawn,
|
|
3662
4461
|
});
|
|
3663
4462
|
}
|
|
3664
4463
|
// 0785 — the gate was expected here and the CLI couldn't hold it. Say so
|
|
@@ -3668,6 +4467,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3668
4467
|
stopHeartbeat();
|
|
3669
4468
|
stopPoller?.stop();
|
|
3670
4469
|
if (run?.sessionId) runSessionId = run.sessionId;
|
|
4470
|
+
try {
|
|
4471
|
+
await harnessEmitter?.drain();
|
|
4472
|
+
} catch {
|
|
4473
|
+
/* runtime exhaust must never break the run */
|
|
4474
|
+
}
|
|
3671
4475
|
if (emitter) {
|
|
3672
4476
|
// Terminal state: flip the status card off "working" (state 'done'/'error')
|
|
3673
4477
|
// so it stops claiming the agent is alive. For a run that produced work,
|
|
@@ -3735,7 +4539,53 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3735
4539
|
return { aborted: true };
|
|
3736
4540
|
}
|
|
3737
4541
|
if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
|
|
3738
|
-
|
|
4542
|
+
if (activeConflictMerge) {
|
|
4543
|
+
const mergeState = validateConflictMergeState({
|
|
4544
|
+
git,
|
|
4545
|
+
cwd: repoPath,
|
|
4546
|
+
baseBranch: activeConflictMerge.baseBranch,
|
|
4547
|
+
});
|
|
4548
|
+
if (!mergeState.ok) {
|
|
4549
|
+
console.log(` code → ${mergeState.reason}; refusing to stage`);
|
|
4550
|
+
return {
|
|
4551
|
+
conflicted: true,
|
|
4552
|
+
mergeStateInvalid: true,
|
|
4553
|
+
files: [mergeState.reason],
|
|
4554
|
+
failed: true,
|
|
4555
|
+
};
|
|
4556
|
+
}
|
|
4557
|
+
const unresolved = remainingConflictFiles({
|
|
4558
|
+
git,
|
|
4559
|
+
cwd: repoPath,
|
|
4560
|
+
knownFiles: activeConflictMerge.files,
|
|
4561
|
+
});
|
|
4562
|
+
if (unresolved.length) {
|
|
4563
|
+
console.log(` code → ${unresolved.length} merge conflict(s) remain; refusing to stage`);
|
|
4564
|
+
return {
|
|
4565
|
+
conflicted: true,
|
|
4566
|
+
files: unresolved,
|
|
4567
|
+
failed: Boolean(run.error) || run.status !== 0,
|
|
4568
|
+
};
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
const add = git(repoPath, ["add", "-A"]);
|
|
4572
|
+
if (activeConflictMerge) {
|
|
4573
|
+
const unmergedAfterAdd = String(
|
|
4574
|
+
git(repoPath, ["diff", "--name-only", "--diff-filter=U"]).stdout || "",
|
|
4575
|
+
)
|
|
4576
|
+
.split("\n")
|
|
4577
|
+
.map((file) => file.trim())
|
|
4578
|
+
.filter(Boolean);
|
|
4579
|
+
if (add.status !== 0 || unmergedAfterAdd.length) {
|
|
4580
|
+
return {
|
|
4581
|
+
conflicted: true,
|
|
4582
|
+
files: unmergedAfterAdd.length
|
|
4583
|
+
? unmergedAfterAdd
|
|
4584
|
+
: ["Git could not stage the resolved merge"],
|
|
4585
|
+
failed: true,
|
|
4586
|
+
};
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
3739
4589
|
const diff = git(repoPath, ["diff", "--cached"]).stdout || "";
|
|
3740
4590
|
if (!diff.trim()) {
|
|
3741
4591
|
// An empty diff after a FAILED run (timeout / non-zero exit / spawn error)
|
|
@@ -3903,9 +4753,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3903
4753
|
// Post the right cancel message: honest about whether cleanup actually worked.
|
|
3904
4754
|
const postStopped = async () => {
|
|
3905
4755
|
const clean = discardBranch();
|
|
4756
|
+
const lead = iterateAuthorityLost
|
|
4757
|
+
? "This Iterate turn no longer owns its run, so I did not start or ship it"
|
|
4758
|
+
: "Stopped";
|
|
3906
4759
|
const body = clean
|
|
3907
|
-
?
|
|
3908
|
-
:
|
|
4760
|
+
? `${lead} — discarded \`${branch}\`.`
|
|
4761
|
+
: `${lead}, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`;
|
|
3909
4762
|
await tool("post_message", {
|
|
3910
4763
|
channelId,
|
|
3911
4764
|
parentId: threadRoot,
|
|
@@ -3919,6 +4772,30 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3919
4772
|
return { status: "cancelled", branch };
|
|
3920
4773
|
};
|
|
3921
4774
|
|
|
4775
|
+
const postStopCompensationPending = async (result) => {
|
|
4776
|
+
const receipt = result?.compensation?.receipt;
|
|
4777
|
+
const ref = receipt?.ref || `refs/heads/${branch}`;
|
|
4778
|
+
const reason = result?.compensation?.probeError
|
|
4779
|
+
|| result?.compensation?.stderr
|
|
4780
|
+
|| "the remote ref could not be verified";
|
|
4781
|
+
const repairGuidance = result?.compensation?.repairable
|
|
4782
|
+
? "The exact-lease repair is safe to retry"
|
|
4783
|
+
: "The remote ref changed again, so it needs manual reconciliation";
|
|
4784
|
+
await tool("post_message", {
|
|
4785
|
+
channelId,
|
|
4786
|
+
parentId: threadRoot,
|
|
4787
|
+
broadcast: true,
|
|
4788
|
+
body:
|
|
4789
|
+
`Stop was received, but hilos could not confirm restoration of \`${ref}\`: ` +
|
|
4790
|
+
`${oneLine(String(reason), 200)}. ${repairGuidance}; ` +
|
|
4791
|
+
`the local branch \`${branch}\` was kept for recovery.`,
|
|
4792
|
+
});
|
|
4793
|
+
await finalizeProgress(`Stop needs remote branch repair on \`${branch}\` — see the note below.`);
|
|
4794
|
+
await updateChannelMarker(compactRunMarker("run-failed", branch));
|
|
4795
|
+
await settleRunUsage();
|
|
4796
|
+
return result;
|
|
4797
|
+
};
|
|
4798
|
+
|
|
3922
4799
|
// Team memory recall (0297): best-effort fetch accumulated learnings for this
|
|
3923
4800
|
// channel before the coding run so the coding agent has the team's conventions
|
|
3924
4801
|
// and gotchas from the start. Capability-gated: older servers without the
|
|
@@ -3944,21 +4821,119 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3944
4821
|
|
|
3945
4822
|
const codePrompt =
|
|
3946
4823
|
(teamMemoryBlock ? teamMemoryBlock + "\n\n" : "") +
|
|
3947
|
-
codeTaskPrompt({ message, context, brief: routed.task, repoFullName, ...codeImagePromptArgs })
|
|
4824
|
+
codeTaskPrompt({ message, context, brief: routed.task, repoFullName, ...codeImagePromptArgs }) +
|
|
4825
|
+
(activeConflictMerge
|
|
4826
|
+
? `\n\n${conflictPromptBlock(activeConflictMerge)}`
|
|
4827
|
+
: "");
|
|
3948
4828
|
let staged = await runAndStage(codePrompt);
|
|
3949
4829
|
if (staged.aborted) return await postStopped();
|
|
4830
|
+
// One focused retry, in the same prepared merge, with the remaining paths
|
|
4831
|
+
// named explicitly. This is recovery owned by the run — never another human
|
|
4832
|
+
// prompt. A second failure stops safely and pushes nothing.
|
|
4833
|
+
if (activeConflictMerge && staged.conflicted) {
|
|
4834
|
+
if (staged.mergeStateInvalid) {
|
|
4835
|
+
await tool("post_message", {
|
|
4836
|
+
channelId,
|
|
4837
|
+
parentId: threadRoot,
|
|
4838
|
+
broadcast: true,
|
|
4839
|
+
body:
|
|
4840
|
+
`I couldn't safely finish the conflict repair on PR #${activeConflictMerge.prNumber}. ` +
|
|
4841
|
+
`${staged.files[0]}. I left its remote branch unchanged.`,
|
|
4842
|
+
});
|
|
4843
|
+
await finalizeProgress(`Conflict repair stopped on \`${branch}\` — nothing was pushed.`);
|
|
4844
|
+
await updateChannelMarker(compactRunMarker("run-failed", branch));
|
|
4845
|
+
await settleConflictFailure("conflict merge state was aborted or replaced");
|
|
4846
|
+
await settleRunUsage();
|
|
4847
|
+
return { status: "conflict-state-invalid", branch };
|
|
4848
|
+
}
|
|
4849
|
+
const retryPrompt =
|
|
4850
|
+
`${codePrompt}\n\nYour first pass left unresolved merge conflicts. ` +
|
|
4851
|
+
`Resolve every remaining file now and leave no conflict marker behind:\n` +
|
|
4852
|
+
staged.files.map((file) => `- ${file}`).join("\n");
|
|
4853
|
+
staged = await runAndStage(retryPrompt, { resume: false });
|
|
4854
|
+
if (staged.aborted) return await postStopped();
|
|
4855
|
+
if (staged.conflicted) {
|
|
4856
|
+
await tool("post_message", {
|
|
4857
|
+
channelId,
|
|
4858
|
+
parentId: threadRoot,
|
|
4859
|
+
broadcast: true,
|
|
4860
|
+
body:
|
|
4861
|
+
`I couldn't safely finish the conflict repair on PR #${activeConflictMerge.prNumber}. ` +
|
|
4862
|
+
`I left its remote branch unchanged; unresolved files were:\n` +
|
|
4863
|
+
staged.files.map((file) => `- ${file}`).join("\n"),
|
|
4864
|
+
});
|
|
4865
|
+
await finalizeProgress(`Conflict repair stopped on \`${branch}\` — nothing was pushed.`);
|
|
4866
|
+
await updateChannelMarker(compactRunMarker("run-failed", branch));
|
|
4867
|
+
await settleConflictFailure(`unresolved conflicts: ${staged.files.join(", ")}`);
|
|
4868
|
+
await settleRunUsage();
|
|
4869
|
+
return { status: "conflicts-unresolved", branch, files: staged.files };
|
|
4870
|
+
}
|
|
4871
|
+
}
|
|
3950
4872
|
// Never-worse-than-today (0282): if we RESUMED a session and that run FAILED (a
|
|
3951
4873
|
// stale/foreign `--resume` id makes claude error out → empty diff → failed), retry
|
|
3952
4874
|
// ONCE without --resume — the exact branch+feedback iterate we'd have run before
|
|
3953
4875
|
// 0282. A dead session can therefore never dead-end an iteration. Gated on
|
|
3954
4876
|
// staged.failed (the reliable resume-error signal) so a legitimate no-op run isn't
|
|
3955
4877
|
// re-run. Adopt the retry regardless — it IS the pre-0282 baseline.
|
|
3956
|
-
if (resumeSessionId && staged.empty && staged.failed) {
|
|
4878
|
+
if (resumeSessionId && staged.empty && staged.failed && !activeConflictMerge) {
|
|
3957
4879
|
console.log(" code → resumed run failed; retrying once without --resume (branch + feedback)");
|
|
3958
4880
|
const retry = await runAndStage(codePrompt, { resume: false });
|
|
3959
4881
|
if (retry.aborted) return await postStopped();
|
|
3960
4882
|
staged = retry;
|
|
3961
4883
|
}
|
|
4884
|
+
// 1182 — directions a person added from the card while the CLI worked. The
|
|
4885
|
+
// coding turn is over; each batch gets one more turn with the same task and
|
|
4886
|
+
// the directions, on the same working tree (the shape the gate's feedback
|
|
4887
|
+
// rounds use), then its receipt: the turn's own command id and the pass it
|
|
4888
|
+
// went into. Bounded, so a room that keeps typing waits for the next pass.
|
|
4889
|
+
// The inbox stays open through the gate's review rounds (each folds what
|
|
4890
|
+
// arrived into its prompt) and closes only when no further turn can run.
|
|
4891
|
+
const settleDirectionTurn = async (batch, applied, commandId) => {
|
|
4892
|
+
if (!directionTurnRan(applied)) {
|
|
4893
|
+
// No runtime ever saw the prompt (the command did not spawn): no
|
|
4894
|
+
// receipt. The server keeps listing; the run's end tells the room.
|
|
4895
|
+
console.log(` direction → the turn did not start; ${batch.length} direction(s) left unsettled`);
|
|
4896
|
+
return;
|
|
4897
|
+
}
|
|
4898
|
+
// The runtime took the directions into a command: delivered, whatever
|
|
4899
|
+
// the turn then produced (its outcome is the report's to tell).
|
|
4900
|
+
const delivered = await directions.acknowledge(batch, { ackId: commandId, turn: iterateTurn });
|
|
4901
|
+
console.log(` direction → ${delivered}/${batch.length} receipt(s) recorded`);
|
|
4902
|
+
};
|
|
4903
|
+
// Runs while directions are held and turns remain; called after the coding
|
|
4904
|
+
// turn and after each review round (a direction listed mid-round arrives
|
|
4905
|
+
// after that round's prompt was built). Never before a ship: the exits are
|
|
4906
|
+
// derived from the tree the last turn left, and a turn would change it.
|
|
4907
|
+
// Reports `stopped` when a person stopped the run mid-turn.
|
|
4908
|
+
let directionTurns = 0;
|
|
4909
|
+
const runDirectionTurns = async () => {
|
|
4910
|
+
const outcome = { stopped: false, advanced: false };
|
|
4911
|
+
// A conflict repair owns the tree until it settles; directions wait and
|
|
4912
|
+
// are answered at close.
|
|
4913
|
+
if (!directions || activeConflictMerge) return outcome;
|
|
4914
|
+
while (!signal?.aborted && directions.size() > 0 && directionTurns < MAX_DIRECTION_TURNS) {
|
|
4915
|
+
const batch = directions.take();
|
|
4916
|
+
directionTurns += 1;
|
|
4917
|
+
console.log(` direction → running one more turn with ${batch.length} direction(s) from the room`);
|
|
4918
|
+
const commandId = `direction-turn:${randomUUID()}`;
|
|
4919
|
+
const applied = await runAndStage(directionTurnPrompt(codePrompt, batch), { resume: false });
|
|
4920
|
+
if (applied.aborted) {
|
|
4921
|
+
outcome.stopped = true;
|
|
4922
|
+
return outcome;
|
|
4923
|
+
}
|
|
4924
|
+
await settleDirectionTurn(batch, applied, commandId);
|
|
4925
|
+
// `staged` always describes the repo after the last turn that ran, so
|
|
4926
|
+
// every exit below (self-committed, no changes, proposal, ship) is
|
|
4927
|
+
// derived from what the tree holds now, never from a stale snapshot.
|
|
4928
|
+
if (directionTurnRan(applied)) {
|
|
4929
|
+
staged = applied;
|
|
4930
|
+
outcome.advanced = true;
|
|
4931
|
+
}
|
|
4932
|
+
}
|
|
4933
|
+
return outcome;
|
|
4934
|
+
};
|
|
4935
|
+
const afterCoding = await runDirectionTurns();
|
|
4936
|
+
if (afterCoding.stopped) return await postStopped();
|
|
3962
4937
|
// Persist this run's session (best-effort) so the NEXT iterate can resume it and
|
|
3963
4938
|
// machine-match. prUrl is filled in again at ship time.
|
|
3964
4939
|
await recordSession(continuingPrUrl);
|
|
@@ -3968,30 +4943,38 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
3968
4943
|
resumeSessionId = null;
|
|
3969
4944
|
// The CLI committed on its own (clean tree, but commits ahead of base). Don't
|
|
3970
4945
|
// report "no changes" or delete the branch — surface the real work.
|
|
4946
|
+
// Approve-before-push: a self-pushing CLI already bypassed the gate; be
|
|
4947
|
+
// honest and let the human review instead of auto-shipping. Leave the
|
|
4948
|
+
// branch. A closure, because a direction turn inside a review round can
|
|
4949
|
+
// end the same way (1182).
|
|
4950
|
+
const finishSelfCommittedGated = async () => {
|
|
4951
|
+
await tool("post_message", {
|
|
4952
|
+
channelId,
|
|
4953
|
+
parentId: threadRoot,
|
|
4954
|
+
broadcast: true,
|
|
4955
|
+
body:
|
|
4956
|
+
`The coding agent committed on \`${branch}\` itself on its last turn (I asked it to only edit files). ` +
|
|
4957
|
+
`Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
|
|
4958
|
+
`re-mention me to ship or discard.`,
|
|
4959
|
+
});
|
|
4960
|
+
await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
|
|
4961
|
+
await updateChannelMarker(compactRunMarker("changes", branch));
|
|
4962
|
+
await settleRunUsage(); // no report on this exit (0787)
|
|
4963
|
+
// 1182 — no turn is left in this pass: what is held is answered now.
|
|
4964
|
+
if (directions) await directions.close();
|
|
4965
|
+
return { status: "self-committed-gated", branch };
|
|
4966
|
+
};
|
|
3971
4967
|
if (staged.empty && !staged.failed && staged.ahead > 0) {
|
|
3972
|
-
if (cfg.gate)
|
|
3973
|
-
// Approve-before-push: a self-pushing CLI already bypassed the gate; be
|
|
3974
|
-
// honest and let the human review instead of auto-shipping. Leave the branch.
|
|
3975
|
-
await tool("post_message", {
|
|
3976
|
-
channelId,
|
|
3977
|
-
parentId: threadRoot,
|
|
3978
|
-
broadcast: true,
|
|
3979
|
-
body:
|
|
3980
|
-
`The coding agent committed on \`${branch}\` itself (I asked it to only edit files). ` +
|
|
3981
|
-
`Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
|
|
3982
|
-
`re-mention me to ship or discard.`,
|
|
3983
|
-
});
|
|
3984
|
-
await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
|
|
3985
|
-
await updateChannelMarker(compactRunMarker("changes", branch));
|
|
3986
|
-
await settleRunUsage(); // no report on this exit (0787)
|
|
3987
|
-
return { status: "self-committed-gated", branch };
|
|
3988
|
-
}
|
|
4968
|
+
if (cfg.gate && !activeConflictMerge) return await finishSelfCommittedGated();
|
|
3989
4969
|
// When a live status card was streaming, settle the report onto it (0289) —
|
|
3990
4970
|
// the card cross-fades run → report. Skip finalizeProgress then (it edits the
|
|
3991
4971
|
// card's body, which the settle overwrites with the report anyway).
|
|
3992
4972
|
if (signal?.aborted || (await stoppedBeforeShip())) return await postStopped();
|
|
3993
4973
|
const selfSettleId = streamOn && progressId ? progressId : null;
|
|
3994
4974
|
if (!selfSettleId) await finalizeProgress(`Agent shipped \`${branch}\` itself — report below.`);
|
|
4975
|
+
// 1182 — closed before the report ends the pass on the server (see the
|
|
4976
|
+
// ungated ship below for why).
|
|
4977
|
+
if (directions) await directions.close();
|
|
3995
4978
|
const selfResult = await shipSelfDriven({
|
|
3996
4979
|
repoPath,
|
|
3997
4980
|
branch,
|
|
@@ -4004,15 +4987,24 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4004
4987
|
parentId: threadRoot,
|
|
4005
4988
|
settleId: selfSettleId,
|
|
4006
4989
|
runId,
|
|
4990
|
+
iterateTurn,
|
|
4007
4991
|
provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
|
|
4008
4992
|
usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
|
|
4993
|
+
shouldContinue: async () => !signal?.aborted && !(await stoppedBeforeShip()),
|
|
4009
4994
|
});
|
|
4995
|
+
if (selfResult.status === "stopped") return await postStopped();
|
|
4996
|
+
if (selfResult.status === "stop-compensation-pending") {
|
|
4997
|
+
return await postStopCompensationPending(selfResult);
|
|
4998
|
+
}
|
|
4010
4999
|
await recordSession(selfResult.prUrl);
|
|
4011
5000
|
await uploadTranscript(selfResult.reportMsgId);
|
|
4012
5001
|
await updateChannelMarker(compactRunMarker(selfResult.status, selfResult.branch));
|
|
4013
5002
|
return selfResult;
|
|
4014
5003
|
}
|
|
4015
|
-
|
|
5004
|
+
// The tree holds nothing to propose: report why and leave or clean the
|
|
5005
|
+
// branch. A closure, because a direction turn inside a review round can
|
|
5006
|
+
// leave the tree this way too (1182); it always reads the current `staged`.
|
|
5007
|
+
const finishEmptyTree = async ({ afterDirection = false } = {}) => {
|
|
4016
5008
|
// When continuing an existing PR, never delete that branch — it's the open
|
|
4017
5009
|
// PR — and don't say "cleaning up". Just report and leave the PR as it was.
|
|
4018
5010
|
const cleanupNote = continuingPrUrl ? "" : ` Cleaning up \`${branch}\`.`;
|
|
@@ -4030,6 +5022,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4030
5022
|
: "";
|
|
4031
5023
|
const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
|
|
4032
5024
|
body = `The run didn't finish${why}${tail}.${retryTail}`;
|
|
5025
|
+
} else if (afterDirection) {
|
|
5026
|
+
// A direction turn inside a review round left nothing staged: say so,
|
|
5027
|
+
// since a proposal card showing a diff may still be standing.
|
|
5028
|
+
body = continuingPrUrl
|
|
5029
|
+
? `The direction from the room left no changes — \`${branch}\` is unchanged.`
|
|
5030
|
+
: `The direction from the room left no changes on \`${branch}\`.${cleanupNote}`;
|
|
4033
5031
|
} else {
|
|
4034
5032
|
body = continuingPrUrl
|
|
4035
5033
|
? `That feedback produced no further changes — \`${branch}\` is unchanged.`
|
|
@@ -4047,13 +5045,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4047
5045
|
compactRunMarker(staged.failed ? "run-failed" : "no-changes", branch),
|
|
4048
5046
|
);
|
|
4049
5047
|
await settleRunUsage(); // "no changes" is not "no spend" (0787)
|
|
5048
|
+
// 1182 — no turn is left in this pass: what is held is answered now.
|
|
5049
|
+
if (directions) await directions.close();
|
|
4050
5050
|
return { status: staged.failed ? "run-failed" : "no-changes" };
|
|
4051
|
-
}
|
|
5051
|
+
};
|
|
5052
|
+
if (staged.empty) return await finishEmptyTree({ afterDirection: afterCoding.advanced });
|
|
4052
5053
|
|
|
4053
5054
|
// Bias to action (default): commit, push, and open a PR for review now — the
|
|
4054
5055
|
// report card's Approve merges it. `gate:true` keeps the older
|
|
4055
5056
|
// propose-a-diff-and-wait flow for users who want approve-before-push.
|
|
4056
|
-
if (!cfg.gate) {
|
|
5057
|
+
if (!cfg.gate || activeConflictMerge) {
|
|
4057
5058
|
// A "stop" that lands between the run finishing and the ship must still win.
|
|
4058
5059
|
// The signal covers a stop we already heard; the server check covers one that
|
|
4059
5060
|
// landed while the CLI was silent and nothing was carrying it home.
|
|
@@ -4065,13 +5066,23 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4065
5066
|
// separate ticket). No status card (older server / status post failed) →
|
|
4066
5067
|
// settleId is null and applyDecision posts a fresh final report instead.
|
|
4067
5068
|
const settleId = streamOn && progressId ? progressId : null;
|
|
5069
|
+
// 1182 — the inbox closes BEFORE the report: the report ends the pass on
|
|
5070
|
+
// the server, and the server's run-end rule would otherwise speak first,
|
|
5071
|
+
// with a notice meant for a run that stopped and before a lost receipt
|
|
5072
|
+
// could be retried. No extra turn here: the exits above were derived
|
|
5073
|
+
// from the tree the last turn left, and a turn now would change it.
|
|
5074
|
+
if (directions) await directions.close();
|
|
4068
5075
|
const result = await applyDecision({
|
|
4069
5076
|
decision: { kind: "approved" },
|
|
4070
5077
|
repoPath,
|
|
4071
5078
|
branch,
|
|
4072
5079
|
// For a continuation the raw mention is the rework ping ("please update the
|
|
4073
5080
|
// PR …"); use the router's distilled brief for a clean commit/PR title.
|
|
4074
|
-
task:
|
|
5081
|
+
task: activeConflictMerge
|
|
5082
|
+
? conflictCommitTitle(activeConflictMerge.baseBranch)
|
|
5083
|
+
: continuingPrUrl
|
|
5084
|
+
? routed.task || message.body
|
|
5085
|
+
: message.body,
|
|
4075
5086
|
requester: message.author,
|
|
4076
5087
|
cfg,
|
|
4077
5088
|
tool,
|
|
@@ -4081,9 +5092,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4081
5092
|
existingPrUrl: continuingPrUrl,
|
|
4082
5093
|
settleId,
|
|
4083
5094
|
runId,
|
|
5095
|
+
iterateTurn,
|
|
4084
5096
|
provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
|
|
4085
5097
|
usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
|
|
5098
|
+
shouldContinue: async () => !signal?.aborted && !(await stoppedBeforeShip()),
|
|
4086
5099
|
});
|
|
5100
|
+
if (result.status === "stopped") return await postStopped();
|
|
5101
|
+
if (result.status === "stop-compensation-pending") {
|
|
5102
|
+
return await postStopCompensationPending(result);
|
|
5103
|
+
}
|
|
4087
5104
|
// The settle already flipped the card to the report (body + metadata); editing
|
|
4088
5105
|
// the body again would clobber the report summary, so only finalize when we
|
|
4089
5106
|
// posted a separate report.
|
|
@@ -4108,11 +5125,45 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4108
5125
|
parentId: threadRoot,
|
|
4109
5126
|
body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
|
|
4110
5127
|
});
|
|
5128
|
+
// 1182 — a direction that arrived while the reviewer was deciding rides
|
|
5129
|
+
// this round's prompt, after the feedback, and gets this round's receipt.
|
|
5130
|
+
const roundDirections = directions ? directions.take() : [];
|
|
5131
|
+
const roundCommandId = `direction-turn:${randomUUID()}`;
|
|
5132
|
+
const roundPrompt = `${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`;
|
|
4111
5133
|
const refined = await runAndStage(
|
|
4112
|
-
|
|
5134
|
+
roundDirections.length > 0 ? directionTurnPrompt(roundPrompt, roundDirections) : roundPrompt,
|
|
4113
5135
|
);
|
|
4114
5136
|
if (refined.aborted) return await postStopped();
|
|
4115
|
-
if (
|
|
5137
|
+
if (roundDirections.length > 0) await settleDirectionTurn(roundDirections, refined, roundCommandId);
|
|
5138
|
+
// The round's own turn is a turn that ran: `staged` follows it (1182
|
|
5139
|
+
// round 5), so the invariant holds for every turn, not only direction
|
|
5140
|
+
// turns. Then a direction listed during this round (it arrived after the
|
|
5141
|
+
// prompt was built) gets its turn, and whichever tree is newest is what
|
|
5142
|
+
// the reviewer sees: the proposal card is the gate, and it shows what
|
|
5143
|
+
// will ship.
|
|
5144
|
+
if (!directionTurnRan(refined)) {
|
|
5145
|
+
// The round's command never started: nothing changed and nothing was
|
|
5146
|
+
// seen, so the earlier proposal stands, said plainly, never re-posted.
|
|
5147
|
+
await tool("post_message", {
|
|
5148
|
+
channelId,
|
|
5149
|
+
parentId: threadRoot,
|
|
5150
|
+
broadcast: true,
|
|
5151
|
+
body: `I couldn't start \`${cfg.codingCmd}\` for this round — leaving \`${branch}\` as proposed.`,
|
|
5152
|
+
});
|
|
5153
|
+
break;
|
|
5154
|
+
}
|
|
5155
|
+
staged = refined;
|
|
5156
|
+
const afterRound = await runDirectionTurns();
|
|
5157
|
+
if (afterRound.stopped) return await postStopped();
|
|
5158
|
+
if (staged.empty) {
|
|
5159
|
+
// Nothing staged after this round's turns. The CLI's own commits end
|
|
5160
|
+
// the pass the way they would after the coding turn (approve-before-
|
|
5161
|
+
// push never auto-ships them, whether a feedback round or a direction
|
|
5162
|
+
// turn made them); a direction turn that left the tree clean ends it as
|
|
5163
|
+
// no changes; a feedback round alone that produced nothing leaves the
|
|
5164
|
+
// earlier proposal standing, as it always has.
|
|
5165
|
+
if (!staged.failed && staged.ahead > 0) return await finishSelfCommittedGated();
|
|
5166
|
+
if (afterRound.advanced) return await finishEmptyTree({ afterDirection: true });
|
|
4116
5167
|
await tool("post_message", {
|
|
4117
5168
|
channelId,
|
|
4118
5169
|
parentId: threadRoot,
|
|
@@ -4121,7 +5172,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4121
5172
|
});
|
|
4122
5173
|
break;
|
|
4123
5174
|
}
|
|
4124
|
-
proposal = await postProposal(
|
|
5175
|
+
proposal = await postProposal(staged);
|
|
4125
5176
|
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
|
|
4126
5177
|
if (decision.kind === "cancelled") return await postStopped();
|
|
4127
5178
|
round += 1;
|
|
@@ -4129,6 +5180,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4129
5180
|
|
|
4130
5181
|
// A stop during the final wait, too — don't ship a cancelled run.
|
|
4131
5182
|
if (signal?.aborted || (await stoppedBeforeShip())) return await postStopped();
|
|
5183
|
+
// 1182 — closed before the report ends the pass on the server (see the
|
|
5184
|
+
// ungated ship above for why). The reviewer approved this tree; no further
|
|
5185
|
+
// turn may change it.
|
|
5186
|
+
if (directions) await directions.close();
|
|
4132
5187
|
const result = await applyDecision({
|
|
4133
5188
|
decision,
|
|
4134
5189
|
repoPath,
|
|
@@ -4144,11 +5199,17 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4144
5199
|
parentId: threadRoot,
|
|
4145
5200
|
existingPrUrl: continuingPrUrl,
|
|
4146
5201
|
runId,
|
|
5202
|
+
iterateTurn,
|
|
4147
5203
|
provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
|
|
4148
5204
|
// Normally {} — a gated run already reported its spend on the proposal card
|
|
4149
5205
|
// it is now shipping. Anything the CLI spent after that still comes home.
|
|
4150
5206
|
usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
|
|
5207
|
+
shouldContinue: async () => !signal?.aborted && !(await stoppedBeforeShip()),
|
|
4151
5208
|
});
|
|
5209
|
+
if (result.status === "stopped") return await postStopped();
|
|
5210
|
+
if (result.status === "stop-compensation-pending") {
|
|
5211
|
+
return await postStopCompensationPending(result);
|
|
5212
|
+
}
|
|
4152
5213
|
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
4153
5214
|
await recordSession(result.prUrl);
|
|
4154
5215
|
// The gated lane's shipped report is a fresh message; `result.reportMsgId`
|
|
@@ -4157,6 +5218,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
4157
5218
|
await updateChannelMarker(compactRunMarker(result.status, result.branch));
|
|
4158
5219
|
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
|
|
4159
5220
|
} finally {
|
|
5221
|
+
// Any early exit while Git is still mid-merge must leave the user's checkout
|
|
5222
|
+
// clean enough to switch back and restore their stash. A successful merge
|
|
5223
|
+
// commit has already removed MERGE_HEAD, so this is a no-op on the happy path.
|
|
5224
|
+
if (
|
|
5225
|
+
activeConflictMerge &&
|
|
5226
|
+
git(repoPath, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).status === 0
|
|
5227
|
+
) {
|
|
5228
|
+
git(repoPath, ["merge", "--abort"]);
|
|
5229
|
+
}
|
|
4160
5230
|
// Whatever happened, give the user their uncommitted work back.
|
|
4161
5231
|
await restoreStash();
|
|
4162
5232
|
}
|