@indigoai-us/hq-cli 5.345.161 → 5.345.163
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 +17 -0
- package/dist/command-catalog.generated.d.ts +385 -1
- package/dist/command-catalog.generated.js +475 -1
- package/dist/command-registration-plan.d.ts +12 -0
- package/dist/command-registration-plan.js +2 -0
- package/dist/commands/agent-inbox.js +6 -2
- package/dist/commands/deploy-domains.d.ts +51 -0
- package/dist/commands/deploy-domains.js +787 -0
- package/dist/commands/feedback-triage.d.ts +67 -0
- package/dist/commands/feedback-triage.js +294 -0
- package/dist/commands/feedback.d.ts +13 -1
- package/dist/commands/feedback.js +32 -8
- package/dist/commands/lanes-loop.js +7 -7
- package/dist/commands/lanes-questions.js +54 -4
- package/dist/commands/lanes.js +2 -1
- package/dist/commands/sync-held.d.ts +87 -0
- package/dist/commands/sync-held.js +191 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3095 -2769
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/inbox-state.d.ts +5 -4
- package/dist/lib/agent-kit/inbox-state.js +24 -3
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/loop.d.ts +17 -0
- package/dist/lib/lanes/loop.js +235 -5
- package/dist/lib/lanes/questions.d.ts +1 -0
- package/package.json +8 -8
package/dist/lib/lanes/loop.d.ts
CHANGED
|
@@ -48,6 +48,10 @@ export interface LoopStatus {
|
|
|
48
48
|
stall_counts: Record<string, number>;
|
|
49
49
|
pid?: number;
|
|
50
50
|
updated_at: string;
|
|
51
|
+
handoff_hold?: {
|
|
52
|
+
envelope_id: string;
|
|
53
|
+
question_id: string;
|
|
54
|
+
};
|
|
51
55
|
}
|
|
52
56
|
export interface LoopResult {
|
|
53
57
|
schema_version: typeof LOOP_SCHEMA;
|
|
@@ -61,6 +65,7 @@ export interface LoopResult {
|
|
|
61
65
|
resumed?: boolean;
|
|
62
66
|
fresh_fallback?: boolean;
|
|
63
67
|
question_id?: string;
|
|
68
|
+
hold_reason?: "handoff_needs_repair";
|
|
64
69
|
decision?: {
|
|
65
70
|
question_id: string;
|
|
66
71
|
choice: string;
|
|
@@ -112,6 +117,7 @@ export interface LoopRunnerOptions {
|
|
|
112
117
|
attempt: number;
|
|
113
118
|
error: string;
|
|
114
119
|
questionId: string;
|
|
120
|
+
reason?: "handoff_needs_repair";
|
|
115
121
|
}): Promise<void> | void;
|
|
116
122
|
defaultDeadlineSeconds?: number;
|
|
117
123
|
now?: () => Date;
|
|
@@ -131,8 +137,19 @@ interface StoredEnvelope {
|
|
|
131
137
|
queued_at: string;
|
|
132
138
|
deadline_budget_seconds?: number;
|
|
133
139
|
stall_attempts?: number;
|
|
140
|
+
accepted_handoff?: {
|
|
141
|
+
output: string;
|
|
142
|
+
sha256: string;
|
|
143
|
+
};
|
|
144
|
+
handoff_retry?: boolean;
|
|
134
145
|
envelope: LoopEnvelope;
|
|
135
146
|
}
|
|
147
|
+
export declare const HANDOFF_HOLD_CHOICES: readonly ["Use the repaired handoff without running the phase again", "Retry the phase; I understand external effects may repeat", "Keep this phase held"];
|
|
148
|
+
export declare function loopHandoffHoldIdentity(runDir: string): {
|
|
149
|
+
envelope_id: string;
|
|
150
|
+
question_id: string;
|
|
151
|
+
unresolved: boolean;
|
|
152
|
+
} | undefined;
|
|
136
153
|
/** Validate both hq-cli phase envelopes and the conduct protocol §8 shape. */
|
|
137
154
|
export declare function validateLoopEnvelope(value: unknown): LoopEnvelope;
|
|
138
155
|
export declare function validateSection8Handoff(value: unknown, envelope: Section8PhaseEnvelope): Section8Handoff;
|
package/dist/lib/lanes/loop.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Durable envelope queue and runtime primitives for conduct worker lanes. */
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { providerSupportsSessionResume } from "./delivery.js";
|
|
@@ -14,6 +14,174 @@ export function loopAdmissionPath(state, hasPendingResume, initialAdmission) {
|
|
|
14
14
|
return "deferred";
|
|
15
15
|
return "resume";
|
|
16
16
|
}
|
|
17
|
+
export const HANDOFF_HOLD_CHOICES = [
|
|
18
|
+
"Use the repaired handoff without running the phase again",
|
|
19
|
+
"Retry the phase; I understand external effects may repeat",
|
|
20
|
+
"Keep this phase held",
|
|
21
|
+
];
|
|
22
|
+
function handoffHoldPath(runDir) { return path.join(runDir, "loop", "handoff-hold.json"); }
|
|
23
|
+
function readHandoffHold(runDir) {
|
|
24
|
+
const file = handoffHoldPath(runDir);
|
|
25
|
+
if (!fs.existsSync(file))
|
|
26
|
+
return undefined;
|
|
27
|
+
const hold = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readHandoffBytes(file, LOOP_ENVELOPE_LIMIT * 8)));
|
|
28
|
+
if (!isRecord(hold) || hold.schema !== "hq-loop-handoff-hold.v1" || !nonEmpty(hold.envelope_id) || !nonEmpty(hold.question_id)) {
|
|
29
|
+
throw new Error("Invalid durable handoff hold; operator investigation required");
|
|
30
|
+
}
|
|
31
|
+
return hold;
|
|
32
|
+
}
|
|
33
|
+
async function notifyHandoffHold(options, hold, envelope) {
|
|
34
|
+
if (hold.notified || hold.resolution || !options.raiseQuestion)
|
|
35
|
+
return;
|
|
36
|
+
const record = envelope ? undefined : ["stalled", "active"].flatMap((dir) => envelopeFiles(path.join(options.runDir, "loop", dir)))
|
|
37
|
+
.map(fileEnvelope).find((entry) => entry.envelope_id === hold.envelope_id);
|
|
38
|
+
const heldEnvelope = envelope ?? record?.envelope;
|
|
39
|
+
if (!heldEnvelope)
|
|
40
|
+
throw new Error("Held handoff envelope is missing; cannot deliver its repair question");
|
|
41
|
+
await options.raiseQuestion({ envelope: heldEnvelope, envelopeId: hold.envelope_id, attempt: 1, error: hold.error, questionId: hold.question_id, reason: "handoff_needs_repair" });
|
|
42
|
+
const current = readHandoffHold(options.runDir);
|
|
43
|
+
if (current?.question_id === hold.question_id)
|
|
44
|
+
atomicJson(handoffHoldPath(options.runDir), { ...current, notified: true });
|
|
45
|
+
}
|
|
46
|
+
export function loopHandoffHoldIdentity(runDir) {
|
|
47
|
+
const hold = readHandoffHold(runDir);
|
|
48
|
+
return hold ? { envelope_id: hold.envelope_id, question_id: hold.question_id, unresolved: !hold.resolution } : undefined;
|
|
49
|
+
}
|
|
50
|
+
function readHandoffBytes(file, limit = LOOP_ENVELOPE_LIMIT) {
|
|
51
|
+
const before = fs.lstatSync(file);
|
|
52
|
+
if (!before.isFile())
|
|
53
|
+
throw new Error("Repaired handoff must be a bounded regular file");
|
|
54
|
+
if (!Number.isSafeInteger(before.ino) || before.ino <= 0 || !Number.isSafeInteger(before.dev))
|
|
55
|
+
throw new Error("Cannot establish handoff file identity on this filesystem");
|
|
56
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.fstatSync(fd);
|
|
59
|
+
const current = fs.lstatSync(file);
|
|
60
|
+
if (!current.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || current.dev !== stat.dev || current.ino !== stat.ino)
|
|
61
|
+
throw new Error("Handoff file identity changed while opening it");
|
|
62
|
+
if (!stat.isFile() || stat.size > limit)
|
|
63
|
+
throw new Error("Repaired handoff must be a bounded regular file");
|
|
64
|
+
const bytes = Buffer.alloc(limit + 1);
|
|
65
|
+
let count = 0;
|
|
66
|
+
while (count < bytes.length) {
|
|
67
|
+
const read = fs.readSync(fd, bytes, count, bytes.length - count, null);
|
|
68
|
+
if (read === 0)
|
|
69
|
+
break;
|
|
70
|
+
count += read;
|
|
71
|
+
}
|
|
72
|
+
if (count > limit)
|
|
73
|
+
throw new Error("Repaired handoff exceeds the 1 MiB limit");
|
|
74
|
+
return bytes.subarray(0, count);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function validateAcceptedHandoff(output, envelope) {
|
|
81
|
+
if (Buffer.byteLength(output, "utf8") > LOOP_ENVELOPE_LIMIT)
|
|
82
|
+
throw new Error("Repaired handoff exceeds the 1 MiB limit");
|
|
83
|
+
if (envelope.schema !== PHASE_ENVELOPE_SCHEMA)
|
|
84
|
+
throw new Error("Repair hold requires a section 8 phase");
|
|
85
|
+
const phase = envelope;
|
|
86
|
+
const handoff = validateSection8Handoff(JSON.parse(output), phase);
|
|
87
|
+
if (handoff.worker_id !== phase.worker_id || handoff.status !== "passed") {
|
|
88
|
+
throw new Error("Repaired handoff must match the worker and have passed status");
|
|
89
|
+
}
|
|
90
|
+
const criteria = phase.acceptance_criteria;
|
|
91
|
+
if (!Array.isArray(criteria) || !criteria.every((item) => typeof item === "string"))
|
|
92
|
+
throw new Error("Held phase requires string acceptance criteria");
|
|
93
|
+
if (!Array.isArray(handoff.ac_evidence))
|
|
94
|
+
throw new Error("Repaired handoff requires acceptance-criterion evidence");
|
|
95
|
+
for (let index = 0; index < criteria.length; index += 1) {
|
|
96
|
+
const evidence = handoff.ac_evidence.filter((item) => isRecord(item) && item.index === index);
|
|
97
|
+
if (evidence.length !== 1)
|
|
98
|
+
throw new Error(`Repaired handoff requires exactly one evidence entry for criterion ${index}`);
|
|
99
|
+
const item = evidence[0];
|
|
100
|
+
if (item.criterion !== criteria[index] || item.met !== true || !nonEmpty(item.evidence)) {
|
|
101
|
+
throw new Error(`Repaired handoff must copy criterion ${index} verbatim with met=true and string evidence`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const item of handoff.ac_evidence) {
|
|
105
|
+
if (!isRecord(item) || !Number.isInteger(item.index) || Number(item.index) < 0 || Number(item.index) >= criteria.length) {
|
|
106
|
+
throw new Error("Repaired handoff contains an unknown criterion index");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return handoff;
|
|
110
|
+
}
|
|
111
|
+
function applyHandoffHoldAnswer(input) {
|
|
112
|
+
const release = acquireEnqueueLock(input.runDir);
|
|
113
|
+
try {
|
|
114
|
+
const hold = readHandoffHold(input.runDir);
|
|
115
|
+
if (!hold || hold.question_id !== input.questionId)
|
|
116
|
+
throw new Error("Handoff hold identity changed");
|
|
117
|
+
if (!HANDOFF_HOLD_CHOICES.includes(input.choice))
|
|
118
|
+
throw new Error("Choose an explicit handoff repair disposition");
|
|
119
|
+
if (loopStatus(input.runDir, path.basename(input.runDir)).state === "stopped")
|
|
120
|
+
throw new Error("An intentionally stopped loop cannot resume from a handoff answer");
|
|
121
|
+
if (fs.existsSync(path.join(input.runDir, "loop", "runner.lock")))
|
|
122
|
+
throw new Error("Handoff controller has not released its lease yet; retry the same answer after it exits");
|
|
123
|
+
const resultPath = path.join(resultsDir(input.runDir), resultName(hold.envelope_id));
|
|
124
|
+
const result = fs.existsSync(resultPath) ? readJson(resultPath) : undefined;
|
|
125
|
+
if (input.choice === HANDOFF_HOLD_CHOICES[2]) {
|
|
126
|
+
if (hold.resolution)
|
|
127
|
+
throw new Error("A handoff disposition is already being committed");
|
|
128
|
+
if (isRecord(result))
|
|
129
|
+
atomicJson(resultPath, { ...result, decision: { question_id: input.questionId, choice: input.choice, at: input.at, ...(input.notes ? { notes: input.notes } : {}) } });
|
|
130
|
+
return { lane_id: path.basename(input.runDir), envelope_id: hold.envelope_id, action: "parked" };
|
|
131
|
+
}
|
|
132
|
+
if (hold.resolution && hold.resolution.choice !== input.choice)
|
|
133
|
+
throw new Error("A different handoff disposition is already recorded");
|
|
134
|
+
if (!hold.resolution) {
|
|
135
|
+
if (!isRecord(result) || result.hold_reason !== "handoff_needs_repair" || result.question_id !== input.questionId || result.envelope_id !== hold.envelope_id)
|
|
136
|
+
throw new Error("Handoff result identity changed");
|
|
137
|
+
const held = envelopeFiles(path.join(input.runDir, "loop", "stalled")).find((file) => fileEnvelope(file).envelope_id === hold.envelope_id);
|
|
138
|
+
if (!held)
|
|
139
|
+
throw new Error("Held handoff envelope is missing");
|
|
140
|
+
const record = fileEnvelope(held);
|
|
141
|
+
if (input.choice === HANDOFF_HOLD_CHOICES[0]) {
|
|
142
|
+
const externalPath = record.envelope.result_path;
|
|
143
|
+
if (typeof externalPath !== "string" || !externalPath.trim())
|
|
144
|
+
throw new Error("Held phase has no handoff path");
|
|
145
|
+
const output = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readHandoffBytes(externalPath));
|
|
146
|
+
validateAcceptedHandoff(output, record.envelope);
|
|
147
|
+
delete record.handoff_retry;
|
|
148
|
+
record.accepted_handoff = { output, sha256: createHash("sha256").update(output).digest("hex") };
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
delete record.accepted_handoff;
|
|
152
|
+
record.handoff_retry = true;
|
|
153
|
+
}
|
|
154
|
+
record.stall_attempts = 0;
|
|
155
|
+
hold.resolution = { choice: input.choice, at: input.at, ...(input.notes ? { notes: input.notes } : {}), pending_name: `0000000000000-answer-${path.basename(held)}`, record };
|
|
156
|
+
// Durable authorization precedes every queue/result move. The hold gates
|
|
157
|
+
// raw controllers until all those moves have completed, including retries.
|
|
158
|
+
atomicJson(handoffHoldPath(input.runDir), hold);
|
|
159
|
+
}
|
|
160
|
+
const resolution = hold.resolution;
|
|
161
|
+
const pending = path.join(pendingDir(input.runDir), resolution.pending_name);
|
|
162
|
+
if (path.basename(resolution.pending_name) !== resolution.pending_name || resolution.record.envelope_id !== hold.envelope_id)
|
|
163
|
+
throw new Error("Invalid handoff disposition record");
|
|
164
|
+
const held = envelopeFiles(path.join(input.runDir, "loop", "stalled")).find((file) => fileEnvelope(file).envelope_id === hold.envelope_id);
|
|
165
|
+
if (held) {
|
|
166
|
+
atomicJson(held, resolution.record);
|
|
167
|
+
fs.mkdirSync(pendingDir(input.runDir), { recursive: true });
|
|
168
|
+
fs.renameSync(held, pending);
|
|
169
|
+
}
|
|
170
|
+
else if (!fs.existsSync(pending) || JSON.stringify(fileEnvelope(pending)) !== JSON.stringify(resolution.record)) {
|
|
171
|
+
throw new Error("Disposition envelope changed before it could be resumed");
|
|
172
|
+
}
|
|
173
|
+
if (fs.existsSync(resultPath)) {
|
|
174
|
+
atomicJson(resultPath, { ...readJson(resultPath), decision: { question_id: input.questionId, choice: resolution.choice, at: resolution.at, ...(resolution.notes ? { notes: resolution.notes } : {}) } });
|
|
175
|
+
fs.renameSync(resultPath, asidePath(resultPath, "stalled"));
|
|
176
|
+
}
|
|
177
|
+
persistStatus(input.runDir, path.basename(input.runDir), { state: "waiting" });
|
|
178
|
+
fs.unlinkSync(handoffHoldPath(input.runDir));
|
|
179
|
+
return { lane_id: path.basename(input.runDir), envelope_id: hold.envelope_id, action: "requeued" };
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
release();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
17
185
|
function asidePath(file, marker) {
|
|
18
186
|
const parsed = path.parse(file);
|
|
19
187
|
let suffix = 1;
|
|
@@ -520,6 +688,11 @@ export function findLoopStallResult(runDir, questionId) {
|
|
|
520
688
|
return resultPath ? readJson(resultPath) : undefined;
|
|
521
689
|
}
|
|
522
690
|
export function applyLoopStallAnswer(input) {
|
|
691
|
+
const hold = readHandoffHold(input.runDir);
|
|
692
|
+
if (hold?.question_id === input.questionId)
|
|
693
|
+
return applyHandoffHoldAnswer(input);
|
|
694
|
+
if (hold)
|
|
695
|
+
throw new Error("A different handoff hold must be resolved first");
|
|
523
696
|
const result = findLoopStallResult(input.runDir, input.questionId);
|
|
524
697
|
if (!result) {
|
|
525
698
|
for (const name of stalledResultArchives(input.runDir)) {
|
|
@@ -586,12 +759,14 @@ export function loopStatus(runDir, laneId) {
|
|
|
586
759
|
const stallCounts = isRecord(loop.stall_counts)
|
|
587
760
|
? Object.fromEntries(Object.entries(loop.stall_counts).filter((entry) => typeof entry[1] === "number"))
|
|
588
761
|
: {};
|
|
762
|
+
const hold = readHandoffHold(runDir);
|
|
589
763
|
const processState = loop.state === "running" || loop.state === "parked" || loop.state === "stopped" || loop.state === "failed" ? loop.state : "waiting";
|
|
590
764
|
return {
|
|
591
|
-
mode: "loop", lane_id: laneId, state: processState, queue_depth: queueDepth,
|
|
765
|
+
mode: "loop", lane_id: laneId, state: hold && processState !== "stopped" ? "parked" : processState, queue_depth: queueDepth,
|
|
592
766
|
active_envelope_id: active?.envelope_id ?? null,
|
|
593
767
|
active_phase: active ? phaseName(active.envelope, active.envelope_id) : null,
|
|
594
768
|
stall_counts: stallCounts,
|
|
769
|
+
...(hold ? { handoff_hold: { envelope_id: hold.envelope_id, question_id: hold.question_id } } : {}),
|
|
595
770
|
...(typeof loop.pid === "number" ? { pid: loop.pid } : {}),
|
|
596
771
|
updated_at: typeof loop.updated_at === "string" ? loop.updated_at : new Date(0).toISOString(),
|
|
597
772
|
};
|
|
@@ -746,6 +921,13 @@ function abortAfter(ms, parent) {
|
|
|
746
921
|
/** Run queued phases serially. Sessions are isolated by story and provider. */
|
|
747
922
|
export async function runLoopLane(options) {
|
|
748
923
|
const now = options.now ?? (() => new Date());
|
|
924
|
+
const held = readHandoffHold(options.runDir);
|
|
925
|
+
if (held) {
|
|
926
|
+
if (loopStatus(options.runDir, options.lane.lane_id).state === "stopped")
|
|
927
|
+
return { state: "stopped", completed: 0 };
|
|
928
|
+
await notifyHandoffHold(options, held);
|
|
929
|
+
return { state: "parked", completed: 0 };
|
|
930
|
+
}
|
|
749
931
|
const defaultDeadlineSeconds = options.defaultDeadlineSeconds ?? 3600;
|
|
750
932
|
const sessions = new Map();
|
|
751
933
|
try {
|
|
@@ -766,6 +948,10 @@ export async function runLoopLane(options) {
|
|
|
766
948
|
let completed = 0;
|
|
767
949
|
fs.mkdirSync(options.runDir, { recursive: true });
|
|
768
950
|
let releaseRunner = acquireRunnerLease(options.runDir);
|
|
951
|
+
if (readHandoffHold(options.runDir)) {
|
|
952
|
+
releaseRunner();
|
|
953
|
+
return { state: loopStatus(options.runDir, options.lane.lane_id).state === "stopped" ? "stopped" : "parked", completed: 0 };
|
|
954
|
+
}
|
|
769
955
|
persistStatus(options.runDir, options.lane.lane_id, { pid: process.pid });
|
|
770
956
|
try {
|
|
771
957
|
const previous = readJson(loopPath(options.runDir));
|
|
@@ -821,7 +1007,7 @@ export async function runLoopLane(options) {
|
|
|
821
1007
|
const provider = envelopeProvider(envelope, options.lane.provider);
|
|
822
1008
|
const story = envelopeStory(envelope);
|
|
823
1009
|
const sessionKey = `${story}\0${provider}`;
|
|
824
|
-
const fresh = envelope.fresh === true || envelope.fresh_call === true;
|
|
1010
|
+
const fresh = record.handoff_retry === true || envelope.fresh === true || envelope.fresh_call === true;
|
|
825
1011
|
const previousSession = sessions.get(sessionKey);
|
|
826
1012
|
const startedAt = now().toISOString();
|
|
827
1013
|
const startedAtMs = Date.parse(startedAt);
|
|
@@ -835,14 +1021,29 @@ export async function runLoopLane(options) {
|
|
|
835
1021
|
atomicJson(file, record);
|
|
836
1022
|
persistStatus(options.runDir, options.lane.lane_id, { state: "running", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: record.envelope_id, active_phase: phaseName(envelope, record.envelope_id) });
|
|
837
1023
|
const timer = abortAfter(budget * 1000, options.signal);
|
|
1024
|
+
let malformedHandoff;
|
|
838
1025
|
try {
|
|
839
1026
|
let result;
|
|
840
1027
|
let fallback = false;
|
|
841
1028
|
try {
|
|
842
1029
|
const resumable = options.canResume?.(provider) ?? providerSupportsSessionResume(provider);
|
|
843
|
-
|
|
1030
|
+
if (record.accepted_handoff) {
|
|
1031
|
+
malformedHandoff = "Accepted handoff changed before continuation";
|
|
1032
|
+
const externalPath = envelope.result_path;
|
|
1033
|
+
if (typeof externalPath !== "string" || createHash("sha256").update(readHandoffBytes(externalPath)).digest("hex") !== record.accepted_handoff.sha256)
|
|
1034
|
+
throw new Error(malformedHandoff);
|
|
1035
|
+
if (createHash("sha256").update(record.accepted_handoff.output).digest("hex") !== record.accepted_handoff.sha256)
|
|
1036
|
+
throw new Error("Accepted handoff hash changed");
|
|
1037
|
+
validateAcceptedHandoff(record.accepted_handoff.output, envelope);
|
|
1038
|
+
result = { output: record.accepted_handoff.output };
|
|
1039
|
+
malformedHandoff = undefined;
|
|
1040
|
+
}
|
|
1041
|
+
else
|
|
1042
|
+
result = await options.execute({ lane: options.lane, envelope, envelopeId: record.envelope_id, signal: timer.signal, fresh: fresh || previousSession === undefined || !resumable, ...(fresh || previousSession === undefined || !resumable ? {} : { sessionId: previousSession }) });
|
|
844
1043
|
}
|
|
845
1044
|
catch (error) {
|
|
1045
|
+
if (record.accepted_handoff)
|
|
1046
|
+
throw error;
|
|
846
1047
|
const resumable = options.canResume?.(provider) ?? providerSupportsSessionResume(provider);
|
|
847
1048
|
if (!fresh && previousSession && resumable && !timer.signal.aborted) {
|
|
848
1049
|
fallback = true;
|
|
@@ -875,6 +1076,7 @@ export async function runLoopLane(options) {
|
|
|
875
1076
|
}
|
|
876
1077
|
}
|
|
877
1078
|
catch (validationError) {
|
|
1079
|
+
malformedHandoff = validationError instanceof Error ? validationError.message : String(validationError);
|
|
878
1080
|
if (options.repairInvalidHandoff !== false && !timer.signal.aborted) {
|
|
879
1081
|
const repairError = validationError instanceof Error ? validationError.message : String(validationError);
|
|
880
1082
|
const repaired = await options.execute({
|
|
@@ -899,6 +1101,7 @@ export async function runLoopLane(options) {
|
|
|
899
1101
|
if (repairedValidated.worker_id !== envelope.worker_id) {
|
|
900
1102
|
throw new Error(`handoff worker_id must match the queued phase envelope: ${repairError}`);
|
|
901
1103
|
}
|
|
1104
|
+
malformedHandoff = undefined;
|
|
902
1105
|
result = repaired;
|
|
903
1106
|
success = {
|
|
904
1107
|
...success,
|
|
@@ -932,6 +1135,33 @@ export async function runLoopLane(options) {
|
|
|
932
1135
|
persistStatus(options.runDir, options.lane.lane_id, { state: "waiting", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
933
1136
|
return { state: "aborted", completed };
|
|
934
1137
|
}
|
|
1138
|
+
if (malformedHandoff !== undefined) {
|
|
1139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1140
|
+
const questionId = randomUUID();
|
|
1141
|
+
const hold = { schema: "hq-loop-handoff-hold.v1", envelope_id: record.envelope_id, question_id: questionId, error: `${malformedHandoff}; repair failed: ${message}` };
|
|
1142
|
+
try {
|
|
1143
|
+
atomicJson(handoffHoldPath(options.runDir), hold);
|
|
1144
|
+
const externalPath = envelope.result_path;
|
|
1145
|
+
if (typeof externalPath === "string" && fs.existsSync(externalPath)) {
|
|
1146
|
+
const stat = fs.lstatSync(externalPath);
|
|
1147
|
+
if (stat.isFile() && stat.size <= LOOP_ENVELOPE_LIMIT) {
|
|
1148
|
+
const backup = path.join(options.runDir, "loop", "handoff-original", `${resultName(record.envelope_id)}.${questionId}`);
|
|
1149
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
1150
|
+
fs.writeFileSync(backup, readHandoffBytes(externalPath), { flag: "wx", mode: 0o600 });
|
|
1151
|
+
hold.original_copy = backup;
|
|
1152
|
+
atomicJson(handoffHoldPath(options.runDir), hold);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
writeResultAt(options.runDir, file, record, { schema_version: LOOP_SCHEMA, envelope_id: record.envelope_id, status: "stalled", hold_reason: "handoff_needs_repair", question_id: questionId, started_at: startedAt, finished_at: now().toISOString(), error: hold.error }, options.repairInvalidHandoff !== false);
|
|
1156
|
+
persistStatus(options.runDir, options.lane.lane_id, { state: "parked", queue_depth: listLoopQueue(options.runDir).length, active_envelope_id: null, active_phase: null });
|
|
1157
|
+
}
|
|
1158
|
+
finally {
|
|
1159
|
+
releaseRunner();
|
|
1160
|
+
clearLoopControllerPid(options.runDir, options.lane.lane_id, process.pid);
|
|
1161
|
+
}
|
|
1162
|
+
await notifyHandoffHold(options, hold, envelope);
|
|
1163
|
+
return { state: "parked", completed };
|
|
1164
|
+
}
|
|
935
1165
|
if (!timer.signal.aborted) {
|
|
936
1166
|
const failure = error instanceof Error ? error.message : String(error);
|
|
937
1167
|
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);
|
|
@@ -1035,7 +1265,7 @@ function writeResultAt(runDir, activeFile, record, result, includeHandoffValidat
|
|
|
1035
1265
|
schema: PHASE_HANDOFF_SCHEMA,
|
|
1036
1266
|
story_id: String(envelope.story_id), phase: String(envelope.phase),
|
|
1037
1267
|
worker_id: String(envelope.worker_id), status: stalled ? "blocked" : "failed",
|
|
1038
|
-
summary: stalled ? "Phase stalled twice and needs operator direction." : includeHandoffValidationError ? `Phase execution failed: ${result.error ?? "unknown error"}` : "Phase execution failed.",
|
|
1268
|
+
summary: result.hold_reason === "handoff_needs_repair" ? `Handoff needs repair: ${result.error ?? "invalid handoff"}` : stalled ? "Phase stalled twice and needs operator direction." : includeHandoffValidationError ? `Phase execution failed: ${result.error ?? "unknown error"}` : "Phase execution failed.",
|
|
1039
1269
|
files_changed: [], commits: [],
|
|
1040
1270
|
back_pressure: { tests: "skip", lint: "skip", typecheck: "skip", build: "skip" },
|
|
1041
1271
|
context_for_next: [result.error ?? result.status, ...(result.question_id ? [`Decision question: ${result.question_id}`] : [])].join("\n"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.345.
|
|
3
|
+
"version": "5.345.163",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
53
53
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
54
54
|
"@aws-sdk/dsql-signer": "^3.1146.0",
|
|
55
|
-
"@indigoai-us/hq-cloud": "6.18.
|
|
55
|
+
"@indigoai-us/hq-cloud": "6.18.100",
|
|
56
56
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
57
57
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
58
58
|
"@sentry/node": "^10.49.0",
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
|
|
115
115
|
},
|
|
116
116
|
"optionalDependencies": {
|
|
117
|
-
"@indigoai-us/hq-hook-win32-x64": "5.345.
|
|
118
|
-
"@indigoai-us/hq-hook-win32-arm64": "5.345.
|
|
119
|
-
"@indigoai-us/hq-hook-darwin-x64": "5.345.
|
|
120
|
-
"@indigoai-us/hq-hook-darwin-arm64": "5.345.
|
|
121
|
-
"@indigoai-us/hq-hook-linux-x64": "5.345.
|
|
122
|
-
"@indigoai-us/hq-hook-linux-arm64": "5.345.
|
|
117
|
+
"@indigoai-us/hq-hook-win32-x64": "5.345.163",
|
|
118
|
+
"@indigoai-us/hq-hook-win32-arm64": "5.345.163",
|
|
119
|
+
"@indigoai-us/hq-hook-darwin-x64": "5.345.163",
|
|
120
|
+
"@indigoai-us/hq-hook-darwin-arm64": "5.345.163",
|
|
121
|
+
"@indigoai-us/hq-hook-linux-x64": "5.345.163",
|
|
122
|
+
"@indigoai-us/hq-hook-linux-arm64": "5.345.163"
|
|
123
123
|
}
|
|
124
124
|
}
|