@indigoai-us/hq-cli 5.215.0 → 5.216.0
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 +15 -0
- package/dist/commands/lanes.js +39 -53
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/mesh-events.d.ts +3 -0
- package/dist/lib/lanes/mesh-events.js +32 -3
- package/dist/lib/lanes/profile.d.ts +5 -1
- package/dist/lib/lanes/profile.js +9 -6
- package/dist/lib/lanes/questions.d.ts +1 -1
- package/dist/lib/lanes/questions.js +2 -2
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +6 -1
- package/dist/lib/lanes/store.js +5 -0
- package/dist/lib/lanes/types.d.ts +2 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.d.ts +3 -1
- package/dist/lib/lanes/watcher.js +174 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.216.0] — 2026-09-26
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Lane watchers keep valid questions from a malformed `ask` envelope when
|
|
10
|
+
repair is skipped, cannot start, or still produces invalid output. The lane
|
|
11
|
+
stores the validation errors, and recovered question context includes them.
|
|
12
|
+
Repair spawns ignore unknown profile metadata and log the field names.
|
|
13
|
+
`hq lanes create` and `hq lanes resume --worker` keep strict validation.
|
|
14
|
+
|
|
15
|
+
- `hq lanes` stops sending status updates for stories missing from the project
|
|
16
|
+
board. The watcher reports the refusal once per lane run and keeps appending
|
|
17
|
+
lane events.
|
|
18
|
+
|
|
5
19
|
## [5.215.0] — 2026-09-26
|
|
6
20
|
|
|
7
21
|
### Fixed
|
|
@@ -9,6 +23,7 @@
|
|
|
9
23
|
- hq-cli 5.211.0 could resume a retired lead from its worker profile's
|
|
10
24
|
`keep_alive` value. Reconcile now requires an explicit lane opt-in written
|
|
11
25
|
after the latest envelope, and skips retired or superseded leads.
|
|
26
|
+
|
|
12
27
|
## [5.214.0] — 2026-09-26
|
|
13
28
|
|
|
14
29
|
### Added
|
package/dist/commands/lanes.js
CHANGED
|
@@ -36,7 +36,7 @@ import { CompanySelectionError } from "../utils/company-selection-error.js";
|
|
|
36
36
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
|
|
37
37
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
38
38
|
import { effectiveAccountHome } from "../lib/lanes/account-home.js";
|
|
39
|
-
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
39
|
+
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, hireGrantCapacityError, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionRecord, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, wakeAnsweredLaneNow, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
40
40
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
41
41
|
import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
|
|
42
42
|
import { keepAliveExclusion, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
|
|
@@ -897,41 +897,6 @@ async function withResumeHireAdmission(ctx, lane, fn) {
|
|
|
897
897
|
return fn();
|
|
898
898
|
});
|
|
899
899
|
}
|
|
900
|
-
function hireGrantCapacityError(parent, children) {
|
|
901
|
-
const grant = parent.hire_grant ?? 0;
|
|
902
|
-
const grantor = (parent.senior.kind === "lane" || parent.senior.kind === "session") &&
|
|
903
|
-
parent.senior.id
|
|
904
|
-
? { kind: parent.senior.kind, id: parent.senior.id }
|
|
905
|
-
: null;
|
|
906
|
-
const grantorReason = grantor
|
|
907
|
-
? undefined
|
|
908
|
-
: parent.senior.kind === "user"
|
|
909
|
-
? "senior_is_user"
|
|
910
|
-
: "parent_senior_missing_id";
|
|
911
|
-
const capacity = children.length === grant
|
|
912
|
-
? `is at its hire grant (${children.length} of ${grant} alive children)`
|
|
913
|
-
: `has exhausted its hire grant (${children.length} alive children; grant ${grant})`;
|
|
914
|
-
const grantCommand = `hq lanes grant ${parent.lane_id} --hire <n>`;
|
|
915
|
-
const authorityGuidance = grantor
|
|
916
|
-
? `Its senior ${grantor.kind} ${grantor.id} can raise it with: ${grantCommand}`
|
|
917
|
-
: parent.senior.kind === "user"
|
|
918
|
-
? `Its senior is the user and can raise it with: ${grantCommand}`
|
|
919
|
-
: `No senior is recorded, so an operator must resolve ownership before running: ${grantCommand}`;
|
|
920
|
-
const message = `Lane ${parent.lane_id} ${capacity}. ${authorityGuidance}`;
|
|
921
|
-
return new LanesError("hire_grant_exhausted", message, {
|
|
922
|
-
senior_lane_id: parent.lane_id,
|
|
923
|
-
parent_grant: grant,
|
|
924
|
-
alive_children: children.map((child) => child.lane_id),
|
|
925
|
-
alive_child_count: children.length,
|
|
926
|
-
grantor,
|
|
927
|
-
...(grantorReason ? { grantor_reason: grantorReason } : {}),
|
|
928
|
-
message,
|
|
929
|
-
ask_guidance: {
|
|
930
|
-
envelope: "decision=ask; include the desired total grant and the reason",
|
|
931
|
-
senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
|
|
932
|
-
},
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
900
|
const CAPACITY_PROBE_TIMEOUT_MS = 15_000;
|
|
936
901
|
const MAX_PLACEMENT_OUTPOSTS = 16;
|
|
937
902
|
function outpostIdentityUnknownError(message, expectedId, details = {}) {
|
|
@@ -2072,7 +2037,7 @@ async function emitLaneTerminalStateSoft(ctx, lane) {
|
|
|
2072
2037
|
process.stderr.write(`hq lanes: terminal mesh update failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2073
2038
|
}
|
|
2074
2039
|
}
|
|
2075
|
-
async function emitLaneStartedForStories(ctx, company, lane, threadId) {
|
|
2040
|
+
async function emitLaneStartedForStories(ctx, company, lane, threadId, knownAbsentStories = new Set()) {
|
|
2076
2041
|
let ready = lane;
|
|
2077
2042
|
if (ctx.mesh.ensureLaneThread) {
|
|
2078
2043
|
try {
|
|
@@ -2108,6 +2073,8 @@ async function emitLaneStartedForStories(ctx, company, lane, threadId) {
|
|
|
2108
2073
|
failures.push(err);
|
|
2109
2074
|
continue;
|
|
2110
2075
|
}
|
|
2076
|
+
if (knownAbsentStories.has(storyBoardStoryKey(story.project_id, story.story_id)))
|
|
2077
|
+
continue;
|
|
2111
2078
|
try {
|
|
2112
2079
|
await ctx.mesh.patchStoryStatus(company, story.project_id, story.story_id, "in_progress");
|
|
2113
2080
|
}
|
|
@@ -2135,20 +2102,30 @@ function storyNotOnBoardWarning(err) {
|
|
|
2135
2102
|
? err.message
|
|
2136
2103
|
: undefined;
|
|
2137
2104
|
}
|
|
2105
|
+
function storyBoardStoryKey(projectId, storyId) {
|
|
2106
|
+
return JSON.stringify([projectId, storyId]);
|
|
2107
|
+
}
|
|
2138
2108
|
function combinedStoryWarning(lane, additional = []) {
|
|
2139
2109
|
const warnings = [storyWarning(lane), ...additional]
|
|
2140
2110
|
.filter((warning) => warning !== undefined && warning.trim().length > 0);
|
|
2141
2111
|
return [...new Set(warnings)].join(" ") || null;
|
|
2142
2112
|
}
|
|
2143
|
-
async function
|
|
2113
|
+
async function storyBoardCheck(ctx, company, projectId, storyIds) {
|
|
2144
2114
|
try {
|
|
2145
2115
|
await assertStoriesOnBoard(ctx, company, projectId, storyIds);
|
|
2146
|
-
return
|
|
2116
|
+
return { absentStoryIds: new Set() };
|
|
2147
2117
|
}
|
|
2148
2118
|
catch (err) {
|
|
2149
2119
|
const warning = storyNotOnBoardWarning(err);
|
|
2150
|
-
if (warning !== undefined)
|
|
2151
|
-
|
|
2120
|
+
if (warning !== undefined) {
|
|
2121
|
+
const storyId = isLanesError(err) && typeof err.details.story_id === "string"
|
|
2122
|
+
? err.details.story_id
|
|
2123
|
+
: undefined;
|
|
2124
|
+
return {
|
|
2125
|
+
warning,
|
|
2126
|
+
absentStoryIds: new Set(storyId ? [storyId] : []),
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2152
2129
|
throw err;
|
|
2153
2130
|
}
|
|
2154
2131
|
}
|
|
@@ -2451,10 +2428,14 @@ async function runCreate(ctx, opts) {
|
|
|
2451
2428
|
const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
|
|
2452
2429
|
assertSameTenant(company, senior, seniorCompany);
|
|
2453
2430
|
const createStoryWarnings = [];
|
|
2431
|
+
const knownAbsentStories = new Set();
|
|
2454
2432
|
if (meshCompany && storyIds.length > 0) {
|
|
2455
|
-
const
|
|
2456
|
-
if (warning !== undefined)
|
|
2457
|
-
createStoryWarnings.push(warning);
|
|
2433
|
+
const boardCheck = await storyBoardCheck(ctx, meshCompany, project, storyIds);
|
|
2434
|
+
if (boardCheck.warning !== undefined)
|
|
2435
|
+
createStoryWarnings.push(boardCheck.warning);
|
|
2436
|
+
for (const storyId of boardCheck.absentStoryIds) {
|
|
2437
|
+
knownAbsentStories.add(storyBoardStoryKey(project, storyId));
|
|
2438
|
+
}
|
|
2458
2439
|
}
|
|
2459
2440
|
let resolvedWorker;
|
|
2460
2441
|
if (opts.worker) {
|
|
@@ -3067,7 +3048,7 @@ async function runCreate(ctx, opts) {
|
|
|
3067
3048
|
locateTranscript: ctx.locateTranscript,
|
|
3068
3049
|
});
|
|
3069
3050
|
if (meshCompany) {
|
|
3070
|
-
createStoryWarnings.push(...await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id));
|
|
3051
|
+
createStoryWarnings.push(...await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id, knownAbsentStories));
|
|
3071
3052
|
}
|
|
3072
3053
|
}
|
|
3073
3054
|
else if (outpostId) {
|
|
@@ -4510,6 +4491,7 @@ async function runShow(ctx, laneId) {
|
|
|
4510
4491
|
provider_session_id: lane.provider_session_id ?? null,
|
|
4511
4492
|
provider_session_id_absent: lane.provider_session_id_absent ?? null,
|
|
4512
4493
|
envelope_repair_mode: lane.envelope_repair?.mode ?? null,
|
|
4494
|
+
envelope_validation_errors: lane.envelope_validation_errors ?? [],
|
|
4513
4495
|
last_resume: lane.last_resume ?? null,
|
|
4514
4496
|
account: lane.account ?? null,
|
|
4515
4497
|
account_history: lane.account_history ?? [],
|
|
@@ -6327,7 +6309,7 @@ async function ensureLaneThreadRecord(ctx, lane, projectId = lane.project_id, st
|
|
|
6327
6309
|
}
|
|
6328
6310
|
return updated;
|
|
6329
6311
|
}
|
|
6330
|
-
async function emitStoryTransition(ctx, lane, projectId, storyId, event, status, payload = {}) {
|
|
6312
|
+
async function emitStoryTransition(ctx, lane, projectId, storyId, event, status, payload = {}, skipStoryStatusPatch = false) {
|
|
6331
6313
|
const company = lane.mesh_company ?? lane.company;
|
|
6332
6314
|
if (!company)
|
|
6333
6315
|
return;
|
|
@@ -6341,7 +6323,9 @@ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status,
|
|
|
6341
6323
|
...payload,
|
|
6342
6324
|
lane_status: lane.state,
|
|
6343
6325
|
});
|
|
6344
|
-
|
|
6326
|
+
if (!skipStoryStatusPatch) {
|
|
6327
|
+
await ctx.mesh.patchStoryStatus(company, projectId, storyId, status === "blocked" ? "in_progress" : status);
|
|
6328
|
+
}
|
|
6345
6329
|
}
|
|
6346
6330
|
function sameCommittedStory(current, expected, projectId, storyId) {
|
|
6347
6331
|
const currentStory = laneStory(current, projectId, storyId);
|
|
@@ -6350,9 +6334,9 @@ function sameCommittedStory(current, expected, projectId, storyId) {
|
|
|
6350
6334
|
current.story_id === expected.story_id &&
|
|
6351
6335
|
JSON.stringify(currentStory) === JSON.stringify(expectedStory));
|
|
6352
6336
|
}
|
|
6353
|
-
async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}) {
|
|
6337
|
+
async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}, skipStoryStatusPatch = false) {
|
|
6354
6338
|
try {
|
|
6355
|
-
await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload);
|
|
6339
|
+
await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload, skipStoryStatusPatch);
|
|
6356
6340
|
return undefined;
|
|
6357
6341
|
}
|
|
6358
6342
|
catch (err) {
|
|
@@ -6408,10 +6392,12 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
|
|
|
6408
6392
|
}
|
|
6409
6393
|
const company = lane.mesh_company ?? lane.company;
|
|
6410
6394
|
const warnings = [];
|
|
6395
|
+
let storyKnownAbsent = false;
|
|
6411
6396
|
if (company) {
|
|
6412
|
-
const
|
|
6413
|
-
if (warning !== undefined)
|
|
6414
|
-
warnings.push(warning);
|
|
6397
|
+
const boardCheck = await storyBoardCheck(ctx, company, projectId, [normalizedStoryId]);
|
|
6398
|
+
if (boardCheck.warning !== undefined)
|
|
6399
|
+
warnings.push(boardCheck.warning);
|
|
6400
|
+
storyKnownAbsent = boardCheck.absentStoryIds.has(normalizedStoryId);
|
|
6415
6401
|
}
|
|
6416
6402
|
const at = ctx.now().toISOString();
|
|
6417
6403
|
const thread = await ensureStoryThreadForTransition(ctx, lane, projectId, normalizedStoryId);
|
|
@@ -6427,7 +6413,7 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
|
|
|
6427
6413
|
no_story_reason: undefined,
|
|
6428
6414
|
};
|
|
6429
6415
|
});
|
|
6430
|
-
const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
|
|
6416
|
+
const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress", {}, storyKnownAbsent);
|
|
6431
6417
|
if (transitionWarning !== undefined)
|
|
6432
6418
|
warnings.push(transitionWarning);
|
|
6433
6419
|
const finalLane = readLane(hqRoot, laneId);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
|
|
5
5
|
export { MAX_LANE_DEPTH } from "./constants.js";
|
|
6
|
-
export { assignedChildHireGrants, aliveDirectChildren, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
|
|
6
|
+
export { assignedChildHireGrants, aliveDirectChildren, hireGrantCapacityError, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
|
|
7
7
|
export type { HireGrantBackfillRow } from "./hire-grants.js";
|
|
8
8
|
export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneOccupiesRunningSlot, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
|
|
9
9
|
export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
|
package/dist/lib/lanes/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export { AccountResolutionError, LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesConfigError, LanesError, WorkerResolutionError, isLaneVersionConflict, isLanesError, } from "./errors.js";
|
|
5
5
|
export { MAX_LANE_DEPTH } from "./constants.js";
|
|
6
|
-
export { assignedChildHireGrants, aliveDirectChildren, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
|
|
6
|
+
export { assignedChildHireGrants, aliveDirectChildren, hireGrantCapacityError, LANE_HIRE_GRANTS_FLAG, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./hire-grants.js";
|
|
7
7
|
export { compareLaneQueueOrder, hasActiveAdmissionReservation, hasActivePendingResume, hasEarlierEligibleQueuedLane, laneOccupiesRunningSlot, laneAdmissionCapReason, laneRunsOnHost, withAllLaneAdmissionLocks, withLaneAdmissionLock, } from "./admission.js";
|
|
8
8
|
export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
|
|
9
9
|
export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
|
|
@@ -92,6 +92,7 @@ export interface CreateLaneMeshOptions {
|
|
|
92
92
|
}) => Promise<void>;
|
|
93
93
|
}
|
|
94
94
|
export declare function storyNotOnBoardError(company: LaneCompanyRef, projectId: string, storyId: string): LanesError;
|
|
95
|
+
export declare function laneStoryStatusSuppressionKey(company: LaneCompanyRef, projectId: string, storyId: string): string;
|
|
95
96
|
export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
|
|
96
97
|
/** Resolve the explicit mesh attribution before falling back to the lane tenant. */
|
|
97
98
|
export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
|
|
@@ -106,6 +107,8 @@ export declare function laneMeshEventIdentity(lane: LaneRecord): Record<string,
|
|
|
106
107
|
export declare function emitLaneDecisionEvents(mesh: LaneMesh, lane: LaneRecord, envelope: Pick<LaneEnvelope, "decision" | "summary" | "blocked_reason">, opts?: {
|
|
107
108
|
questionIds?: string[];
|
|
108
109
|
signal?: AbortSignal;
|
|
110
|
+
/** Per-watcher-run cache for story_not_on_board refusals. */
|
|
111
|
+
storyStatusSuppressions?: Set<string>;
|
|
109
112
|
}): Promise<void>;
|
|
110
113
|
/** Emit an abnormal terminal state even when the worker produced no envelope. */
|
|
111
114
|
export declare function emitLaneTerminalStateEvent(mesh: LaneMesh, lane: LaneRecord, state: Exclude<TerminalLaneState, "done">, summary?: string, signal?: AbortSignal): Promise<void>;
|
|
@@ -229,6 +229,17 @@ function isUnknownProjectStory(error) {
|
|
|
229
229
|
typeof candidate.message === "string" &&
|
|
230
230
|
candidate.message.includes("Unknown story"));
|
|
231
231
|
}
|
|
232
|
+
function isUncodedStoryPatchNotFound(error) {
|
|
233
|
+
if (!error || typeof error !== "object")
|
|
234
|
+
return false;
|
|
235
|
+
const candidate = error;
|
|
236
|
+
// The story-absent route response is exactly `404 Not found` without a code.
|
|
237
|
+
// Keep coded 404s, especially PROJECT_VIEW_NOT_FOUND, on their own paths.
|
|
238
|
+
return (candidate.status === 404 &&
|
|
239
|
+
candidate.code === undefined &&
|
|
240
|
+
typeof candidate.message === "string" &&
|
|
241
|
+
/^404 not found$/i.test(candidate.message));
|
|
242
|
+
}
|
|
232
243
|
function shellQuote(value) {
|
|
233
244
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
234
245
|
}
|
|
@@ -245,6 +256,9 @@ export function storyNotOnBoardError(company, projectId, storyId) {
|
|
|
245
256
|
command,
|
|
246
257
|
});
|
|
247
258
|
}
|
|
259
|
+
export function laneStoryStatusSuppressionKey(company, projectId, storyId) {
|
|
260
|
+
return JSON.stringify([company.uid, projectId, storyId]);
|
|
261
|
+
}
|
|
248
262
|
function isAlreadyRegistered(error) {
|
|
249
263
|
if (!error || typeof error !== "object")
|
|
250
264
|
return false;
|
|
@@ -365,7 +379,7 @@ export function createLaneMesh(opts = {}) {
|
|
|
365
379
|
await patchStoryStatus(tok, company.uid, projectId, storyId, status);
|
|
366
380
|
}
|
|
367
381
|
catch (error) {
|
|
368
|
-
if (isUnknownProjectStory(error)) {
|
|
382
|
+
if (isUnknownProjectStory(error) || isUncodedStoryPatchNotFound(error)) {
|
|
369
383
|
throw storyNotOnBoardError(company, projectId, storyId);
|
|
370
384
|
}
|
|
371
385
|
throw error;
|
|
@@ -437,6 +451,21 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
|
|
|
437
451
|
const threadId = await ensureLaneEventThread(mesh, company, lane, lane.state, opts.signal);
|
|
438
452
|
if (!company || !threadId)
|
|
439
453
|
return;
|
|
454
|
+
const patchStoryStatus = async (status) => {
|
|
455
|
+
const suppressions = opts.storyStatusSuppressions;
|
|
456
|
+
const key = laneStoryStatusSuppressionKey(company, lane.project_id, lane.story_id);
|
|
457
|
+
if (suppressions?.has(key))
|
|
458
|
+
return;
|
|
459
|
+
try {
|
|
460
|
+
await mesh.patchStoryStatus(company, lane.project_id, lane.story_id, status);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
if (suppressions && err instanceof LanesError && err.error === "story_not_on_board") {
|
|
464
|
+
suppressions.add(key);
|
|
465
|
+
}
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
440
469
|
switch (envelope.decision) {
|
|
441
470
|
case "done": {
|
|
442
471
|
const outcomes = await Promise.allSettled([
|
|
@@ -444,7 +473,7 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
|
|
|
444
473
|
...laneMeshEventIdentity(lane),
|
|
445
474
|
summary: envelope.summary,
|
|
446
475
|
}, opts.signal),
|
|
447
|
-
|
|
476
|
+
patchStoryStatus("review"),
|
|
448
477
|
]);
|
|
449
478
|
const failure = outcomes.find((outcome) => outcome.status === "rejected");
|
|
450
479
|
if (failure?.status === "rejected")
|
|
@@ -458,7 +487,7 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
|
|
|
458
487
|
...laneMeshEventIdentity(lane),
|
|
459
488
|
blocked_reason: envelope.blocked_reason ?? "",
|
|
460
489
|
}),
|
|
461
|
-
|
|
490
|
+
patchStoryStatus("in_progress"),
|
|
462
491
|
]);
|
|
463
492
|
const failure = outcomes.find((outcome) => outcome.status === "rejected");
|
|
464
493
|
if (failure?.status === "rejected")
|
|
@@ -33,10 +33,14 @@ export interface WorkerProfileRuntime {
|
|
|
33
33
|
settingsPath?: string;
|
|
34
34
|
hooksDir?: string;
|
|
35
35
|
}
|
|
36
|
+
export interface LoadWorkerProfileOptions {
|
|
37
|
+
/** Unknown keys may be ignored only by callers with an explicit recovery path. */
|
|
38
|
+
onUnknownMetaFields?: (fields: readonly string[]) => void;
|
|
39
|
+
}
|
|
36
40
|
export declare function personalWorkerProfileDir(hqRoot: string, id: string): string;
|
|
37
41
|
/** A meta.yaml file is the explicit marker for a personal worker profile. */
|
|
38
42
|
export declare function workerProfileMarker(hqRoot: string, id: string): "absent" | "present";
|
|
39
|
-
export declare function loadWorkerProfile(hqRoot: string, id: string): WorkerProfile;
|
|
43
|
+
export declare function loadWorkerProfile(hqRoot: string, id: string, options?: LoadWorkerProfileOptions): WorkerProfile;
|
|
40
44
|
export interface EffectiveLaneKeepAlive {
|
|
41
45
|
enabled: boolean;
|
|
42
46
|
source: "lane" | "none";
|
|
@@ -117,15 +117,18 @@ function collectFiles(id, dir, relativeDir) {
|
|
|
117
117
|
visit(absoluteDir, relativeDir);
|
|
118
118
|
return files.sort((a, b) => a.localeCompare(b));
|
|
119
119
|
}
|
|
120
|
-
function parseMeta(id, dir, raw) {
|
|
120
|
+
function parseMeta(id, dir, raw, options = {}) {
|
|
121
121
|
if (!isPlainObject(raw)) {
|
|
122
122
|
throw profileError(id, dir, "meta.yaml must contain an object");
|
|
123
123
|
}
|
|
124
124
|
const unknown = Object.keys(raw).filter((key) => !PROFILE_META_KEYS.has(key));
|
|
125
125
|
if (unknown.length > 0) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
if (options.onUnknownMetaFields === undefined) {
|
|
127
|
+
throw profileError(id, dir, `meta.yaml has unknown field(s): ${unknown.join(", ")}`, {
|
|
128
|
+
fields: unknown,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
options.onUnknownMetaFields(unknown);
|
|
129
132
|
}
|
|
130
133
|
const provider = raw.provider;
|
|
131
134
|
if (typeof provider !== "string" || !isLaneProvider(provider.trim())) {
|
|
@@ -305,7 +308,7 @@ export function workerProfileMarker(hqRoot, id) {
|
|
|
305
308
|
return "present";
|
|
306
309
|
return "present";
|
|
307
310
|
}
|
|
308
|
-
export function loadWorkerProfile(hqRoot, id) {
|
|
311
|
+
export function loadWorkerProfile(hqRoot, id, options = {}) {
|
|
309
312
|
const dir = personalWorkerProfileDir(hqRoot, id);
|
|
310
313
|
if (!validProfileId(id)) {
|
|
311
314
|
throw profileError(id, dir, "profile id must be a simple directory name", {
|
|
@@ -338,7 +341,7 @@ export function loadWorkerProfile(hqRoot, id) {
|
|
|
338
341
|
field: PROFILE_META,
|
|
339
342
|
});
|
|
340
343
|
}
|
|
341
|
-
const meta = parseMeta(id, dir, metaRaw);
|
|
344
|
+
const meta = parseMeta(id, dir, metaRaw, options);
|
|
342
345
|
const systemPromptPath = path.join(dir, PROFILE_SYSTEM_PROMPT);
|
|
343
346
|
const systemPrompt = readRequiredText(id, dir, systemPromptPath, PROFILE_SYSTEM_PROMPT);
|
|
344
347
|
if (systemPrompt.trim().length === 0) {
|
|
@@ -71,5 +71,5 @@ export declare function envelopeQuestionId(laneId: string, envelopeRef: string,
|
|
|
71
71
|
* Spec §6.1: each envelope questions[] entry becomes one queue record, via
|
|
72
72
|
* raiseLaneQuestion (the same function the CLI ask path uses).
|
|
73
73
|
*/
|
|
74
|
-
export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string): QuestionRecord[];
|
|
74
|
+
export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string, questionIndexes?: readonly number[]): QuestionRecord[];
|
|
75
75
|
//# sourceMappingURL=questions.d.ts.map
|
|
@@ -222,7 +222,7 @@ function envelopeQuestionBody(item) {
|
|
|
222
222
|
* Spec §6.1: each envelope questions[] entry becomes one queue record, via
|
|
223
223
|
* raiseLaneQuestion (the same function the CLI ask path uses).
|
|
224
224
|
*/
|
|
225
|
-
export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at) {
|
|
225
|
+
export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at, questionIndexes) {
|
|
226
226
|
const items = envelope.questions ?? [];
|
|
227
227
|
const raised = [];
|
|
228
228
|
for (let i = 0; i < items.length; i += 1) {
|
|
@@ -235,7 +235,7 @@ export function raiseEnvelopeQuestions(hqRoot, lane, envelope, envelopeRef, at)
|
|
|
235
235
|
body: envelopeQuestionBody(item),
|
|
236
236
|
raisedVia: "envelope",
|
|
237
237
|
at,
|
|
238
|
-
questionId: envelopeQuestionId(lane.lane_id, envelopeRef, i),
|
|
238
|
+
questionId: envelopeQuestionId(lane.lane_id, envelopeRef, questionIndexes?.[i] ?? i),
|
|
239
239
|
}));
|
|
240
240
|
}
|
|
241
241
|
return raised;
|
|
@@ -305,6 +305,8 @@ export interface EnvelopeRepairSpawnInput {
|
|
|
305
305
|
rawText: string;
|
|
306
306
|
/** True when this run never wrote its expected envelope file. */
|
|
307
307
|
missing?: boolean;
|
|
308
|
+
/** Repair-only warning hook for profile metadata ignored during recovery. */
|
|
309
|
+
onProfileWarning?: (fields: readonly string[]) => void;
|
|
308
310
|
}
|
|
309
311
|
/** How the spec §4 repair turn reaches the provider (recorded as `envelope_repair.mode`). */
|
|
310
312
|
export type EnvelopeRepairMode = "resume" | "fresh_turn";
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -2152,7 +2152,12 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
2152
2152
|
ensureDir(logsDir);
|
|
2153
2153
|
const config = loadLanesConfig(hqRoot);
|
|
2154
2154
|
const profile = lane.worker_profile
|
|
2155
|
-
? loadWorkerProfile(hqRoot, lane.worker_profile
|
|
2155
|
+
? loadWorkerProfile(hqRoot, lane.worker_profile, {
|
|
2156
|
+
onUnknownMetaFields: (fields) => {
|
|
2157
|
+
process.stderr.write(`hq lanes watcher: envelope repair ignored unknown worker profile meta field(s) for ${lane.worker_profile}: ${fields.join(", ")}\n`);
|
|
2158
|
+
input.onProfileWarning?.(fields);
|
|
2159
|
+
},
|
|
2160
|
+
})
|
|
2156
2161
|
: undefined;
|
|
2157
2162
|
const profileDefaultsApply = profile !== undefined && profile.meta.provider === lane.provider;
|
|
2158
2163
|
const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -470,6 +470,11 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
470
470
|
new Set(raw.pending_question_ids).size !== raw.pending_question_ids.length)) {
|
|
471
471
|
throw new LaneSchemaError("pending_question_ids must be an array of unique non-empty ids", { path: filePath, field: "pending_question_ids" });
|
|
472
472
|
}
|
|
473
|
+
if (raw.envelope_validation_errors !== undefined && (!Array.isArray(raw.envelope_validation_errors) ||
|
|
474
|
+
raw.envelope_validation_errors.length === 0 ||
|
|
475
|
+
raw.envelope_validation_errors.some((error) => typeof error !== "string" || error.trim() === ""))) {
|
|
476
|
+
throw new LaneSchemaError("envelope_validation_errors must be a non-empty array of non-empty strings", { path: filePath, field: "envelope_validation_errors" });
|
|
477
|
+
}
|
|
473
478
|
requireTimestamp(raw.created_at, "created_at", filePath);
|
|
474
479
|
requireTimestamp(raw.updated_at, "updated_at", filePath);
|
|
475
480
|
if (raw.queued_reason !== undefined && !isQueuedReason(raw.queued_reason)) {
|
|
@@ -432,6 +432,8 @@ export interface LaneRecord {
|
|
|
432
432
|
story_complete_reason?: string;
|
|
433
433
|
/** Copied from the last ingested envelope when present. */
|
|
434
434
|
story_status?: StoryStatus;
|
|
435
|
+
/** Envelope parser failures retained when a usable ask is recovered. */
|
|
436
|
+
envelope_validation_errors?: string[];
|
|
435
437
|
/** Set by the reconcile pass when `--timeout` has passed (spec §5.7). */
|
|
436
438
|
overdue?: boolean;
|
|
437
439
|
overdue_at?: string;
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -232,7 +232,7 @@ export const LANE_FIELD_WRITERS = {
|
|
|
232
232
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
233
233
|
admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
234
234
|
quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
|
|
235
|
-
provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], last_progress_event_at: ["watcher"],
|
|
235
|
+
provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
|
|
236
236
|
};
|
|
237
237
|
function isPlainObject(value) {
|
|
238
238
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -143,6 +143,8 @@ export declare function notifySlotFreed(_input: {
|
|
|
143
143
|
export interface ApplyTerminalOpts {
|
|
144
144
|
envelope?: LaneEnvelope | null;
|
|
145
145
|
envelopeRef?: string;
|
|
146
|
+
envelopeValidationErrors?: string[];
|
|
147
|
+
questionIndexes?: readonly number[];
|
|
146
148
|
at?: string;
|
|
147
149
|
quota?: QuotaSignal | null;
|
|
148
150
|
/** Written to `terminal_details` with the model and account the run used. */
|
|
@@ -225,7 +227,7 @@ export interface WatcherLoopDeps {
|
|
|
225
227
|
*/
|
|
226
228
|
export declare function emitMeshSoft(event: string, logDecision: (record: Record<string, unknown>) => void, at: string, fn: (signal: AbortSignal) => Promise<void>, timeoutMs?: number): Promise<boolean>;
|
|
227
229
|
/** Question ids an ask envelope raised, as `raiseEnvelopeQuestions` mints them. */
|
|
228
|
-
export declare function envelopeQuestionIds(laneId: string, envelopeRef: string, envelope: LaneEnvelope): string[];
|
|
230
|
+
export declare function envelopeQuestionIds(laneId: string, envelopeRef: string, envelope: LaneEnvelope, questionIndexes?: readonly number[]): string[];
|
|
229
231
|
export declare function runWatcherLoop(hqRoot: string, laneId: string, deps?: WatcherLoopDeps): Promise<void>;
|
|
230
232
|
export type EnvelopeRepairDecision = {
|
|
231
233
|
action: "spawned";
|
|
@@ -35,7 +35,7 @@ import * as fs from "node:fs";
|
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
import { LanesError } from "./errors.js";
|
|
37
37
|
import { raiseEnvelopeQuestions } from "./questions.js";
|
|
38
|
-
import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, } from "./types.js";
|
|
38
|
+
import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, } from "./types.js";
|
|
39
39
|
import { laneStories, storyStatusForEnvelope } from "./stories.js";
|
|
40
40
|
import { readLane, retryUpdateLane } from "./store.js";
|
|
41
41
|
import { readWorkerRunResult } from "./delivery.js";
|
|
@@ -44,7 +44,7 @@ import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, provide
|
|
|
44
44
|
import { readToolActivity, toolActivityPath } from "./dropbox.js";
|
|
45
45
|
import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
|
|
46
46
|
import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
|
|
47
|
-
import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, progressEventDue, } from "./mesh-events.js";
|
|
47
|
+
import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
|
|
48
48
|
import { envelopeQuestionId } from "./questions.js";
|
|
49
49
|
import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, retryLaneOnAlternateAccount, } from "./spawn.js";
|
|
50
50
|
/** Spec §5.7: no signal for 20 minutes while the process is still alive. */
|
|
@@ -313,7 +313,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
313
313
|
classification.state === "awaiting_input" &&
|
|
314
314
|
opts.envelope &&
|
|
315
315
|
(opts.envelope.questions?.length ?? 0) > 0) {
|
|
316
|
-
raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at);
|
|
316
|
+
raiseEnvelopeQuestions(hqRoot, existing, opts.envelope, opts.envelopeRef ?? "envelopes/unknown.json", at, opts.questionIndexes);
|
|
317
317
|
}
|
|
318
318
|
let changed = false;
|
|
319
319
|
const updated = retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
|
|
@@ -336,6 +336,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
336
336
|
stalled: undefined,
|
|
337
337
|
stalled_at: undefined,
|
|
338
338
|
stalled_reason: undefined,
|
|
339
|
+
envelope_validation_errors: opts.envelopeValidationErrors,
|
|
339
340
|
};
|
|
340
341
|
changed = true;
|
|
341
342
|
if (classification.reason) {
|
|
@@ -432,6 +433,81 @@ function fileNonEmpty(filePath) {
|
|
|
432
433
|
throw err;
|
|
433
434
|
}
|
|
434
435
|
}
|
|
436
|
+
function isPlainObject(value) {
|
|
437
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
438
|
+
}
|
|
439
|
+
/** Keep only questions that the normal envelope parser accepts. */
|
|
440
|
+
function recoverAskEnvelope(text, validationErrors) {
|
|
441
|
+
const raw = JSON.parse(text);
|
|
442
|
+
if (!isPlainObject(raw) || raw.decision !== "ask" || !Array.isArray(raw.questions)) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
const validQuestions = [];
|
|
446
|
+
const questionIndexes = [];
|
|
447
|
+
const questionErrors = [];
|
|
448
|
+
for (let index = 0; index < raw.questions.length; index += 1) {
|
|
449
|
+
const question = raw.questions[index];
|
|
450
|
+
try {
|
|
451
|
+
const parsed = parseLaneEnvelope({
|
|
452
|
+
schema_version: "hq-lane-envelope.v1",
|
|
453
|
+
decision: "ask",
|
|
454
|
+
summary: "Recovered operator question",
|
|
455
|
+
story_status: "in_progress",
|
|
456
|
+
artifacts: [],
|
|
457
|
+
questions: [question],
|
|
458
|
+
});
|
|
459
|
+
const item = parsed.questions?.[0];
|
|
460
|
+
if (item === undefined) {
|
|
461
|
+
throw new Error("question parser returned no question");
|
|
462
|
+
}
|
|
463
|
+
validQuestions.push(item);
|
|
464
|
+
questionIndexes.push(index);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
questionErrors.push(`questions[${index}] is invalid: ${err instanceof Error ? err.message : String(err)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (validQuestions.length === 0) {
|
|
471
|
+
return {
|
|
472
|
+
envelope: {
|
|
473
|
+
schema_version: "hq-lane-envelope.v1",
|
|
474
|
+
decision: "ask",
|
|
475
|
+
summary: "The worker needs operator input after envelope validation failed.",
|
|
476
|
+
story_status: STORY_STATUSES.includes(raw.story_status)
|
|
477
|
+
? raw.story_status
|
|
478
|
+
: "in_progress",
|
|
479
|
+
artifacts: [],
|
|
480
|
+
questions: [],
|
|
481
|
+
},
|
|
482
|
+
questionIndexes,
|
|
483
|
+
questionErrors,
|
|
484
|
+
sourceQuestionCount: raw.questions.length,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const contextErrors = [...new Set([...validationErrors, ...questionErrors])];
|
|
488
|
+
const storyStatus = STORY_STATUSES.includes(raw.story_status) && raw.story_status !== "done"
|
|
489
|
+
? raw.story_status
|
|
490
|
+
: "in_progress";
|
|
491
|
+
const questions = validQuestions.map((question) => ({
|
|
492
|
+
...question,
|
|
493
|
+
context: `${question.context}\n\nEnvelope validation errors:\n${contextErrors.map((error) => `- ${error}`).join("\n")}`,
|
|
494
|
+
}));
|
|
495
|
+
return {
|
|
496
|
+
envelope: {
|
|
497
|
+
schema_version: "hq-lane-envelope.v1",
|
|
498
|
+
decision: "ask",
|
|
499
|
+
summary: typeof raw.summary === "string" && raw.summary.length > 0
|
|
500
|
+
? raw.summary
|
|
501
|
+
: "The worker needs operator input after envelope validation failed.",
|
|
502
|
+
story_status: storyStatus,
|
|
503
|
+
artifacts: [],
|
|
504
|
+
questions,
|
|
505
|
+
},
|
|
506
|
+
questionIndexes,
|
|
507
|
+
questionErrors,
|
|
508
|
+
sourceQuestionCount: raw.questions.length,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
435
511
|
export function readLatestEnvelope(hqRoot, laneId, minName) {
|
|
436
512
|
return readLatestEnvelopeRecord(hqRoot, laneId, minName)?.envelope ?? null;
|
|
437
513
|
}
|
|
@@ -570,8 +646,8 @@ function readLaneSoft(hqRoot, laneId) {
|
|
|
570
646
|
}
|
|
571
647
|
}
|
|
572
648
|
/** Question ids an ask envelope raised, as `raiseEnvelopeQuestions` mints them. */
|
|
573
|
-
export function envelopeQuestionIds(laneId, envelopeRef, envelope) {
|
|
574
|
-
return (envelope.questions ?? []).map((_q, i) => envelopeQuestionId(laneId, envelopeRef, i));
|
|
649
|
+
export function envelopeQuestionIds(laneId, envelopeRef, envelope, questionIndexes) {
|
|
650
|
+
return (envelope.questions ?? []).map((_q, i) => envelopeQuestionId(laneId, envelopeRef, questionIndexes?.[i] ?? i));
|
|
575
651
|
}
|
|
576
652
|
function defaultSleep(ms) {
|
|
577
653
|
return new Promise((resolve) => {
|
|
@@ -604,6 +680,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
604
680
|
const progressFiles = [...logFiles, ...activityFiles];
|
|
605
681
|
// Mesh seam (spec §8). Non-interactive: nobody is at this process's tty.
|
|
606
682
|
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
683
|
+
const storyStatusSuppressions = new Set();
|
|
607
684
|
const meshRequestTimeoutMs = deps.meshRequestTimeoutMs ?? 10_000;
|
|
608
685
|
const initialLane = readLaneSoft(hqRoot, laneId);
|
|
609
686
|
if (mesh && initialLane) {
|
|
@@ -631,6 +708,10 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
631
708
|
}
|
|
632
709
|
catch (err) {
|
|
633
710
|
const message = err instanceof Error ? err.message : String(err);
|
|
711
|
+
if (company && initialLane.story_id &&
|
|
712
|
+
err instanceof LanesError && err.error === "story_not_on_board") {
|
|
713
|
+
storyStatusSuppressions.add(laneStoryStatusSuppressionKey(company, initialLane.project_id, initialLane.story_id));
|
|
714
|
+
}
|
|
634
715
|
process.stderr.write(`hq lanes watcher: mesh thread ensure failed for ${laneId}: ${message}\n`);
|
|
635
716
|
writeWatcherDecision(outPath, {
|
|
636
717
|
event: "mesh_event_failed",
|
|
@@ -701,10 +782,10 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
701
782
|
const missingExpected = !processAlive && latestFile === null
|
|
702
783
|
? missingExpectedEnvelopeFile(hqRoot, laneId, manifest.envelope_path)
|
|
703
784
|
: null;
|
|
704
|
-
|
|
785
|
+
let latest = latestFile && latestFile.envelope
|
|
705
786
|
? { envelope: latestFile.envelope, ref: latestFile.ref }
|
|
706
787
|
: null;
|
|
707
|
-
|
|
788
|
+
let envelope = latest?.envelope ?? null;
|
|
708
789
|
if (latest && latest.ref !== lastEnvelopeRef) {
|
|
709
790
|
logDecision({
|
|
710
791
|
event: "envelope_observed",
|
|
@@ -751,6 +832,9 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
751
832
|
// fails the same way, and would end the lane envelope_invalid without
|
|
752
833
|
// ever recording the hold. The quota result applies immediately instead.
|
|
753
834
|
let invalidEnvelope = null;
|
|
835
|
+
let envelopeValidationErrors;
|
|
836
|
+
let questionIndexes;
|
|
837
|
+
let recoveredAsk = false;
|
|
754
838
|
const repairCandidate = latestFile ?? missingExpected;
|
|
755
839
|
if (!processAlive &&
|
|
756
840
|
!envelope &&
|
|
@@ -771,11 +855,70 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
771
855
|
await sleep(pollMs);
|
|
772
856
|
continue;
|
|
773
857
|
}
|
|
774
|
-
if (!repairCandidate.missing)
|
|
775
|
-
|
|
858
|
+
if (!repairCandidate.missing) {
|
|
859
|
+
const validationErrors = repair.errors.filter((error) => !error.startsWith("repair_spawn_failed:"));
|
|
860
|
+
let recovery = null;
|
|
861
|
+
try {
|
|
862
|
+
recovery = recoverAskEnvelope(repairCandidate.text, validationErrors);
|
|
863
|
+
}
|
|
864
|
+
catch (err) {
|
|
865
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
866
|
+
process.stderr.write(`hq lanes watcher: invalid ask fallback could not parse ${repairCandidate.path} for ${laneId}: ${message}\n`);
|
|
867
|
+
logDecision({
|
|
868
|
+
event: "envelope_repair",
|
|
869
|
+
at: new Date(now()).toISOString(),
|
|
870
|
+
outcome: "ask_fallback_skipped",
|
|
871
|
+
reason: "invalid_json",
|
|
872
|
+
error: message,
|
|
873
|
+
validation_errors: validationErrors,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
if (recovery !== null && (recovery.envelope.questions?.length ?? 0) > 0) {
|
|
877
|
+
const errors = [...new Set([...validationErrors, ...recovery.questionErrors])];
|
|
878
|
+
envelopeValidationErrors = errors;
|
|
879
|
+
questionIndexes = recovery.questionIndexes;
|
|
880
|
+
latest = { envelope: recovery.envelope, ref: repairCandidate.ref };
|
|
881
|
+
envelope = recovery.envelope;
|
|
882
|
+
recoveredAsk = true;
|
|
883
|
+
const message = `hq lanes watcher: routing invalid ask envelope for ${laneId} to awaiting_input ` +
|
|
884
|
+
`with ${recovery.envelope.questions?.length ?? 0} validated question(s); errors: ${errors.join("; ")}`;
|
|
885
|
+
process.stderr.write(`${message}\n`);
|
|
886
|
+
logDecision({
|
|
887
|
+
event: "envelope_repair",
|
|
888
|
+
at: new Date(now()).toISOString(),
|
|
889
|
+
outcome: "ask_fallback_routed",
|
|
890
|
+
state: "awaiting_input",
|
|
891
|
+
questions: recovery.envelope.questions?.length ?? 0,
|
|
892
|
+
validation_errors: errors,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
else {
|
|
896
|
+
const errors = recovery === null
|
|
897
|
+
? repair.errors
|
|
898
|
+
: [...new Set([...repair.errors, ...recovery.questionErrors])];
|
|
899
|
+
invalidEnvelope = { errors };
|
|
900
|
+
if (recovery !== null) {
|
|
901
|
+
const message = `hq lanes watcher: invalid ask envelope for ${laneId} remains failed; ` +
|
|
902
|
+
`0 of ${recovery.sourceQuestionCount} questions passed validation`;
|
|
903
|
+
process.stderr.write(`${message}\n`);
|
|
904
|
+
logDecision({
|
|
905
|
+
event: "envelope_repair",
|
|
906
|
+
at: new Date(now()).toISOString(),
|
|
907
|
+
outcome: "ask_fallback_skipped",
|
|
908
|
+
reason: "no_valid_questions",
|
|
909
|
+
questions: 0,
|
|
910
|
+
validation_errors: errors,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
776
915
|
}
|
|
777
916
|
}
|
|
778
|
-
if (processAlive === false &&
|
|
917
|
+
if (processAlive === false &&
|
|
918
|
+
envelope &&
|
|
919
|
+
latestFile !== null &&
|
|
920
|
+
latestFile.envelope !== null &&
|
|
921
|
+
!recoveredAsk) {
|
|
779
922
|
markRepairSucceeded(hqRoot, laneId, latestFile.ref);
|
|
780
923
|
}
|
|
781
924
|
const tick = tickWatcher({
|
|
@@ -913,6 +1056,8 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
913
1056
|
const terminalWrite = applyTerminalToLaneWithResult(hqRoot, laneId, classification, {
|
|
914
1057
|
envelope,
|
|
915
1058
|
envelopeRef: latest?.ref,
|
|
1059
|
+
envelopeValidationErrors,
|
|
1060
|
+
questionIndexes,
|
|
916
1061
|
at: tick.emit.at,
|
|
917
1062
|
quota,
|
|
918
1063
|
providerRejection,
|
|
@@ -930,9 +1075,13 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
930
1075
|
if (mesh && terminalWrite.changed) {
|
|
931
1076
|
if (envelope && latest) {
|
|
932
1077
|
const questionIds = envelope.decision === "ask"
|
|
933
|
-
? envelopeQuestionIds(laneId, latest.ref, envelope)
|
|
1078
|
+
? envelopeQuestionIds(laneId, latest.ref, envelope, questionIndexes)
|
|
934
1079
|
: undefined;
|
|
935
|
-
await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, {
|
|
1080
|
+
await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, {
|
|
1081
|
+
questionIds,
|
|
1082
|
+
signal,
|
|
1083
|
+
storyStatusSuppressions,
|
|
1084
|
+
}), meshRequestTimeoutMs);
|
|
936
1085
|
}
|
|
937
1086
|
else if (updated.state === "failed" || updated.state === "interrupted" ||
|
|
938
1087
|
updated.state === "cancelled" || updated.state === "killed") {
|
|
@@ -973,9 +1122,11 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
|
|
|
973
1122
|
const runDir = laneRunDir(hqRoot, laneId);
|
|
974
1123
|
const manifest = readSpawnManifest(runDir);
|
|
975
1124
|
const prior = lane.envelope_repair;
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1125
|
+
// A resumed worker can write its next envelope before spawn.json points to
|
|
1126
|
+
// that slot. Use the newest of the manifest's expected slot and the actual
|
|
1127
|
+
// latest file so an older manifest or repair record cannot spend its budget.
|
|
1128
|
+
const expectedRef = missingExpectedEnvelopeFile(hqRoot, laneId, manifest.envelope_path)?.ref;
|
|
1129
|
+
const repairRef = expectedRef !== undefined && expectedRef > latest.ref ? expectedRef : latest.ref;
|
|
979
1130
|
const priorAppliesToSlot = prior?.envelope_ref === undefined || prior.envelope_ref === repairRef;
|
|
980
1131
|
if (prior && prior.attempted && priorAppliesToSlot) {
|
|
981
1132
|
// The repair turn ran and its output does not parse either.
|
|
@@ -1025,6 +1176,14 @@ export function decideEnvelopeRepair(hqRoot, laneId, latest, nowMs, spawnRepair,
|
|
|
1025
1176
|
error,
|
|
1026
1177
|
rawText: latest.text,
|
|
1027
1178
|
missing: latest.missing,
|
|
1179
|
+
onProfileWarning: (fields) => {
|
|
1180
|
+
logDecision({
|
|
1181
|
+
event: "envelope_repair",
|
|
1182
|
+
at,
|
|
1183
|
+
outcome: "profile_unknown_meta_fields_ignored",
|
|
1184
|
+
fields: [...fields],
|
|
1185
|
+
});
|
|
1186
|
+
},
|
|
1028
1187
|
});
|
|
1029
1188
|
logDecision({ event: "envelope_repair", at, outcome: "spawned", mode, pid, session_id: sessionId, error });
|
|
1030
1189
|
return { action: "spawned" };
|