@indigoai-us/hq-cli 5.345.73 → 5.345.75
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/CHANGELOG.md +20 -0
- package/assets/scaffold/core/scripts/hq-delegate-pickup.sh +11 -4
- package/dist/commands/core-delegate-w1b.js +53 -11
- package/dist/commands/core-delegate.d.ts +5 -2
- package/dist/commands/core-delegate.js +43 -8
- package/dist/commands/install-global.js +4 -2
- package/dist/commands/lanes-loop.js +23 -2
- package/dist/commands/lanes-resume.d.ts +2 -1
- package/dist/commands/lanes-resume.js +13 -2
- package/dist/commands/lanes-shared.d.ts +2 -0
- package/dist/commands/lanes.d.ts +1 -0
- package/dist/commands/lanes.js +30 -11
- package/dist/lib/lanes/dropbox.js +1 -0
- package/dist/lib/lanes/flag-keys.d.ts +2 -0
- package/dist/lib/lanes/flag-keys.js +2 -0
- package/dist/lib/lanes/loop.d.ts +2 -0
- package/dist/lib/lanes/loop.js +52 -14
- package/dist/lib/lanes/reconcile.js +8 -2
- package/dist/lib/lanes/spawn.d.ts +3 -1
- package/dist/lib/lanes/spawn.js +122 -2
- package/dist/lib/lanes/types.d.ts +4 -0
- package/dist/lib/lanes/watcher.d.ts +1 -12
- package/dist/lib/lanes/watcher.js +53 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.75] — 2026-10-10
|
|
6
|
+
|
|
7
|
+
- Native delegate manifest updates now use mode 0600 on POSIX, matching the
|
|
8
|
+
shell commands and preventing the manifest from becoming readable by other
|
|
9
|
+
local users.
|
|
10
|
+
- Lane lifecycle delivery, worker continuity, status-row fields, and operator-requested orphan stopping now stay enabled when their hq-flags registry rows are missing or unavailable.
|
|
11
|
+
- `hq uninstall --global --runtime codex` is now idempotent: on a machine with
|
|
12
|
+
no global Codex installation it reports that there is nothing to remove and
|
|
13
|
+
exits successfully.
|
|
14
|
+
- Delegate send now stops before DM delivery and reports the shell-compatible
|
|
15
|
+
error when saving the `sending` state fails.
|
|
16
|
+
|
|
17
|
+
## [5.345.74] — 2026-10-10
|
|
18
|
+
|
|
19
|
+
- Lane envelope repair prompts now identify parser locations and schema fields,
|
|
20
|
+
include a minimal valid envelope, and keep `next_check_at` optional. Section 8
|
|
21
|
+
loop workers receive explicit handoff enums and one same-session repair turn;
|
|
22
|
+
failed handoff summaries retain the validation error. Both behaviors have
|
|
23
|
+
default-on hq-flags kill switches.
|
|
24
|
+
|
|
5
25
|
## [5.345.73] — 2026-10-10
|
|
6
26
|
- Fixed company resolution so a minimal entity liveness response is not treated as a full company record.
|
|
7
27
|
|
|
@@ -59,9 +59,13 @@ case "$STATUS" in
|
|
|
59
59
|
esac
|
|
60
60
|
|
|
61
61
|
record_pickup() { # <kind> <detail>
|
|
62
|
-
|
|
62
|
+
local TMP_MANIFEST
|
|
63
|
+
TMP_MANIFEST="$(mktemp "${MANIFEST}.tmp.XXXXXX")" || exit 1
|
|
64
|
+
if ! jq --arg now "$NOW" --arg k "$1" --arg d "$2" \
|
|
63
65
|
'.status = "picked-up" | .pickedUpAt = $now | .pickup = {kind: $k, detail: $d, recordedAt: $now}' \
|
|
64
|
-
"$MANIFEST" > "$
|
|
66
|
+
"$MANIFEST" > "$TMP_MANIFEST" || ! mv "$TMP_MANIFEST" "$MANIFEST"; then
|
|
67
|
+
rm -f "$TMP_MANIFEST"
|
|
68
|
+
fi
|
|
65
69
|
echo "hq-delegate-pickup: $NAME picked up the delegation ($1) — status advanced to 'picked-up'"
|
|
66
70
|
exit 0
|
|
67
71
|
}
|
|
@@ -93,7 +97,10 @@ if [ "$age_h" -lt "$WINDOW" ]; then
|
|
|
93
97
|
exit 6
|
|
94
98
|
fi
|
|
95
99
|
reason="no pickup evidence from $NAME within ${WINDOW}h of send (${SENT_AT:-unknown}): no acknowledgement, no commit by $TO on ${BRANCH:-the branch}"
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
TMP_MANIFEST="$(mktemp "${MANIFEST}.tmp.XXXXXX")" || exit 1
|
|
101
|
+
if ! jq --arg now "$NOW" --arg r "$reason" '.status = "failed" | .failedAt = $now | .failure = {reason: $r, recordedAt: $now}' \
|
|
102
|
+
"$MANIFEST" > "$TMP_MANIFEST" || ! mv "$TMP_MANIFEST" "$MANIFEST"; then
|
|
103
|
+
rm -f "$TMP_MANIFEST"
|
|
104
|
+
fi
|
|
98
105
|
echo "hq-delegate-pickup: FAILED — $reason. Re-send with a direct ask, or hand it to someone else." >&2
|
|
99
106
|
exit 5
|
|
@@ -174,6 +174,23 @@ function parseFlags(args, allowed) {
|
|
|
174
174
|
}
|
|
175
175
|
function usage(name) { return USAGE[name] ?? ""; }
|
|
176
176
|
function usageFailure(name) { err(usage(name)); return 1; }
|
|
177
|
+
function grantHasDanglingManifestFlag(args) {
|
|
178
|
+
for (let i = 0; i < args.length;) {
|
|
179
|
+
const key = args[i] ?? "";
|
|
180
|
+
if (key === "--yes") {
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (key === "--manifest") {
|
|
185
|
+
if (i + 1 >= args.length)
|
|
186
|
+
return true;
|
|
187
|
+
i += 2;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
177
194
|
function bashPath(value) {
|
|
178
195
|
if (process.platform !== "win32")
|
|
179
196
|
return value;
|
|
@@ -205,6 +222,10 @@ function runBundle(args, context, onMalformedWhoami) {
|
|
|
205
222
|
const root = context.root ?? context.cwd;
|
|
206
223
|
if (args[0] !== "build")
|
|
207
224
|
return usageFailure("bundle");
|
|
225
|
+
// The shell uses `shift 2` for these options. With no value at end of argv,
|
|
226
|
+
// Bash exits nonzero before it reaches the required-field diagnostics.
|
|
227
|
+
if (["--company", "--project", "--to", "--to-kind", "--to-name", "--mode"].includes(args.at(-1) ?? ""))
|
|
228
|
+
return 1;
|
|
208
229
|
const parsed = parseFlags(args.slice(1), ["--company", "--project", "--to", "--to-kind", "--to-name", "--mode", "--dry-run"]);
|
|
209
230
|
if (parsed.help) {
|
|
210
231
|
out(usage("bundle"));
|
|
@@ -326,7 +347,7 @@ function runBundle(args, context, onMalformedWhoami) {
|
|
|
326
347
|
const next = stories.filter((story) => !isRecord(story) || story.passes !== true)
|
|
327
348
|
.sort((a, b) => Number(getPath(a, "priority", 0)) - Number(getPath(b, "priority", 0))).slice(0, 3);
|
|
328
349
|
const doneList = done.map((story) => `- ${text(getPath(story, "id"))}: ${text(getPath(story, "title"))}`).join("\n");
|
|
329
|
-
const nextList = next.map((story) => `### ${text(getPath(story, "id"))}: ${text(getPath(story, "title"))}\n\n${text(getPath(story, "description"))}\n`).join("\n");
|
|
350
|
+
const nextList = next.map((story) => `### ${text(getPath(story, "id"))}: ${text(getPath(story, "title"))}\n\n${text(getPath(story, "description"))}\n`).join("\n").replace(/\n+$/, "");
|
|
330
351
|
const questions = list(getPath(prd, "metadata.openQuestions", [])).map((entry) => `- ${text(entry)}`).join("\n");
|
|
331
352
|
const traps = [
|
|
332
353
|
...list(getPath(prd, "metadata.securityNotes", [])),
|
|
@@ -410,6 +431,10 @@ const ACL_FILTER = `
|
|
|
410
431
|
(.direct | type == "array") and (.exists | type == "boolean")
|
|
411
432
|
`;
|
|
412
433
|
function runGrant(args, context) {
|
|
434
|
+
// The shell companion's `shift 2` runs under `set -e`; a final bare
|
|
435
|
+
// --manifest exits 1 without diagnostics. Preserve that observable contract.
|
|
436
|
+
if (grantHasDanglingManifestFlag(args))
|
|
437
|
+
return 1;
|
|
413
438
|
const parsed = parseFlags(args, ["--manifest", "--yes"]);
|
|
414
439
|
if (parsed.help) {
|
|
415
440
|
out(usage("grant"));
|
|
@@ -618,10 +643,14 @@ function runGrant(args, context) {
|
|
|
618
643
|
out(`hq-delegate-grant: all ${prefixes.length} grants verified — manifest status advanced to 'granted'\n`);
|
|
619
644
|
return 0;
|
|
620
645
|
}
|
|
621
|
-
function atomicCopy(source, target) {
|
|
646
|
+
function atomicCopy(source, target, mode) {
|
|
622
647
|
const temporary = `${target}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`;
|
|
623
648
|
try {
|
|
624
|
-
fs.
|
|
649
|
+
const contents = fs.readFileSync(source);
|
|
650
|
+
if (mode === undefined)
|
|
651
|
+
fs.writeFileSync(temporary, contents, { flag: "wx" });
|
|
652
|
+
else
|
|
653
|
+
fs.writeFileSync(temporary, contents, { flag: "wx", mode });
|
|
625
654
|
moveFile(temporary, target);
|
|
626
655
|
}
|
|
627
656
|
catch (error) {
|
|
@@ -745,9 +774,9 @@ function runPublish(args, context) {
|
|
|
745
774
|
if (shareSession.test(fs.readFileSync(stagedManifest, "utf8")) || shareSession.test(fs.readFileSync(stagedBrief, "utf8")))
|
|
746
775
|
return fail("publish", "share-session capability in dossier; nothing published");
|
|
747
776
|
fs.mkdirSync(dest, { recursive: true });
|
|
748
|
-
atomicCopy(stagedBrief, path.join(dest, "BRIEF.md"));
|
|
749
|
-
atomicCopy(stagedManifest, path.join(dest, "manifest.json"));
|
|
750
|
-
atomicCopy(stagedManifest, manifestPath);
|
|
777
|
+
atomicCopy(stagedBrief, path.join(dest, "BRIEF.md"), 0o600);
|
|
778
|
+
atomicCopy(stagedManifest, path.join(dest, "manifest.json"), 0o600);
|
|
779
|
+
atomicCopy(stagedManifest, manifestPath, 0o600);
|
|
751
780
|
const push = runHq(["sync", "push", `${relative}/`, "--company", company, "--on-conflict", "keep"], context, "inherit");
|
|
752
781
|
if (push.code !== 0)
|
|
753
782
|
return fail("publish", "final dossier push failed; resume publication");
|
|
@@ -775,7 +804,7 @@ function runPublish(args, context) {
|
|
|
775
804
|
const publication = { verifiedAt: currentTime(), manifestPath: `${relative}/delegation/${id}/manifest.json`, files: receipts };
|
|
776
805
|
const updated = { ...canonical, publication, recipientAccess: "unconfirmed" };
|
|
777
806
|
fs.writeFileSync(stagedManifest, jsonText(updated));
|
|
778
|
-
atomicCopy(stagedManifest, manifestPath);
|
|
807
|
+
atomicCopy(stagedManifest, manifestPath, 0o600);
|
|
779
808
|
out("hq-delegate-publish: canonical dossier bytes verified as sender; recipient access unconfirmed\n");
|
|
780
809
|
return 0;
|
|
781
810
|
}
|
|
@@ -794,6 +823,10 @@ function sortedJson(value) {
|
|
|
794
823
|
return serializeJqJson(sort(value), "compact").replace(/\n+$/, "");
|
|
795
824
|
}
|
|
796
825
|
function runSend(args, context) {
|
|
826
|
+
// The shell shifts two arguments for value options. Under `set -e`, a
|
|
827
|
+
// trailing option without its value exits 1 before printing diagnostics.
|
|
828
|
+
if (["--manifest", "--headline", "--note"].includes(args.at(-1) ?? ""))
|
|
829
|
+
return 1;
|
|
797
830
|
const parsed = parseFlags(args, ["--manifest", "--send", "--headline", "--note"]);
|
|
798
831
|
if (parsed.help) {
|
|
799
832
|
out(usage("send"));
|
|
@@ -884,7 +917,7 @@ function runSend(args, context) {
|
|
|
884
917
|
const access = jqRawPath(manifest, "repo.accessNote");
|
|
885
918
|
const branchCheck = branch ? subprocess("git", ["check-ref-format", "--branch", branch], context.cwd, { stdout: "ignore", stderr: "ignore" }) : { code: 1 };
|
|
886
919
|
if (branch && branchCheck.code === 0 && sha) {
|
|
887
|
-
repoSection = `## Get the code\n\nThe work lives on branch \`${branch}\` (base: \`${base}\`) at \`${sha}\`.\n\n\`\`\`bash\n# From your HQ root; clone first if the repo is not present${remote ? ` (remote: ${remote})` : ""}:\ngit -C ${quoteBash(repoPath)} fetch origin\ngit -C ${quoteBash(repoPath)} switch -- ${quoteBash(branch)}\n\`\`\`${access ? `\n\n> ${access}` : ""}`;
|
|
920
|
+
repoSection = `## Get the code\n\nThe work lives on branch \`${branch}\` (base: \`${base}\`) at \`${sha}\`.\n\n\`\`\`bash\n# From your HQ root; clone first if the repo is not present${remote ? ` (remote: ${remote})` : ""}:\ngit -C ${quoteBash(repoPath)} fetch origin\ngit -C ${quoteBash(repoPath)} switch -- ${quoteBash(branch)}\n\`\`\`${access ? `\n\n> ${access}` : "\n"}`;
|
|
888
921
|
}
|
|
889
922
|
}
|
|
890
923
|
const secretNames = list(getPath(manifest, "secrets", [])).map((entry) => text(entry)).join(",");
|
|
@@ -906,7 +939,7 @@ function runSend(args, context) {
|
|
|
906
939
|
if (repoSection)
|
|
907
940
|
body += `${repoSection}\n\n`;
|
|
908
941
|
if (secretsSection)
|
|
909
|
-
body += `${secretsSection}\n`;
|
|
942
|
+
body += `${secretsSection}\n\n`;
|
|
910
943
|
if (nextSteps)
|
|
911
944
|
body += `## Your next three steps\n\n${nextSteps}\n\n`;
|
|
912
945
|
body += `## If something is off\n\nThe manifest and BRIEF.md are published under \`delegation/${delegationId}/\` within \`companies/${company}/projects/${project}/\`. If a pull fails or a path looks empty, report it to ${from}.\n`;
|
|
@@ -956,7 +989,12 @@ function runSend(args, context) {
|
|
|
956
989
|
if (checksum(brief) !== checksum(path.join(path.dirname(publishedManifestPath), "BRIEF.md")))
|
|
957
990
|
return fail("send", "brief changed since publication; run verification again");
|
|
958
991
|
manifest.status = "sending";
|
|
959
|
-
|
|
992
|
+
try {
|
|
993
|
+
writeJsonAtomically(manifestPath, manifest, true, context.atomicWriteFileSystem);
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
return fail("send", "could not persist sending state; no DM attempted");
|
|
997
|
+
}
|
|
960
998
|
const dm = runHq(["dm", principal, headline, "--prompt-file", prompt, "--details-file", brief], context);
|
|
961
999
|
if (dm.code !== 0)
|
|
962
1000
|
return fail("send", "hq dm delivery outcome is uncertain; status is sending, inspect DM history before retrying");
|
|
@@ -994,6 +1032,10 @@ function captureRun(run) {
|
|
|
994
1032
|
}
|
|
995
1033
|
}
|
|
996
1034
|
function runVerify(args, context) {
|
|
1035
|
+
// The shell shifts a value-taking option and its value under `set -e`.
|
|
1036
|
+
// A trailing option therefore exits 1 before any diagnostic is printed.
|
|
1037
|
+
if (["--manifest", "--company", "--project", "--to", "--mode"].includes(args.at(-1) ?? ""))
|
|
1038
|
+
return 1;
|
|
997
1039
|
const parsed = parseFlags(args, ["--manifest", "--dry-run", "--company", "--project", "--to", "--mode"]);
|
|
998
1040
|
if (parsed.help) {
|
|
999
1041
|
out(usage("verify"));
|
|
@@ -1051,7 +1093,7 @@ function runVerify(args, context) {
|
|
|
1051
1093
|
out(` Recipient: ${recipient}\n Mode: ${mode}\n Company: ${company}\n Project: ${project}\n\n`);
|
|
1052
1094
|
out(" Vault grants that would be written:\n");
|
|
1053
1095
|
for (const entry of list(getPath(manifest, "vaultPrefixes", [])))
|
|
1054
|
-
out(` ${jqRawPath(entry, "permission")}\
|
|
1096
|
+
out(` ${jqRawPath(entry, "permission")}\ton ${jqRawPath(entry, "prefix")}\t(${jqRawPath(entry, "reason")})\n`);
|
|
1055
1097
|
out(`\n Secret names that would be granted (read): ${secretNames}\n\n`);
|
|
1056
1098
|
const repoValue = getPath(manifest, "repo");
|
|
1057
1099
|
if (repoValue !== null && repoValue !== undefined)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/** Native implementations of the five `hq core delegate` shell companions. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
2
3
|
import { Command } from "commander";
|
|
3
4
|
import { type SpawnPlan } from "../utils/windows-spawn.js";
|
|
4
5
|
export declare const CORE_NATIVE_DELEGATE_FLAG = "core-native-delegate";
|
|
5
6
|
export type DelegateContext = {
|
|
6
7
|
cwd: string;
|
|
7
8
|
root?: string;
|
|
9
|
+
atomicWriteFileSystem?: DelegateAtomicWriteFileSystem;
|
|
8
10
|
};
|
|
11
|
+
export type DelegateAtomicWriteFileSystem = Pick<typeof fs, "openSync" | "writeFileSync" | "closeSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync">;
|
|
9
12
|
type CommandResult = {
|
|
10
13
|
code: number;
|
|
11
14
|
stdout?: string;
|
|
@@ -38,8 +41,8 @@ export declare function rawJsonValue(value: unknown): string;
|
|
|
38
41
|
export declare function jqRawPath(value: unknown, dottedPath: string, fallback?: unknown): string;
|
|
39
42
|
export declare function jqRawPaths(value: unknown, dottedPaths: string[], fallback?: unknown): string;
|
|
40
43
|
export declare function requireRecord(value: unknown, property: string): JsonRecord;
|
|
41
|
-
export declare function moveFile(source: string, target: string): void;
|
|
42
|
-
export declare function writeJsonAtomically(target: string, value: unknown, siblingTemp?: boolean): void;
|
|
44
|
+
export declare function moveFile(source: string, target: string, fileSystem?: Pick<DelegateAtomicWriteFileSystem, "renameSync" | "copyFileSync" | "unlinkSync">): void;
|
|
45
|
+
export declare function writeJsonAtomically(target: string, value: unknown, siblingTemp?: boolean, fileSystem?: DelegateAtomicWriteFileSystem): void;
|
|
43
46
|
export declare function delegateDiagnosticPath(argument: string): string;
|
|
44
47
|
export declare function scanSecretFiles(file: string): string[] | undefined;
|
|
45
48
|
/** Fast-entry adapter; family gating and shell fallback happen before import. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Native implementations of the five `hq core delegate` shell companions. */
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
3
4
|
import * as fs from "node:fs";
|
|
4
5
|
import * as os from "node:os";
|
|
5
6
|
import * as path from "node:path";
|
|
@@ -174,25 +175,59 @@ export function requireRecord(value, property) {
|
|
|
174
175
|
throw new TypeError(`Cannot update non-object field ${property}`);
|
|
175
176
|
return result;
|
|
176
177
|
}
|
|
177
|
-
export function moveFile(source, target) {
|
|
178
|
+
export function moveFile(source, target, fileSystem = fs) {
|
|
178
179
|
try {
|
|
179
|
-
|
|
180
|
+
fileSystem.renameSync(source, target);
|
|
180
181
|
}
|
|
181
182
|
catch (error) {
|
|
182
183
|
if (error.code !== "EXDEV")
|
|
183
184
|
throw error;
|
|
184
185
|
// The shell companion uses `mv`, which copies across filesystem boundaries.
|
|
185
|
-
|
|
186
|
-
|
|
186
|
+
fileSystem.copyFileSync(source, target);
|
|
187
|
+
fileSystem.unlinkSync(source);
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
|
-
export function writeJsonAtomically(target, value, siblingTemp = false) {
|
|
190
|
+
export function writeJsonAtomically(target, value, siblingTemp = false, fileSystem = fs) {
|
|
190
191
|
const serialized = serializeJqJson(value, "pretty");
|
|
191
192
|
// Native delegate JSON writers use LF on every platform.
|
|
192
193
|
if (siblingTemp) {
|
|
193
|
-
const temporary = `${target}.tmp`;
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
const temporary = `${target}.tmp.${randomBytes(6).toString("hex")}`;
|
|
195
|
+
let fd;
|
|
196
|
+
let descriptorOpen = false;
|
|
197
|
+
let temporaryCreated = false;
|
|
198
|
+
try {
|
|
199
|
+
fd = fileSystem.openSync(temporary, "wx", 0o600);
|
|
200
|
+
descriptorOpen = true;
|
|
201
|
+
temporaryCreated = true;
|
|
202
|
+
fileSystem.writeFileSync(fd, serialized);
|
|
203
|
+
fileSystem.closeSync(fd);
|
|
204
|
+
descriptorOpen = false;
|
|
205
|
+
moveFile(temporary, target, fileSystem);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const failures = [error];
|
|
209
|
+
if (descriptorOpen && fd !== undefined) {
|
|
210
|
+
try {
|
|
211
|
+
fileSystem.closeSync(fd);
|
|
212
|
+
descriptorOpen = false;
|
|
213
|
+
}
|
|
214
|
+
catch (closeError) {
|
|
215
|
+
failures.push(closeError);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (temporaryCreated) {
|
|
219
|
+
try {
|
|
220
|
+
fileSystem.rmSync(temporary, { force: true });
|
|
221
|
+
}
|
|
222
|
+
catch (cleanupError) {
|
|
223
|
+
failures.push(cleanupError);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (failures.length > 1) {
|
|
227
|
+
throw new AggregateError(failures, "Failed to write and clean up delegate manifest temporary file");
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
196
231
|
return;
|
|
197
232
|
}
|
|
198
233
|
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-delegate-"));
|
|
@@ -919,8 +919,10 @@ export function uninstallGlobalCodex(opts = {}) {
|
|
|
919
919
|
const hqPointer = claudeTargets(home).hqPointer;
|
|
920
920
|
const state = readCodexState(t);
|
|
921
921
|
const hqRoot = state?.hqRoot ?? opts.hqRoot ?? readOrNull(hqPointer)?.trim();
|
|
922
|
-
if (!hqRoot)
|
|
923
|
-
|
|
922
|
+
if (!hqRoot) {
|
|
923
|
+
log('No HQ global Codex install found; nothing to remove.');
|
|
924
|
+
return [];
|
|
925
|
+
}
|
|
924
926
|
const regFile = opts.registryFile ?? registryFile(registryDir(home, env));
|
|
925
927
|
const claudeStillInstalled = fs.existsSync(claudeTargets(home).stateFile);
|
|
926
928
|
const providerBin = path.join(home, '.hq', 'bin');
|
|
@@ -9,6 +9,8 @@ import { raiseLaneQuestion } from "../lib/lanes/questions.js";
|
|
|
9
9
|
import { isTerminalLaneState } from "../lib/lanes/types.js";
|
|
10
10
|
import { clearLoopControllerPid, loopAdmissionPath, runLoopLane, setLoopControllerPid } from "../lib/lanes/loop.js";
|
|
11
11
|
import { laneStories } from "../lib/lanes/stories.js";
|
|
12
|
+
import { readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
|
|
13
|
+
import { LANE_LOOP_HANDOFF_REPAIR_FLAG } from "../lib/lanes/flag-keys.js";
|
|
12
14
|
const LOOP_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
13
15
|
function loopRunDir(hqRoot, laneId) {
|
|
14
16
|
return path.join(hqRoot, LOOP_RUNS_REL, laneId);
|
|
@@ -34,6 +36,11 @@ export async function runLaneLoopController(ctx, laneId, deps) {
|
|
|
34
36
|
if (lane.loop_mode !== true)
|
|
35
37
|
throw new LanesError("lane_not_loop_mode", `Lane ${laneId} is not a loop lane`, { lane_id: laneId });
|
|
36
38
|
const runDir = loopRunDir(hqRoot, laneId);
|
|
39
|
+
const handoffRepairEnabled = await readLanesRegistryFlagFailOpen(LANE_LOOP_HANDOFF_REPAIR_FLAG, {
|
|
40
|
+
company: lane.company ?? lane.mesh_company,
|
|
41
|
+
defaultValue: true,
|
|
42
|
+
env: ctx.env,
|
|
43
|
+
});
|
|
37
44
|
let initialAdmission = ctx.env.HQ_LANE_LOOP_INITIAL === "1";
|
|
38
45
|
const stop = new AbortController();
|
|
39
46
|
const onSignal = () => stop.abort();
|
|
@@ -43,9 +50,10 @@ export async function runLaneLoopController(ctx, laneId, deps) {
|
|
|
43
50
|
const result = await runLoopLane({
|
|
44
51
|
lane,
|
|
45
52
|
runDir,
|
|
53
|
+
repairInvalidHandoff: handoffRepairEnabled,
|
|
46
54
|
signal: stop.signal,
|
|
47
55
|
idlePollMs: 250,
|
|
48
|
-
execute: async ({ envelope, signal, fresh, sessionId }) => {
|
|
56
|
+
execute: async ({ envelope, signal, fresh, sessionId, repairError }) => {
|
|
49
57
|
let current = readLane(hqRoot, laneId);
|
|
50
58
|
const data = envelope;
|
|
51
59
|
const storyId = typeof data.story_id === "string" && data.story_id.trim() ? data.story_id.trim() : current.story_id;
|
|
@@ -88,9 +96,21 @@ export async function runLaneLoopController(ctx, laneId, deps) {
|
|
|
88
96
|
const prompt = typeof data.prompt === "string" ? data.prompt :
|
|
89
97
|
`${phaseContext}\n\nPhase envelope:\n${JSON.stringify(envelope, null, 2)}`;
|
|
90
98
|
const briefPath = path.join(runDir, "brief.md");
|
|
99
|
+
const repairInstructions = repairError && data.schema === "hq-phase-envelope/v1"
|
|
100
|
+
? [
|
|
101
|
+
"HANDOFF REPAIR TURN: the previous handoff failed validation.",
|
|
102
|
+
`Exact validation error: ${repairError}`,
|
|
103
|
+
"Return a corrected hq-phase-handoff/v1 object. Preserve every valid supplied value and extension field.",
|
|
104
|
+
"Allowed status values: passed, failed, blocked. Allowed back_pressure values for tests, lint, typecheck, and build: pass, fail, skip.",
|
|
105
|
+
`Minimal valid shape: ${JSON.stringify({ schema: "hq-phase-handoff/v1", story_id: String(data.story_id), phase: String(data.phase), worker_id: String(data.worker_id), status: "passed", summary: "...", files_changed: [], commits: [], back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" }, context_for_next: "..." }, null, 2)}`,
|
|
106
|
+
].join("\n")
|
|
107
|
+
: "";
|
|
91
108
|
fs.writeFileSync(briefPath, [
|
|
92
109
|
"Follow the lane worker instructions and complete this single bounded phase.",
|
|
93
|
-
|
|
110
|
+
handoffRepairEnabled
|
|
111
|
+
? "Return the standard hq-lane-envelope.v1 envelope when the phase is complete. For a section 8 phase envelope, put a complete hq-phase-handoff/v1 object in artifacts. The lane envelope decision uses done/ask/blocked; handoff status uses passed/failed/blocked. Allowed handoff status values are passed, failed, blocked. Allowed back_pressure values for tests, lint, typecheck, and build are pass, fail, skip. Include story_id, phase, worker_id, summary, files_changed, commits, back_pressure, and context_for_next."
|
|
112
|
+
: "Return the standard hq-lane-envelope.v1 envelope when the phase is complete. For a section 8 phase envelope, put a complete hq-phase-handoff/v1 object in artifacts; it must identify the requested story_id and phase and include worker_id, status, summary, files_changed, commits, back_pressure (tests/lint/typecheck/build), and context_for_next.",
|
|
113
|
+
repairInstructions,
|
|
94
114
|
"",
|
|
95
115
|
prompt,
|
|
96
116
|
].join("\n"));
|
|
@@ -137,6 +157,7 @@ export async function runLaneLoopController(ctx, laneId, deps) {
|
|
|
137
157
|
story: storyId,
|
|
138
158
|
freshThread: fresh,
|
|
139
159
|
...(sessionId ? { loopSessionId: sessionId } : {}),
|
|
160
|
+
...(repairError !== undefined ? { loopHandoffRepair: true } : {}),
|
|
140
161
|
workingDirectory: worktree,
|
|
141
162
|
});
|
|
142
163
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type Ctx, type LaneBalanceMoveTargetOptions, type LaneResumeHandlerDeps, type LaneResumeOptions } from "./lanes-shared.js";
|
|
1
|
+
import { type Ctx, type LaneBalanceMoveTargetOptions, type LaneRecord, type LaneResumeHandlerDeps, type LaneResumeOptions } from "./lanes-shared.js";
|
|
2
|
+
export declare function isLoopHandoffRepairResume(lane: Pick<LaneRecord, "loop_mode" | "state" | "provider_session_id">, opts: Pick<LaneResumeOptions, "loopHandoffRepair" | "loopSessionId">): boolean;
|
|
2
3
|
export declare function createLaneResumeHandlers(deps: LaneResumeHandlerDeps): {
|
|
3
4
|
runClaudeBalanceMoveTarget: (ctx: Ctx, laneId: string, opts: LaneBalanceMoveTargetOptions) => Promise<void>;
|
|
4
5
|
runResume: (ctx: Ctx, laneId: string, opts: LaneResumeOptions) => Promise<void>;
|
|
@@ -10,6 +10,13 @@ import { markSpawnFailure, spawnLaneWorker } from "../lib/lanes/spawn.js";
|
|
|
10
10
|
import { appendProjectFocusOverride, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
|
|
11
11
|
import { appendLaneFocusOverride, makeFocusOverride } from "../lib/lanes/focus-guard.js";
|
|
12
12
|
import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, hasKeepAliveProgress, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
|
|
13
|
+
export function isLoopHandoffRepairResume(lane, opts) {
|
|
14
|
+
return opts.loopHandoffRepair === true &&
|
|
15
|
+
lane.loop_mode === true &&
|
|
16
|
+
lane.state === "awaiting_input" &&
|
|
17
|
+
opts.loopSessionId !== undefined &&
|
|
18
|
+
opts.loopSessionId === lane.provider_session_id;
|
|
19
|
+
}
|
|
13
20
|
export function createLaneResumeHandlers(deps) {
|
|
14
21
|
const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, automaticBalanceMoveHistoryReason, authorizeFailedResume, authorizeResumeProfileRepin, casUpdateLane, dispatchOutpostLaneCommand, laneIsHostedHere, laneWithRecoveredProviderSession, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
|
|
15
22
|
async function runClaudeBalanceMoveTarget(ctx, laneId, opts) {
|
|
@@ -156,6 +163,10 @@ export function createLaneResumeHandlers(deps) {
|
|
|
156
163
|
throw new LanesError("effort_resume_unsupported", `Provider ${lane.provider} cannot change effort on a resumed session`, { lane_id: laneId, provider: lane.provider });
|
|
157
164
|
}
|
|
158
165
|
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
166
|
+
const loopHandoffRepair = isLoopHandoffRepairResume(lane, opts);
|
|
167
|
+
if (opts.loopHandoffRepair === true && !loopHandoffRepair) {
|
|
168
|
+
throw new LanesError("loop_handoff_repair_session_mismatch", "Loop handoff repair requires the awaiting-input loop lane's current provider session", { lane_id: laneId, state: lane.state });
|
|
169
|
+
}
|
|
159
170
|
if (opts.loopSessionId !== undefined && opts.loopSessionId !== lane.provider_session_id) {
|
|
160
171
|
lane = casUpdateLane(hqRoot, laneId, (current) => ({ ...current, provider_session_id: opts.loopSessionId }));
|
|
161
172
|
}
|
|
@@ -192,7 +203,7 @@ export function createLaneResumeHandlers(deps) {
|
|
|
192
203
|
const pendingQuestions = lane.state === "awaiting_input"
|
|
193
204
|
? await deps.pendingQuestionsForLane(ctx, lane)
|
|
194
205
|
: [];
|
|
195
|
-
if (pendingQuestions.length > 0) {
|
|
206
|
+
if (pendingQuestions.length > 0 && !loopHandoffRepair) {
|
|
196
207
|
const companySlug = lane.company?.slug ?? lane.mesh_company ?? "<company-slug>";
|
|
197
208
|
const instructions = pendingQuestions.map(({ question_id, audience }) => `question ${question_id} awaits ${audience.kind}:${audience.id}; answer it with hq lanes questions answer ${question_id} --company ${companySlug} --freeform <answer>`);
|
|
198
209
|
const message = `Lane ${laneId} is awaiting input. ${instructions.join("; ")}`;
|
|
@@ -632,7 +643,7 @@ export function createLaneResumeHandlers(deps) {
|
|
|
632
643
|
kind: "resume",
|
|
633
644
|
from: resumedFrom,
|
|
634
645
|
...(opts.freshThread === true ? { fresh: true } : {}),
|
|
635
|
-
...(resumedFrom === "awaiting_input" && pendingQuestions.length === 0 ? { allowAwaitingInput: true } : {}),
|
|
646
|
+
...(resumedFrom === "awaiting_input" && (pendingQuestions.length === 0 || loopHandoffRepair) ? { allowAwaitingInput: true } : {}),
|
|
636
647
|
...(opts.fromFailed === true ? { fromFailed: true } : {}),
|
|
637
648
|
...(failedResumeExpectedSenior !== undefined
|
|
638
649
|
? { expectedSenior: failedResumeExpectedSenior }
|
|
@@ -81,6 +81,8 @@ export interface LaneResumeOptions {
|
|
|
81
81
|
workingDirectory?: string;
|
|
82
82
|
/** Internal loop runner session; selects this story's saved provider session. */
|
|
83
83
|
loopSessionId?: string;
|
|
84
|
+
/** Allows only a section 8 handoff repair to continue a loop lane with a pending question. */
|
|
85
|
+
loopHandoffRepair?: boolean;
|
|
84
86
|
meshCompany?: string;
|
|
85
87
|
profileRepinContext?: string;
|
|
86
88
|
grantContext?: string;
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -158,5 +158,6 @@ export declare function pickupFailureUpdater(reasonRaw: string, details: Record<
|
|
|
158
158
|
export declare function applyOutpostAccountPickup(hqRoot: string, laneId: string, modelDeps?: KnownModelsDeps): LaneRecord;
|
|
159
159
|
export declare function cancelPendingQuestionsForLane(hqRoot: string, laneId: string, nowIso: string, operatorReason?: string): QuestionRecord[];
|
|
160
160
|
export declare function stopCommittedState(state: LaneRecord["state"]): "cancelled" | "killed";
|
|
161
|
+
export declare function readLaneReapStopEnabled(hqRoot: string, lane: LaneRecord, env: NodeJS.ProcessEnv): Promise<boolean>;
|
|
161
162
|
export declare function registerLanesCommand(parent: Command, deps?: LanesCommandDeps): Command;
|
|
162
163
|
//# sourceMappingURL=lanes.d.ts.map
|
package/dist/commands/lanes.js
CHANGED
|
@@ -42,7 +42,7 @@ import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFla
|
|
|
42
42
|
import { readLaneStatusRowFields } from "../lib/lanes/status-fields.js";
|
|
43
43
|
import { appendProjectFocusOverride, readProjectStoryFocusData, removeProjectFocusOverride } from "../lib/lanes/story-ledger.js";
|
|
44
44
|
import { appendLaneFocusOverride, assertFocusGuardAllows, makeFocusOverride } from "../lib/lanes/focus-guard.js";
|
|
45
|
-
import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, LANE_CONDUCT_LOOP_FLAG, } from "../lib/lanes/flag-keys.js";
|
|
45
|
+
import { LANE_FOCUS_GUARD_FLAG, LANE_FOCUS_DASHBOARD_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_LIST_SKIP_REMOTE_REFRESH_FLAG, LANE_MESSAGE_ROUTE_OWNER_HOST_FLAG, LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG, LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG, LANE_STATUS_ROW_FIELDS_FLAG, LANE_REAP_STOP_FLAG, LANE_REUSE_REFRESHES_PROFILE_FLAG, LANE_WORKER_CONTINUITY_FLAG, LANE_CONDUCT_LOOP_FLAG, LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, } from "../lib/lanes/flag-keys.js";
|
|
46
46
|
import { findWorkerContinuityLane, sameLaneHost, workerContinuityPrompt } from "../lib/lanes/worker-continuity.js";
|
|
47
47
|
import { enqueueLoopEnvelope, interruptLoopQueue, interruptLoopQueueForStory, loopControllerIsAlive, loopStatus, validateLoopEnvelope } from "../lib/lanes/loop.js";
|
|
48
48
|
import { runLaneLoopController, startLaneLoopController } from "./lanes-loop.js";
|
|
@@ -3392,7 +3392,7 @@ async function runCreate(ctx, opts) {
|
|
|
3392
3392
|
const provider = explicitProvider ??
|
|
3393
3393
|
profile?.meta.provider ??
|
|
3394
3394
|
parseLaneProvider("codex");
|
|
3395
|
-
const continuityEnabled = opts.worker !== undefined && await readLanesRegistryFlagFailOpen(LANE_WORKER_CONTINUITY_FLAG, { company: company ?? meshCompany, env: ctx.env });
|
|
3395
|
+
const continuityEnabled = opts.worker !== undefined && await readLanesRegistryFlagFailOpen(LANE_WORKER_CONTINUITY_FLAG, { company: company ?? meshCompany, env: ctx.env, defaultValue: true });
|
|
3396
3396
|
let workerContinuity = continuityEnabled
|
|
3397
3397
|
? findWorkerContinuityLane(loadAllLanes(hqRoot), {
|
|
3398
3398
|
senior,
|
|
@@ -5456,6 +5456,7 @@ async function laneStatusFieldsForCommand(ctx, hqRoot, lane, now) {
|
|
|
5456
5456
|
enabled = await readLanesRegistryFlagFailOpen(LANE_STATUS_ROW_FIELDS_FLAG, {
|
|
5457
5457
|
...(resolved ? { company: resolved } : {}),
|
|
5458
5458
|
env: ctx.env,
|
|
5459
|
+
defaultValue: true,
|
|
5459
5460
|
});
|
|
5460
5461
|
}
|
|
5461
5462
|
catch (err) {
|
|
@@ -9365,6 +9366,16 @@ function attachJsonNoop(cmd) {
|
|
|
9365
9366
|
for (const child of cmd.commands)
|
|
9366
9367
|
attachJsonNoop(child);
|
|
9367
9368
|
}
|
|
9369
|
+
export async function readLaneReapStopEnabled(hqRoot, lane, env) {
|
|
9370
|
+
const resolution = resolveLaneCompany(hqRoot, lane);
|
|
9371
|
+
const company = resolution.company ?? undefined;
|
|
9372
|
+
return readLanesRegistryFlagFailOpen(LANE_REAP_STOP_FLAG, {
|
|
9373
|
+
company,
|
|
9374
|
+
...(company ? { companyUidSource: laneCompanyUidSource(resolution.source) } : {}),
|
|
9375
|
+
env,
|
|
9376
|
+
defaultValue: true,
|
|
9377
|
+
});
|
|
9378
|
+
}
|
|
9368
9379
|
export function registerLanesCommand(parent, deps = {}) {
|
|
9369
9380
|
const ctx = resolveDeps(deps);
|
|
9370
9381
|
const storyHandlers = createLaneStoryHandlers({
|
|
@@ -9566,15 +9577,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9566
9577
|
presenceConnected: ctx.presenceConnected ?? false,
|
|
9567
9578
|
deps: ctx.livenessDeps,
|
|
9568
9579
|
}),
|
|
9569
|
-
stopEnabled: (lane) =>
|
|
9570
|
-
const resolution = resolveLaneCompany(ctx.hqRoot(), lane);
|
|
9571
|
-
const company = resolution.company ?? undefined;
|
|
9572
|
-
return readLanesRegistryFlag(LANE_REAP_STOP_FLAG, {
|
|
9573
|
-
company,
|
|
9574
|
-
...(company ? { companyUidSource: laneCompanyUidSource(resolution.source) } : {}),
|
|
9575
|
-
env: ctx.env,
|
|
9576
|
-
});
|
|
9577
|
-
},
|
|
9580
|
+
stopEnabled: (lane) => readLaneReapStopEnabled(ctx.hqRoot(), lane, ctx.env),
|
|
9578
9581
|
}, wrap);
|
|
9579
9582
|
registerLaneDropboxRepairCommand(lanes, {
|
|
9580
9583
|
hqRoot: ctx.hqRoot,
|
|
@@ -10021,8 +10024,24 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
10021
10024
|
catch (err) {
|
|
10022
10025
|
throw new LanesError("repair_request_invalid", `Envelope repair request is invalid JSON: ${err instanceof Error ? err.message : String(err)}`, {});
|
|
10023
10026
|
}
|
|
10027
|
+
const laneId = payload && typeof payload === "object" && !Array.isArray(payload) && typeof payload.lane_id === "string"
|
|
10028
|
+
? payload.lane_id
|
|
10029
|
+
: undefined;
|
|
10030
|
+
const company = laneId ? (() => { try {
|
|
10031
|
+
const lane = readLane(ctx.hqRoot(), laneId);
|
|
10032
|
+
return lane.company ?? lane.mesh_company;
|
|
10033
|
+
}
|
|
10034
|
+
catch {
|
|
10035
|
+
return undefined;
|
|
10036
|
+
} })() : undefined;
|
|
10037
|
+
const envelopeRepairGuidanceEnabled = await readLanesRegistryFlagFailOpen(LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, {
|
|
10038
|
+
company,
|
|
10039
|
+
defaultValue: true,
|
|
10040
|
+
env: ctx.env,
|
|
10041
|
+
});
|
|
10024
10042
|
printJson(spawnEnvelopeRepairRequest(ctx.hqRoot(), payload, {
|
|
10025
10043
|
env: ctx.env,
|
|
10044
|
+
envelopeRepairGuidanceEnabled,
|
|
10026
10045
|
hostname: ctx.hostname(),
|
|
10027
10046
|
existsSync: ctx.existsSync,
|
|
10028
10047
|
spawnProcess: ctx.spawnProcess,
|
|
@@ -44,4 +44,6 @@ export declare const LANE_REAP_STOP_FLAG = "lanes.reap-stop";
|
|
|
44
44
|
export declare const LANE_WORKER_CONTINUITY_FLAG = "lanes.worker-continuity";
|
|
45
45
|
/** Kill switch for conduct loop lanes; absence or read failure keeps it enabled. */
|
|
46
46
|
export declare const LANE_CONDUCT_LOOP_FLAG = "lanes.conduct-loop";
|
|
47
|
+
export declare const LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG = "lanes.envelope-repair-guidance";
|
|
48
|
+
export declare const LANE_LOOP_HANDOFF_REPAIR_FLAG = "lanes.loop-handoff-repair";
|
|
47
49
|
//# sourceMappingURL=flag-keys.d.ts.map
|
|
@@ -44,4 +44,6 @@ export const LANE_REAP_STOP_FLAG = "lanes.reap-stop";
|
|
|
44
44
|
export const LANE_WORKER_CONTINUITY_FLAG = "lanes.worker-continuity";
|
|
45
45
|
/** Kill switch for conduct loop lanes; absence or read failure keeps it enabled. */
|
|
46
46
|
export const LANE_CONDUCT_LOOP_FLAG = "lanes.conduct-loop";
|
|
47
|
+
export const LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG = "lanes.envelope-repair-guidance";
|
|
48
|
+
export const LANE_LOOP_HANDOFF_REPAIR_FLAG = "lanes.loop-handoff-repair";
|
|
47
49
|
//# sourceMappingURL=flag-keys.js.map
|
package/dist/lib/lanes/loop.d.ts
CHANGED
|
@@ -95,6 +95,7 @@ export interface LoopExecutionContext {
|
|
|
95
95
|
signal: AbortSignal;
|
|
96
96
|
sessionId?: string;
|
|
97
97
|
fresh: boolean;
|
|
98
|
+
repairError?: string;
|
|
98
99
|
}
|
|
99
100
|
export interface LoopExecutionResult {
|
|
100
101
|
output: string;
|
|
@@ -116,6 +117,7 @@ export interface LoopRunnerOptions {
|
|
|
116
117
|
now?: () => Date;
|
|
117
118
|
idlePollMs?: number;
|
|
118
119
|
signal?: AbortSignal;
|
|
120
|
+
repairInvalidHandoff?: boolean;
|
|
119
121
|
}
|
|
120
122
|
export interface LoopRunSummary {
|
|
121
123
|
state: "stopped" | "parked" | "aborted";
|
package/dist/lib/lanes/loop.js
CHANGED
|
@@ -750,7 +750,7 @@ export async function runLoopLane(options) {
|
|
|
750
750
|
if (envelope.kind === "stop") {
|
|
751
751
|
const stopped = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stopped", started_at: record.queued_at, finished_at: now().toISOString() };
|
|
752
752
|
try {
|
|
753
|
-
writeResultAt(options.runDir, file, record, stopped);
|
|
753
|
+
writeResultAt(options.runDir, file, record, stopped, options.repairInvalidHandoff !== false);
|
|
754
754
|
persistStatus(options.runDir, options.lane.lane_id, { state: "stopped", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
755
755
|
}
|
|
756
756
|
catch (error) {
|
|
@@ -811,7 +811,7 @@ export async function runLoopLane(options) {
|
|
|
811
811
|
if (timer.signal.aborted)
|
|
812
812
|
throw timer.signal.reason;
|
|
813
813
|
const finishedAt = now().toISOString();
|
|
814
|
-
|
|
814
|
+
let success = {
|
|
815
815
|
schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "done", started_at: startedAt, finished_at: finishedAt,
|
|
816
816
|
output: result.output, ...(result.sessionId ? { session_id: result.sessionId } : {}),
|
|
817
817
|
...(previousSession && !fresh && !fallback ? { resumed: true } : { resumed: false }),
|
|
@@ -820,17 +820,55 @@ export async function runLoopLane(options) {
|
|
|
820
820
|
if (envelope.schema === PHASE_ENVELOPE_SCHEMA) {
|
|
821
821
|
let handoff;
|
|
822
822
|
try {
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
823
|
+
try {
|
|
824
|
+
handoff = JSON.parse(result.output);
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
throw new Error("section 8 phase result is not valid JSON");
|
|
828
|
+
}
|
|
829
|
+
const validated = validateSection8Handoff(handoff, envelope);
|
|
830
|
+
if (validated.worker_id !== envelope.worker_id) {
|
|
831
|
+
throw new Error("handoff worker_id must match the queued phase envelope");
|
|
832
|
+
}
|
|
827
833
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
834
|
+
catch (validationError) {
|
|
835
|
+
if (options.repairInvalidHandoff !== false && !timer.signal.aborted) {
|
|
836
|
+
const repairError = validationError instanceof Error ? validationError.message : String(validationError);
|
|
837
|
+
const repaired = await options.execute({
|
|
838
|
+
lane: options.lane,
|
|
839
|
+
envelope,
|
|
840
|
+
envelopeId: record.envelope_id,
|
|
841
|
+
signal: timer.signal,
|
|
842
|
+
fresh: false,
|
|
843
|
+
...(result.sessionId ? { sessionId: result.sessionId } : {}),
|
|
844
|
+
repairError,
|
|
845
|
+
});
|
|
846
|
+
if (timer.signal.aborted)
|
|
847
|
+
throw timer.signal.reason;
|
|
848
|
+
let repairedHandoff;
|
|
849
|
+
try {
|
|
850
|
+
repairedHandoff = JSON.parse(repaired.output);
|
|
851
|
+
}
|
|
852
|
+
catch {
|
|
853
|
+
throw new Error(`handoff repair returned invalid JSON: ${repairError}`);
|
|
854
|
+
}
|
|
855
|
+
const repairedValidated = validateSection8Handoff(repairedHandoff, envelope);
|
|
856
|
+
if (repairedValidated.worker_id !== envelope.worker_id) {
|
|
857
|
+
throw new Error(`handoff worker_id must match the queued phase envelope: ${repairError}`);
|
|
858
|
+
}
|
|
859
|
+
result = repaired;
|
|
860
|
+
success = {
|
|
861
|
+
...success,
|
|
862
|
+
output: repaired.output,
|
|
863
|
+
...(repaired.sessionId ? { session_id: repaired.sessionId } : {}),
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
throw validationError;
|
|
868
|
+
}
|
|
831
869
|
}
|
|
832
870
|
}
|
|
833
|
-
writeResultAt(options.runDir, file, record, success);
|
|
871
|
+
writeResultAt(options.runDir, file, record, success, options.repairInvalidHandoff !== false);
|
|
834
872
|
if (result.sessionId) {
|
|
835
873
|
sessions.set(sessionKey, result.sessionId);
|
|
836
874
|
atomicJson(sessionsPath(options.runDir), Object.fromEntries(sessions));
|
|
@@ -853,7 +891,7 @@ export async function runLoopLane(options) {
|
|
|
853
891
|
}
|
|
854
892
|
if (!timer.signal.aborted) {
|
|
855
893
|
const failure = error instanceof Error ? error.message : String(error);
|
|
856
|
-
writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "failed", started_at: startedAt, finished_at: now().toISOString(), error: failure });
|
|
894
|
+
writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "failed", started_at: startedAt, finished_at: now().toISOString(), error: failure }, options.repairInvalidHandoff !== false);
|
|
857
895
|
completed += 1;
|
|
858
896
|
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
859
897
|
continue;
|
|
@@ -880,7 +918,7 @@ export async function runLoopLane(options) {
|
|
|
880
918
|
const message = error instanceof Error ? error.message : String(error);
|
|
881
919
|
const questionId = options.raiseQuestion ? randomUUID() : undefined;
|
|
882
920
|
const result = { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stalled", started_at: startedAt, finished_at: now().toISOString(), error: message, ...(questionId ? { question_id: questionId } : {}) };
|
|
883
|
-
writeResultAt(options.runDir, file, record, result);
|
|
921
|
+
writeResultAt(options.runDir, file, record, result, options.repairInvalidHandoff !== false);
|
|
884
922
|
persistStatus(options.runDir, options.lane.lane_id, { state: "parked", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
885
923
|
completed += 1;
|
|
886
924
|
try {
|
|
@@ -921,7 +959,7 @@ function appendJournal(runDir, value) {
|
|
|
921
959
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
922
960
|
fs.appendFileSync(file, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
923
961
|
}
|
|
924
|
-
function writeResultAt(runDir, activeFile, record, result) {
|
|
962
|
+
function writeResultAt(runDir, activeFile, record, result, includeHandoffValidationError = true) {
|
|
925
963
|
const localPath = path.join(resultsDir(runDir), resultName(record.envelope_id));
|
|
926
964
|
atomicJson(localPath, result);
|
|
927
965
|
const envelope = record.envelope;
|
|
@@ -939,7 +977,7 @@ function writeResultAt(runDir, activeFile, record, result) {
|
|
|
939
977
|
schema: PHASE_HANDOFF_SCHEMA,
|
|
940
978
|
story_id: String(envelope.story_id), phase: String(envelope.phase),
|
|
941
979
|
worker_id: String(envelope.worker_id), status: stalled ? "blocked" : "failed",
|
|
942
|
-
summary: stalled ? "Phase stalled twice and needs operator direction." : "Phase execution failed.",
|
|
980
|
+
summary: stalled ? "Phase stalled twice and needs operator direction." : includeHandoffValidationError ? `Phase execution failed: ${result.error ?? "unknown error"}` : "Phase execution failed.",
|
|
943
981
|
files_changed: [], commits: [],
|
|
944
982
|
back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
|
|
945
983
|
context_for_next: [result.error ?? result.status, ...(result.question_id ? [`Decision question: ${result.question_id}`] : [])].join("\n"),
|
|
@@ -24,7 +24,7 @@ import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecor
|
|
|
24
24
|
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, retryUpdateLane, readQuestion, listQuestionIds, updateLane, updateQuestion, } from "./store.js";
|
|
25
25
|
import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyObservation, recordCpuBusyObservation, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
26
26
|
import { readLanesRegistryFlag, readLanesRegistryFlagFailOff, readLanesRegistryFlagFailOpen } from "./registry-flag.js";
|
|
27
|
-
import { LANE_BLOCKED_ESCALATION_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_KEEP_ALIVE_DUE_WAKE_PRECISE_FLAG, LANE_LEAD_ADMISSION_PRIORITY_FLAG, LANE_QUOTA_ACCOUNT_FAILOVER_FLAG, } from "./flag-keys.js";
|
|
27
|
+
import { LANE_BLOCKED_ESCALATION_FLAG, LANE_HIRE_GRANT_FINISHED_LANES_FLAG, LANE_KEEP_ALIVE_DUE_WAKE_PRECISE_FLAG, LANE_LEAD_ADMISSION_PRIORITY_FLAG, LANE_QUOTA_ACCOUNT_FAILOVER_FLAG, LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, } from "./flag-keys.js";
|
|
28
28
|
import { checkLaneDiskSpace, configuredCodexRolloutHomes, laneDiskAccount, retainCodexRollouts } from "./disk-guard.js";
|
|
29
29
|
import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
|
|
30
30
|
import { appendDropboxMessageOnce, appendDropboxMessageOnceAtPath } from "./dropbox.js";
|
|
@@ -1700,7 +1700,13 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1700
1700
|
}
|
|
1701
1701
|
const spawnRepair = deps.spawnRepair ??
|
|
1702
1702
|
((root, repairLane, input) => spawnEnvelopeRepair(root, repairLane, input, spawnDepsForHost(deps)));
|
|
1703
|
-
const
|
|
1703
|
+
const repairGuidanceFlagReader = deps.readRegistryFlag ?? readLanesRegistryFlagFailOpen;
|
|
1704
|
+
const repairGuidanceEnabled = await repairGuidanceFlagReader(LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, {
|
|
1705
|
+
company: resolveLaneCompany(hqRoot, classified).company,
|
|
1706
|
+
defaultValue: true,
|
|
1707
|
+
env: deps.spawnLaneDeps?.env ?? deps.env ?? process.env,
|
|
1708
|
+
});
|
|
1709
|
+
const repair = decideEnvelopeRepair(hqRoot, lane.lane_id, repairCandidate, nowMs, spawnRepair, () => undefined, repairGuidanceEnabled);
|
|
1704
1710
|
if (repair.action === "spawned") {
|
|
1705
1711
|
armLaneWatcher(hqRoot, lane.lane_id, spawnDepsForHost(deps));
|
|
1706
1712
|
summary.heartbeats.push(`${lane.lane_id}:envelope-repair`);
|
|
@@ -94,6 +94,7 @@ export interface SpawnLaneDeps {
|
|
|
94
94
|
locateTranscript?: (home: string, sessionId: string) => string | null;
|
|
95
95
|
/** Internal loop-lane override for the single provider turn's working directory. */
|
|
96
96
|
workingDirectory?: string;
|
|
97
|
+
envelopeRepairGuidanceEnabled?: boolean;
|
|
97
98
|
}
|
|
98
99
|
export interface ProviderLaunch {
|
|
99
100
|
command: string;
|
|
@@ -210,12 +211,12 @@ export declare function resumePrompt(input: {
|
|
|
210
211
|
second_error?: string;
|
|
211
212
|
};
|
|
212
213
|
}): string;
|
|
213
|
-
/** Spec §4: the one repair turn. Schema, parser error, raw text, JSON only. */
|
|
214
214
|
export declare function envelopeRepairPrompt(input: {
|
|
215
215
|
envelopePath: string;
|
|
216
216
|
error: string;
|
|
217
217
|
rawText: string;
|
|
218
218
|
missing?: boolean;
|
|
219
|
+
detailedGuidance?: boolean;
|
|
219
220
|
}): string;
|
|
220
221
|
export interface ProviderResumeInput {
|
|
221
222
|
session_id: string;
|
|
@@ -319,6 +320,7 @@ export interface EnvelopeRepairSpawnInput {
|
|
|
319
320
|
rawText: string;
|
|
320
321
|
/** True when this run never wrote its expected envelope file. */
|
|
321
322
|
missing?: boolean;
|
|
323
|
+
detailedGuidance?: boolean;
|
|
322
324
|
/** Repair-only warning hook for profile metadata ignored during recovery. */
|
|
323
325
|
onProfileWarning?: (fields: readonly string[]) => void;
|
|
324
326
|
}
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -1375,6 +1375,42 @@ export function resumePrompt(input) {
|
|
|
1375
1375
|
return parts.join("\n\n");
|
|
1376
1376
|
}
|
|
1377
1377
|
/** Spec §4: the one repair turn. Schema, parser error, raw text, JSON only. */
|
|
1378
|
+
function recoverRawJsonArrayField(rawText, field) {
|
|
1379
|
+
const match = new RegExp(`"${field}"\\s*:\\s*\\[`).exec(rawText);
|
|
1380
|
+
if (!match)
|
|
1381
|
+
return undefined;
|
|
1382
|
+
const start = match.index + match[0].lastIndexOf("[");
|
|
1383
|
+
let depth = 0;
|
|
1384
|
+
let inString = false;
|
|
1385
|
+
let escaped = false;
|
|
1386
|
+
for (let index = start; index < rawText.length; index += 1) {
|
|
1387
|
+
const char = rawText[index];
|
|
1388
|
+
if (inString) {
|
|
1389
|
+
if (escaped)
|
|
1390
|
+
escaped = false;
|
|
1391
|
+
else if (char === "\\")
|
|
1392
|
+
escaped = true;
|
|
1393
|
+
else if (char === '"')
|
|
1394
|
+
inString = false;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (char === '"')
|
|
1398
|
+
inString = true;
|
|
1399
|
+
else if (char === "[")
|
|
1400
|
+
depth += 1;
|
|
1401
|
+
else if (char === "]" && --depth === 0) {
|
|
1402
|
+
try {
|
|
1403
|
+
const parsed = JSON.parse(rawText.slice(start, index + 1));
|
|
1404
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
process.stderr.write("hq lanes: envelope repair could not recover the supplied JSON array\n");
|
|
1408
|
+
return undefined;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return undefined;
|
|
1413
|
+
}
|
|
1378
1414
|
export function envelopeRepairPrompt(input) {
|
|
1379
1415
|
const maxRawTextChars = 24_000;
|
|
1380
1416
|
const rawText = input.rawText.length <= maxRawTextChars
|
|
@@ -1393,11 +1429,94 @@ export function envelopeRepairPrompt(input) {
|
|
|
1393
1429
|
`Output only that JSON. Do not add keys the schema does not name, and do not resume the task.`,
|
|
1394
1430
|
].join("\n\n");
|
|
1395
1431
|
}
|
|
1432
|
+
if (input.detailedGuidance === false) {
|
|
1433
|
+
return [
|
|
1434
|
+
`The file you wrote at ${input.envelopePath} is not a valid hq-lane-envelope.v1. This is the single repair turn: rewrite that file so it validates, and do nothing else.`,
|
|
1435
|
+
`Parser error: ${input.error}`,
|
|
1436
|
+
"For an ask question, include non-empty id, question, and context fields.",
|
|
1437
|
+
"Schema:", JSON.stringify(LANE_ENVELOPE_JSON_SCHEMA, null, 2),
|
|
1438
|
+
"Your previous output was:", rawText,
|
|
1439
|
+
`Write the corrected JSON object to ${input.envelopePath}, overwriting it. Output only that JSON. Do not add keys the schema does not name, and do not resume the task.`,
|
|
1440
|
+
].join("\n\n");
|
|
1441
|
+
}
|
|
1442
|
+
const location = input.error.match(/position (\d+) \(line (\d+) column (\d+)\)/i);
|
|
1443
|
+
const lineColumn = input.error.match(/line (\d+) column (\d+)/i);
|
|
1444
|
+
const offendingToken = location
|
|
1445
|
+
? input.rawText[Number(location[1])] ?? "<end of file>"
|
|
1446
|
+
: undefined;
|
|
1447
|
+
const preciseError = location || lineColumn
|
|
1448
|
+
? `${input.error}\nFault location: line ${location?.[2] ?? lineColumn?.[1]}, column ${location?.[3] ?? lineColumn?.[2]}.${offendingToken === undefined ? "" : ` Offending token: ${JSON.stringify(offendingToken)}.`}`
|
|
1449
|
+
: input.error;
|
|
1450
|
+
const syntaxFault = /unexpected (?:token|end)|property name.*in JSON at position|JSON at position \d+|position \d+/i.test(input.error);
|
|
1451
|
+
const originalDecision = input.rawText.match(/"decision"\s*:\s*"(done|ask|blocked)"/)?.[1] ?? "done";
|
|
1452
|
+
const originalCompleteMatch = input.rawText.match(/"story_complete"\s*:\s*(true|false)/)?.[1];
|
|
1453
|
+
const originalComplete = originalCompleteMatch === undefined ? undefined : originalCompleteMatch === "true";
|
|
1454
|
+
const originalStatus = input.rawText.match(/"story_status"\s*:\s*"(queued|in_progress|review|done)"/)?.[1];
|
|
1455
|
+
let originalObject = {};
|
|
1456
|
+
try {
|
|
1457
|
+
const parsed = JSON.parse(input.rawText);
|
|
1458
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
|
|
1459
|
+
originalObject = parsed;
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
process.stderr.write("hq lanes: envelope repair is recovering values from invalid JSON\n");
|
|
1463
|
+
}
|
|
1464
|
+
const rawStringField = (field) => {
|
|
1465
|
+
const token = input.rawText.match(new RegExp(`"${field}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`))?.[1];
|
|
1466
|
+
if (!token)
|
|
1467
|
+
return undefined;
|
|
1468
|
+
try {
|
|
1469
|
+
return JSON.parse(token);
|
|
1470
|
+
}
|
|
1471
|
+
catch {
|
|
1472
|
+
process.stderr.write(`hq lanes: envelope repair could not recover the supplied ${field} string\n`);
|
|
1473
|
+
return undefined;
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1476
|
+
const minimalEnvelope = {
|
|
1477
|
+
schema_version: "hq-lane-envelope.v1",
|
|
1478
|
+
decision: originalDecision,
|
|
1479
|
+
summary: rawStringField("summary") || "Describe the completed work or remaining issue.",
|
|
1480
|
+
story_status: originalComplete === true ? "done" : originalComplete === false && originalStatus === "done" ? "review" : originalStatus ?? "review",
|
|
1481
|
+
artifacts: [],
|
|
1482
|
+
};
|
|
1483
|
+
const originalCompleteReason = rawStringField("story_complete_reason");
|
|
1484
|
+
if (originalComplete !== undefined)
|
|
1485
|
+
minimalEnvelope.story_complete = originalComplete;
|
|
1486
|
+
if (originalComplete !== undefined || originalCompleteReason !== undefined) {
|
|
1487
|
+
minimalEnvelope.story_complete_reason = originalCompleteReason || (originalComplete === true ? "State what was delivered and how it was verified." : "State what remains missing.");
|
|
1488
|
+
}
|
|
1489
|
+
const originalArtifacts = Array.isArray(originalObject.artifacts)
|
|
1490
|
+
? originalObject.artifacts
|
|
1491
|
+
: recoverRawJsonArrayField(input.rawText, "artifacts");
|
|
1492
|
+
if (originalArtifacts)
|
|
1493
|
+
minimalEnvelope.artifacts = originalArtifacts;
|
|
1494
|
+
if (originalDecision === "ask") {
|
|
1495
|
+
const originalQuestion = Array.isArray(originalObject.questions) && originalObject.questions[0] && typeof originalObject.questions[0] === "object"
|
|
1496
|
+
? originalObject.questions[0]
|
|
1497
|
+
: {};
|
|
1498
|
+
const options = Array.isArray(originalQuestion.options) && originalQuestion.options.every((item) => typeof item === "string")
|
|
1499
|
+
? originalQuestion.options
|
|
1500
|
+
: ["Option A", "Option B"];
|
|
1501
|
+
minimalEnvelope.questions = [{
|
|
1502
|
+
id: typeof originalQuestion.id === "string" && originalQuestion.id.trim() ? originalQuestion.id : "repair-question",
|
|
1503
|
+
question: typeof originalQuestion.question === "string" && originalQuestion.question.trim() ? originalQuestion.question : rawStringField("question") || "What decision is needed?",
|
|
1504
|
+
context: typeof originalQuestion.context === "string" && originalQuestion.context.trim() ? originalQuestion.context : rawStringField("context") || "State the unresolved choice.",
|
|
1505
|
+
options,
|
|
1506
|
+
}];
|
|
1507
|
+
}
|
|
1508
|
+
if (originalDecision === "blocked")
|
|
1509
|
+
minimalEnvelope.blocked_reason = rawStringField("blocked_reason") || "State the external blocker.";
|
|
1396
1510
|
return [
|
|
1397
1511
|
`The file you wrote at ${input.envelopePath} is not a valid hq-lane-envelope.v1. ` +
|
|
1398
1512
|
`This is the single repair turn: rewrite that file so it validates, and do nothing else.`,
|
|
1399
|
-
`
|
|
1400
|
-
|
|
1513
|
+
`Exact validation error: ${preciseError}`,
|
|
1514
|
+
syntaxFault
|
|
1515
|
+
? "This is a JSON syntax error. Fix the exact reported line and column and remove the offending token or delimiter. Do not add trailing commas."
|
|
1516
|
+
: "This is a schema validation error. Correct the named field path and use only the allowed value(s) stated in the error.",
|
|
1517
|
+
"Keep every supplied value that is already valid. Do not invent next_check_at; it is optional. For decision=ask include non-empty id, question, and context fields; for decision=blocked include blocked_reason.",
|
|
1518
|
+
"Minimal valid envelope shape (retain your original decision and values when valid):",
|
|
1519
|
+
JSON.stringify(minimalEnvelope, null, 2),
|
|
1401
1520
|
"Schema:",
|
|
1402
1521
|
JSON.stringify(LANE_ENVELOPE_JSON_SCHEMA, null, 2),
|
|
1403
1522
|
"Your previous output was:",
|
|
@@ -2894,6 +3013,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
2894
3013
|
error: input.error,
|
|
2895
3014
|
rawText: input.rawText,
|
|
2896
3015
|
missing: input.missing,
|
|
3016
|
+
detailedGuidance: input.detailedGuidance ?? deps.envelopeRepairGuidanceEnabled,
|
|
2897
3017
|
});
|
|
2898
3018
|
const command = buildProviderCommand(lane.provider, {
|
|
2899
3019
|
hqRoot,
|
|
@@ -800,6 +800,10 @@ export interface LaneEnvelopeRepair {
|
|
|
800
800
|
skipped_reason?: string;
|
|
801
801
|
/** Envelope slot the repair was asked to rewrite. */
|
|
802
802
|
envelope_ref?: string;
|
|
803
|
+
/** Absolute raw envelope retained when the repair output also fails JSON parsing. */
|
|
804
|
+
raw_envelope_path?: string;
|
|
805
|
+
/** Recoverable artifacts from malformed raw JSON, surfaced by lanes show --json. */
|
|
806
|
+
artifacts?: unknown[];
|
|
803
807
|
at: string;
|
|
804
808
|
}
|
|
805
809
|
export interface AccountHistoryEntry {
|
|
@@ -247,18 +247,7 @@ export type EnvelopeRepairDecision = {
|
|
|
247
247
|
action: "give_up";
|
|
248
248
|
errors: string[];
|
|
249
249
|
};
|
|
250
|
-
|
|
251
|
-
* Spec §4 repair decision for an exited worker whose expected envelope is
|
|
252
|
-
* invalid or missing. First failure on a resumable provider with a recorded
|
|
253
|
-
* session id: record `envelope_repair` on the lane and spawn the repair turn.
|
|
254
|
-
* A provider with no session resume gets the same single attempt as a fresh
|
|
255
|
-
* turn for invalid files. Anything else (already attempted, no session id on a
|
|
256
|
-
* resumable provider, non-running lane, spawn failure): give up with every
|
|
257
|
-
* error collected. The caller preserves the normal interrupted classification
|
|
258
|
-
* for a missing file and uses `envelope_invalid` only for an existing invalid
|
|
259
|
-
* file.
|
|
260
|
-
*/
|
|
261
|
-
export declare function decideEnvelopeRepair(hqRoot: string, laneId: string, latest: LatestEnvelopeFile, nowMs: number, spawnRepair: NonNullable<WatcherLoopDeps["spawnRepair"]>, logDecision: (record: Record<string, unknown>) => void): EnvelopeRepairDecision;
|
|
250
|
+
export declare function decideEnvelopeRepair(hqRoot: string, laneId: string, latest: LatestEnvelopeFile, nowMs: number, spawnRepair: NonNullable<WatcherLoopDeps["spawnRepair"]>, logDecision: (record: Record<string, unknown>) => void, repairGuidanceEnabled?: boolean): EnvelopeRepairDecision;
|
|
262
251
|
/** Exported so tests can prove the watcher reads the live record, not spawn env. */
|
|
263
252
|
export declare function laneIdentityFromRecord(hqRoot: string, laneId: string): {
|
|
264
253
|
lane_id: string;
|
|
@@ -49,13 +49,13 @@ import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefau
|
|
|
49
49
|
import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
|
|
50
50
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
|
|
51
51
|
import { envelopeQuestionId } from "./questions.js";
|
|
52
|
-
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
52
|
+
import { readLanesRegistryFlag, readLanesRegistryFlagFailOpen } from "./registry-flag.js";
|
|
53
53
|
import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
|
|
54
54
|
import { blockerFreshnessFingerprint, resolveBlockerFreshnessForLane, } from "./blocker-freshness.js";
|
|
55
55
|
import { krFreshnessFingerprint, resolveKrFreshnessForLane, } from "./kr-freshness.js";
|
|
56
56
|
import { managerStatusWasWritten, readStatusSnapshot, resolveManagerStatusTarget, } from "./manager-status-gate.js";
|
|
57
57
|
import { LEAD_LANE_KEEP_ALIVE_FIRST_RESUME_SETTLE_MS, LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_IMMEDIATE_FIRST_RESUME_FLAG, LEAD_LANE_KEEP_ALIVE_TERMINAL_TRIGGER_FLAG, queueLaneTerminalResume, queueManagerStatusGateResume, } from "./keep-alive.js";
|
|
58
|
-
import { LANE_ENVELOPE_REPAIR_CONTINUE_FLAG } from "./flag-keys.js";
|
|
58
|
+
import { LANE_ENVELOPE_REPAIR_CONTINUE_FLAG, LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG } from "./flag-keys.js";
|
|
59
59
|
import { monitorSessionDir } from "../monitor.js";
|
|
60
60
|
import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, writePidRecord, retryLaneOnAlternateAccount, } from "./spawn.js";
|
|
61
61
|
/** Spec §5.7: no signal for 20 minutes while the process is still alive. */
|
|
@@ -1574,7 +1574,13 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
|
|
|
1574
1574
|
const missingRepairAllowed = !repairCandidate.missing ||
|
|
1575
1575
|
(lane !== null && envelopeRepairMode(lane.provider) === "resume");
|
|
1576
1576
|
if (missingRepairAllowed) {
|
|
1577
|
-
const
|
|
1577
|
+
const repairLane = readLaneSoft(hqRoot, laneId);
|
|
1578
|
+
const repairGuidanceEnabled = await readLanesRegistryFlagFailOpen(LANE_ENVELOPE_REPAIR_GUIDANCE_FLAG, {
|
|
1579
|
+
company: repairLane?.company ?? repairLane?.mesh_company,
|
|
1580
|
+
defaultValue: true,
|
|
1581
|
+
env: process.env,
|
|
1582
|
+
});
|
|
1583
|
+
const repair = decideEnvelopeRepair(hqRoot, laneId, repairCandidate, now(), spawnRepair, logDecision, repairGuidanceEnabled);
|
|
1578
1584
|
if (repair.action === "spawned") {
|
|
1579
1585
|
lastSignalMs = now();
|
|
1580
1586
|
lastAlive = null;
|
|
@@ -1941,7 +1947,43 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
|
|
|
1941
1947
|
* for a missing file and uses `envelope_invalid` only for an existing invalid
|
|
1942
1948
|
* file.
|
|
1943
1949
|
*/
|
|
1944
|
-
|
|
1950
|
+
function recoverRawEnvelopeArtifacts(rawText) {
|
|
1951
|
+
const match = /"artifacts"\s*:\s*\[/.exec(rawText);
|
|
1952
|
+
if (!match)
|
|
1953
|
+
return undefined;
|
|
1954
|
+
const start = match.index + match[0].lastIndexOf("[");
|
|
1955
|
+
let depth = 0;
|
|
1956
|
+
let inString = false;
|
|
1957
|
+
let escaped = false;
|
|
1958
|
+
for (let index = start; index < rawText.length; index += 1) {
|
|
1959
|
+
const char = rawText[index];
|
|
1960
|
+
if (inString) {
|
|
1961
|
+
if (escaped)
|
|
1962
|
+
escaped = false;
|
|
1963
|
+
else if (char === "\\")
|
|
1964
|
+
escaped = true;
|
|
1965
|
+
else if (char === '"')
|
|
1966
|
+
inString = false;
|
|
1967
|
+
continue;
|
|
1968
|
+
}
|
|
1969
|
+
if (char === '"')
|
|
1970
|
+
inString = true;
|
|
1971
|
+
else if (char === "[")
|
|
1972
|
+
depth += 1;
|
|
1973
|
+
else if (char === "]" && --depth === 0) {
|
|
1974
|
+
try {
|
|
1975
|
+
const parsed = JSON.parse(rawText.slice(start, index + 1));
|
|
1976
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
1977
|
+
}
|
|
1978
|
+
catch {
|
|
1979
|
+
process.stderr.write("hq lanes watcher: could not recover artifacts from the invalid envelope\n");
|
|
1980
|
+
return undefined;
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
return undefined;
|
|
1985
|
+
}
|
|
1986
|
+
export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair, logDecision, repairGuidanceEnabled = true) {
|
|
1945
1987
|
const at = new Date(nowMs).toISOString();
|
|
1946
1988
|
const error = latest.error ?? "unparseable envelope";
|
|
1947
1989
|
const lane = readLaneSoft(hqRoot, laneId);
|
|
@@ -1964,10 +2006,14 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
|
|
|
1964
2006
|
// The repair turn ran and its output does not parse either.
|
|
1965
2007
|
if (prior.attempted) {
|
|
1966
2008
|
const errors = [prior.first_error, error];
|
|
2009
|
+
let artifacts;
|
|
2010
|
+
if (repairGuidanceEnabled) {
|
|
2011
|
+
artifacts = recoverRawEnvelopeArtifacts(latest.text);
|
|
2012
|
+
}
|
|
1967
2013
|
try {
|
|
1968
2014
|
retryUpdateLane(hqRoot, laneId, "watcher", (current) => ({
|
|
1969
2015
|
...current,
|
|
1970
|
-
envelope_repair: { ...prior, succeeded: false, second_error: error },
|
|
2016
|
+
envelope_repair: { ...prior, succeeded: false, second_error: error, ...(repairGuidanceEnabled ? { raw_envelope_path: latest.path } : {}), ...(artifacts ? { artifacts } : {}) },
|
|
1971
2017
|
}));
|
|
1972
2018
|
}
|
|
1973
2019
|
catch (err) {
|
|
@@ -1993,6 +2039,7 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
|
|
|
1993
2039
|
const base = {
|
|
1994
2040
|
first_error: error,
|
|
1995
2041
|
envelope_ref: repairRef,
|
|
2042
|
+
...(repairGuidanceEnabled ? { raw_envelope_path: latest.path } : {}),
|
|
1996
2043
|
at,
|
|
1997
2044
|
};
|
|
1998
2045
|
if (skipped !== undefined) {
|
|
@@ -2008,6 +2055,7 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
|
|
|
2008
2055
|
error,
|
|
2009
2056
|
rawText: latest.text,
|
|
2010
2057
|
missing: latest.missing,
|
|
2058
|
+
detailedGuidance: repairGuidanceEnabled,
|
|
2011
2059
|
onProfileWarning: (fields) => {
|
|
2012
2060
|
logDecision({
|
|
2013
2061
|
event: "envelope_repair",
|