@indigoai-us/hq-cli 5.345.56 → 5.345.57
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 +6 -0
- package/dist/command-catalog.generated.d.ts +84 -0
- package/dist/command-catalog.generated.js +104 -0
- package/dist/commands/lanes-loop.d.ts +12 -0
- package/dist/commands/lanes-loop.js +214 -0
- package/dist/commands/lanes-questions.js +37 -11
- package/dist/commands/lanes-resume.js +19 -1
- package/dist/commands/lanes-shared.d.ts +12 -1
- package/dist/commands/lanes.js +262 -22
- package/dist/lib/lanes/delivery.d.ts +3 -0
- package/dist/lib/lanes/delivery.js +5 -0
- package/dist/lib/lanes/dropbox.d.ts +3 -0
- package/dist/lib/lanes/dropbox.js +103 -1
- package/dist/lib/lanes/flag-keys.d.ts +4 -0
- package/dist/lib/lanes/flag-keys.js +4 -0
- package/dist/lib/lanes/loop.d.ts +158 -0
- package/dist/lib/lanes/loop.js +743 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
- package/dist/lib/lanes/outpost-dispatch.js +21 -7
- package/dist/lib/lanes/reconcile.d.ts +19 -0
- package/dist/lib/lanes/reconcile.js +173 -6
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +10 -8
- package/dist/lib/lanes/store.js +49 -2
- package/dist/lib/lanes/types.d.ts +18 -0
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.js +3 -0
- package/package.json +1 -1
|
@@ -27,7 +27,7 @@ export interface OutpostDispatchResult {
|
|
|
27
27
|
stderr: string;
|
|
28
28
|
}
|
|
29
29
|
export interface OutpostLaneCommand {
|
|
30
|
-
action: "message" | "resume" | "stop" | "show" | "logs" | "grant" | "grant-target" | "reparent-target" | "approve-target" | "repair-company-target";
|
|
30
|
+
action: "message" | "resume" | "stop" | "show" | "logs" | "grant" | "grant-target" | "reparent-target" | "approve-target" | "repair-company-target" | "blocked-escalation-target";
|
|
31
31
|
laneId: string;
|
|
32
32
|
hqBinary?: string;
|
|
33
33
|
text?: string;
|
|
@@ -53,6 +53,8 @@ export interface OutpostLaneCommand {
|
|
|
53
53
|
json?: boolean;
|
|
54
54
|
callerLaneId?: string;
|
|
55
55
|
callerSessionId?: string;
|
|
56
|
+
messageId?: string;
|
|
57
|
+
recipientKind?: "lane" | "session";
|
|
56
58
|
}
|
|
57
59
|
export declare function buildOutpostQuestionAnswerCommand(input: {
|
|
58
60
|
questionId: string;
|
|
@@ -114,6 +116,12 @@ export declare function buildOutpostLegacyLaneUlidListCommand(input: {
|
|
|
114
116
|
/** Read questions on one Outpost, scoped to a company when supplied. */
|
|
115
117
|
export declare function buildOutpostQuestionListCommand(input?: {
|
|
116
118
|
company?: string;
|
|
119
|
+
sessionId?: string;
|
|
120
|
+
for?: string;
|
|
121
|
+
from?: string;
|
|
122
|
+
project?: string;
|
|
123
|
+
story?: string;
|
|
124
|
+
status?: string;
|
|
117
125
|
}): string;
|
|
118
126
|
/** Read one question's body and owner-computed audience from its Outpost. */
|
|
119
127
|
export declare function buildOutpostQuestionShowCommand(questionId: string): string;
|
|
@@ -121,10 +121,23 @@ export function buildOutpostLegacyLaneUlidListCommand(input) {
|
|
|
121
121
|
}
|
|
122
122
|
/** Read questions on one Outpost, scoped to a company when supplied. */
|
|
123
123
|
export function buildOutpostQuestionListCommand(input = {}) {
|
|
124
|
-
const args = [
|
|
124
|
+
const args = [
|
|
125
|
+
...(input.sessionId?.trim() ? ["env", `HQ_SESSION_ID=${input.sessionId.trim()}`] : []),
|
|
126
|
+
"hq", "lanes", "questions", "list",
|
|
127
|
+
];
|
|
125
128
|
const company = input.company?.trim();
|
|
126
129
|
if (company)
|
|
127
130
|
args.push("--company", company);
|
|
131
|
+
for (const [flag, value] of [
|
|
132
|
+
["--for", input.for],
|
|
133
|
+
["--from", input.from],
|
|
134
|
+
["--project", input.project],
|
|
135
|
+
["--story", input.story],
|
|
136
|
+
["--status", input.status],
|
|
137
|
+
]) {
|
|
138
|
+
if (value !== undefined)
|
|
139
|
+
args.push(flag, value);
|
|
140
|
+
}
|
|
128
141
|
return withRemoteHqDir(joinCommandParts(args));
|
|
129
142
|
}
|
|
130
143
|
/** Read one question's body and owner-computed audience from its Outpost. */
|
|
@@ -191,12 +204,12 @@ export function buildOutpostIdentityBackfillCommand(expectedId) {
|
|
|
191
204
|
}
|
|
192
205
|
/** Build the same `hq outposts exec` remote command used by lane pickup. */
|
|
193
206
|
export function buildOutpostLaneCommand(input) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
input.
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
const action = input.action === "blocked-escalation-target" ? "_enqueue-blocked-escalation" : input.action;
|
|
208
|
+
const args = [input.hqBinary?.trim() || "hq", "lanes", action];
|
|
209
|
+
if (input.action === "blocked-escalation-target")
|
|
210
|
+
args.push(input.recipientKind ?? "lane", input.laneId);
|
|
211
|
+
else
|
|
212
|
+
args.push(input.laneId);
|
|
200
213
|
for (const [flag, value] of [
|
|
201
214
|
["--text", input.text],
|
|
202
215
|
["--project", input.project],
|
|
@@ -214,6 +227,7 @@ export function buildOutpostLaneCommand(input) {
|
|
|
214
227
|
["--senior", input.senior],
|
|
215
228
|
["--reason", input.reason],
|
|
216
229
|
["--company", input.company],
|
|
230
|
+
["--message-id", input.messageId],
|
|
217
231
|
]) {
|
|
218
232
|
if (value !== undefined)
|
|
219
233
|
args.push(flag, value);
|
|
@@ -40,6 +40,20 @@ export interface ReconcileChange {
|
|
|
40
40
|
/** The attempted provider PATH when admission found no executable. */
|
|
41
41
|
searched_path?: string;
|
|
42
42
|
}
|
|
43
|
+
export interface BlockedLaneEscalationDelivery {
|
|
44
|
+
lane_id: string;
|
|
45
|
+
project_id: string;
|
|
46
|
+
story_id: string;
|
|
47
|
+
direct_senior: SeniorRef;
|
|
48
|
+
recipient: SeniorRef;
|
|
49
|
+
recipient_host?: LaneRecord["host"];
|
|
50
|
+
blocked_since: string;
|
|
51
|
+
at: string;
|
|
52
|
+
hours: number;
|
|
53
|
+
blocked_reason: string;
|
|
54
|
+
message_id: string;
|
|
55
|
+
text: string;
|
|
56
|
+
}
|
|
43
57
|
export interface ReconcileSummary {
|
|
44
58
|
ok: boolean;
|
|
45
59
|
action: "reconcile";
|
|
@@ -57,6 +71,7 @@ export interface ReconcileSummary {
|
|
|
57
71
|
id: string;
|
|
58
72
|
reason: KeepAliveBlockedReason;
|
|
59
73
|
}>;
|
|
74
|
+
blocked_escalations: Array<Pick<BlockedLaneEscalationDelivery, "lane_id" | "recipient" | "blocked_since" | "at" | "hours" | "blocked_reason" | "message_id" | "text">>;
|
|
60
75
|
errors: Array<{
|
|
61
76
|
message: string;
|
|
62
77
|
code?: string;
|
|
@@ -94,6 +109,10 @@ export interface ReconcileDeps {
|
|
|
94
109
|
skipAdmissionWithoutCpuSample?: boolean;
|
|
95
110
|
/** Flag reader seam used to assert rollout reads happen before admission locks. */
|
|
96
111
|
readRegistryFlag?: typeof readLanesRegistryFlag;
|
|
112
|
+
/** Durable delivery seam for six-hour blocked-lane escalation. */
|
|
113
|
+
deliverBlockedEscalation?: (delivery: BlockedLaneEscalationDelivery) => Promise<void>;
|
|
114
|
+
/** Read a missing escalation ancestor from the authoritative hierarchy. */
|
|
115
|
+
resolveEscalationLane?: (laneId: string) => Promise<LaneRecord | null>;
|
|
97
116
|
/** Active-company fallback for questions raised from an unscoped lane. */
|
|
98
117
|
resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
|
|
99
118
|
/** Resume queued messages after this pass wins a local dead-worker CAS. */
|
|
@@ -20,17 +20,18 @@ import { loadWorkerProfile } from "./profile.js";
|
|
|
20
20
|
import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./grants.js";
|
|
21
21
|
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
|
|
22
22
|
import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, nextEnvelopePath, readPidRecord, readProcStarttime, readSpawnManifest, markSpawnFailure, stopLaneProcesses, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
|
|
23
|
-
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, listQuestionIds, updateLane, updateQuestion, } from "./store.js";
|
|
23
|
+
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, retryUpdateLane, readQuestion, listQuestionIds, updateLane, updateQuestion, } from "./store.js";
|
|
24
24
|
import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyObservation, recordCpuBusyObservation, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
25
25
|
import { readLanesRegistryFlag, readLanesRegistryFlagFailOff, readLanesRegistryFlagFailOpen } from "./registry-flag.js";
|
|
26
|
-
import { 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";
|
|
26
|
+
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
27
|
import { checkLaneDiskSpace, configuredCodexRolloutHomes, laneDiskAccount, retainCodexRollouts } from "./disk-guard.js";
|
|
28
28
|
import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
|
|
29
|
-
import {
|
|
29
|
+
import { appendDropboxMessageOnce, appendDropboxMessageOnceAtPath } from "./dropbox.js";
|
|
30
|
+
import { monitorSessionDir } from "../monitor.js";
|
|
30
31
|
import { DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG, listHostProcesses, runRunawayToolGuard, RUNAWAY_TOOL_GUARD_FLAG, } from "./runaway-tool-guard.js";
|
|
31
32
|
import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG, LEAD_LANE_KEEP_ALIVE_IMMEDIATE_FIRST_RESUME_FLAG, LEAD_LANE_KEEP_ALIVE_FIRST_RESUME_SETTLE_MS, LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS, LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT, queueLaneTerminalResume, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, doneKeepAliveVerdict, dueKeepAliveNextCheckMs, hasPendingKeepAliveWork, keepAliveNextCheckAtMs, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, undeliveredAnsweredQuestions, } from "./keep-alive.js";
|
|
32
33
|
import { isFailedQuotaKeepAliveLane, providerSupportsSessionResume } from "./delivery.js";
|
|
33
|
-
import { isIsoDateTime } from "./types.js";
|
|
34
|
+
import { LANE_PROVIDERS, isIsoDateTime, isTerminalLaneState } from "./types.js";
|
|
34
35
|
import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, loadQuotaState, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
35
36
|
import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
36
37
|
import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
|
|
@@ -120,6 +121,7 @@ function emptySummary(dryRun) {
|
|
|
120
121
|
heartbeats: [],
|
|
121
122
|
skipped: [],
|
|
122
123
|
keep_alive_blocked: [],
|
|
124
|
+
blocked_escalations: [],
|
|
123
125
|
errors: [],
|
|
124
126
|
heartbeat_at: null,
|
|
125
127
|
};
|
|
@@ -193,6 +195,161 @@ export function currentHopStartedAtMs(question) {
|
|
|
193
195
|
const ms = Date.parse(raw);
|
|
194
196
|
return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
|
|
195
197
|
}
|
|
198
|
+
const BLOCKED_ESCALATION_WINDOW_MS = 6 * 60 * 60 * 1000;
|
|
199
|
+
const BLOCKED_ESCALATION_OPERATOR_SESSION = "fad349d4-38a6-4ae0-b8cc-e5dfd1bc3e9a";
|
|
200
|
+
function seniorKey(senior) {
|
|
201
|
+
return senior.id ? `${senior.kind}:${senior.id}` : senior.kind;
|
|
202
|
+
}
|
|
203
|
+
function recipientIsOnCurrentHost(recipientHost, currentHost) {
|
|
204
|
+
if (!recipientHost)
|
|
205
|
+
return false;
|
|
206
|
+
if (recipientHost.kind === "outpost")
|
|
207
|
+
return currentHost.outpostId === recipientHost.id;
|
|
208
|
+
return currentHost.outpostId === null;
|
|
209
|
+
}
|
|
210
|
+
async function reconcileBlockedEscalations(hqRoot, lanes, summary, deps, now) {
|
|
211
|
+
const env = deps.env ?? process.env;
|
|
212
|
+
const flagReader = deps.readRegistryFlag ?? readLanesRegistryFlagFailOpen;
|
|
213
|
+
const nowMs = now.getTime();
|
|
214
|
+
const findLane = async (laneId) => lanes.find((candidate) => candidate.lane_id === laneId) ??
|
|
215
|
+
(await deps.resolveEscalationLane?.(laneId)) ??
|
|
216
|
+
undefined;
|
|
217
|
+
for (const snapshot of lanes) {
|
|
218
|
+
if (snapshot.state !== "blocked" || !laneRunsHere(snapshot, deps.host))
|
|
219
|
+
continue;
|
|
220
|
+
if (lanes.some((child) => child.senior.kind === "lane" && child.senior.id === snapshot.lane_id && !isTerminalLaneState(child.state)))
|
|
221
|
+
continue;
|
|
222
|
+
let enabled;
|
|
223
|
+
try {
|
|
224
|
+
enabled = await flagReader(LANE_BLOCKED_ESCALATION_FLAG, {
|
|
225
|
+
company: resolveLaneCompany(hqRoot, snapshot).company,
|
|
226
|
+
env,
|
|
227
|
+
defaultValue: true,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
markError(summary, error);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!enabled)
|
|
235
|
+
continue;
|
|
236
|
+
let lane = readLane(hqRoot, snapshot.lane_id);
|
|
237
|
+
if (!lane.blocked_since) {
|
|
238
|
+
if (!deps.dryRun) {
|
|
239
|
+
lane = retryUpdateLane(hqRoot, lane.lane_id, "reconcile", (current) => current.state === "blocked" && !current.blocked_since
|
|
240
|
+
? { ...current, blocked_since: now.toISOString() }
|
|
241
|
+
: current);
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const blockedSinceMs = Date.parse(lane.blocked_since);
|
|
246
|
+
if (!Number.isFinite(blockedSinceMs) || nowMs - blockedSinceMs < BLOCKED_ESCALATION_WINDOW_MS)
|
|
247
|
+
continue;
|
|
248
|
+
const last = lane.escalations?.at(-1);
|
|
249
|
+
if (last && nowMs - Date.parse(last.at) < BLOCKED_ESCALATION_WINDOW_MS)
|
|
250
|
+
continue;
|
|
251
|
+
const directSenior = lane.senior;
|
|
252
|
+
let recipient = { kind: "session", id: BLOCKED_ESCALATION_OPERATOR_SESSION };
|
|
253
|
+
let recipientHost;
|
|
254
|
+
if (directSenior.kind === "lane" && directSenior.id) {
|
|
255
|
+
let seniorLane;
|
|
256
|
+
try {
|
|
257
|
+
seniorLane = await findLane(directSenior.id);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
markError(summary, error);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!seniorLane) {
|
|
264
|
+
markError(summary, new LanesError("blocked_escalation_ancestor_unavailable", `Could not resolve direct senior lane ${directSenior.id}`, {
|
|
265
|
+
lane_id: lane.lane_id,
|
|
266
|
+
senior_lane_id: directSenior.id,
|
|
267
|
+
}));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
recipient = seniorLane.senior;
|
|
271
|
+
if (recipient.kind === "lane" && recipient.id) {
|
|
272
|
+
try {
|
|
273
|
+
recipientHost = (await findLane(recipient.id))?.host;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
markError(summary, error);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!recipientHost) {
|
|
280
|
+
markError(summary, new LanesError("blocked_escalation_ancestor_unavailable", `Could not resolve escalation recipient lane ${recipient.id}`, {
|
|
281
|
+
lane_id: lane.lane_id,
|
|
282
|
+
recipient_lane_id: recipient.id,
|
|
283
|
+
}));
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else if (recipient.kind !== "session") {
|
|
288
|
+
recipient = { kind: "session", id: BLOCKED_ESCALATION_OPERATOR_SESSION };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (recipient.kind === "session")
|
|
292
|
+
recipientHost = { kind: "local", id: "primary" };
|
|
293
|
+
const hours = Math.round(((nowMs - blockedSinceMs) / 3600000) * 100) / 100;
|
|
294
|
+
const blockedReason = lane.last_envelope?.blocked_reason ?? lane.terminal_reason ?? "not recorded";
|
|
295
|
+
const messageId = `blocked-escalation-${lane.lane_id}-${Math.floor((nowMs - blockedSinceMs) / BLOCKED_ESCALATION_WINDOW_MS)}`;
|
|
296
|
+
const text = `Blocked lane escalation\nLane: ${lane.lane_id}\nStory: ${lane.story_id}\nDirect senior: ${seniorKey(directSenior)}\nBlocked since: ${lane.blocked_since}\nHours blocked: ${hours}\nLast blocked_reason: ${blockedReason}`;
|
|
297
|
+
const delivery = {
|
|
298
|
+
lane_id: lane.lane_id,
|
|
299
|
+
project_id: lane.project_id,
|
|
300
|
+
story_id: lane.story_id,
|
|
301
|
+
direct_senior: directSenior,
|
|
302
|
+
recipient,
|
|
303
|
+
recipient_host: recipientHost,
|
|
304
|
+
blocked_since: lane.blocked_since,
|
|
305
|
+
at: now.toISOString(),
|
|
306
|
+
hours,
|
|
307
|
+
blocked_reason: blockedReason,
|
|
308
|
+
message_id: messageId,
|
|
309
|
+
text,
|
|
310
|
+
};
|
|
311
|
+
summary.blocked_escalations.push(delivery);
|
|
312
|
+
if (deps.dryRun)
|
|
313
|
+
continue;
|
|
314
|
+
try {
|
|
315
|
+
const current = readLane(hqRoot, lane.lane_id);
|
|
316
|
+
if (current.state !== "blocked" ||
|
|
317
|
+
current.blocked_since !== lane.blocked_since ||
|
|
318
|
+
(current.escalations?.at(-1) !== undefined &&
|
|
319
|
+
nowMs - Date.parse(current.escalations.at(-1).at) < BLOCKED_ESCALATION_WINDOW_MS))
|
|
320
|
+
continue;
|
|
321
|
+
if (deps.deliverBlockedEscalation && !recipientIsOnCurrentHost(recipientHost, deps.host)) {
|
|
322
|
+
await deps.deliverBlockedEscalation(delivery);
|
|
323
|
+
}
|
|
324
|
+
else if (recipient.kind === "lane" && recipient.id) {
|
|
325
|
+
appendDropboxMessageOnce(hqRoot, recipient.id, { at: delivery.at, message_id: messageId, kind: "blocked_escalation", text });
|
|
326
|
+
}
|
|
327
|
+
else if (recipient.kind === "session" && recipient.id) {
|
|
328
|
+
const dirs = LANE_PROVIDERS.map((provider) => monitorSessionDir(hqRoot, provider, recipient.id))
|
|
329
|
+
.filter((dir) => fs.existsSync(dir));
|
|
330
|
+
if (dirs.length !== 1)
|
|
331
|
+
throw new LanesError("blocked_escalation_recipient_unavailable", `Expected one durable inbox for ${recipient.id}; found ${dirs.length}`);
|
|
332
|
+
appendDropboxMessageOnceAtPath(path.join(dirs[0], "dropbox"), { at: delivery.at, message_id: messageId, kind: "blocked_escalation", text });
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
throw new LanesError("blocked_escalation_recipient_invalid", `Cannot resolve escalation recipient ${seniorKey(recipient)}`);
|
|
336
|
+
}
|
|
337
|
+
retryUpdateLane(hqRoot, lane.lane_id, "reconcile", (latest) => {
|
|
338
|
+
if (latest.state !== "blocked" ||
|
|
339
|
+
latest.blocked_since !== lane.blocked_since ||
|
|
340
|
+
latest.escalations?.some((entry) => entry.at === delivery.at) ||
|
|
341
|
+
(latest.escalations?.at(-1) !== undefined &&
|
|
342
|
+
nowMs - Date.parse(latest.escalations.at(-1).at) < BLOCKED_ESCALATION_WINDOW_MS))
|
|
343
|
+
return latest;
|
|
344
|
+
const entry = { at: delivery.at, to: seniorKey(recipient), hours };
|
|
345
|
+
return { ...latest, escalations: [...(latest.escalations ?? []), entry] };
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
markError(summary, error);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
196
353
|
export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUESTION_AGE_MS, maxAgedAdvances = MAX_AGED_ADVANCES) {
|
|
197
354
|
if (question.status !== "pending")
|
|
198
355
|
return false;
|
|
@@ -203,6 +360,12 @@ export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUEST
|
|
|
203
360
|
const started = currentHopStartedAtMs(question);
|
|
204
361
|
if (started === Number.NEGATIVE_INFINITY)
|
|
205
362
|
return false;
|
|
363
|
+
if (question.hop === 1 && audience.kind === "session") {
|
|
364
|
+
const seenAt = question.seen_at ? Date.parse(question.seen_at) : Number.NaN;
|
|
365
|
+
if (!Number.isFinite(seenAt) || question.seen_session_id !== audience.id)
|
|
366
|
+
return false;
|
|
367
|
+
return nowMs - Math.max(started, seenAt) >= ageWindowMs;
|
|
368
|
+
}
|
|
206
369
|
return nowMs - started >= ageWindowMs;
|
|
207
370
|
}
|
|
208
371
|
export function shouldAdvanceAudienceEnded(audience, liveness) {
|
|
@@ -932,7 +1095,7 @@ async function runRunawayToolGuardPass(hqRoot, lanes, config, deps, now, summary
|
|
|
932
1095
|
((pid, expectedStarttime) => readProcStarttime(pid) === expectedStarttime),
|
|
933
1096
|
signal: deps.runawayToolProcessSignal ?? ((pid, signal) => process.kill(pid, signal)),
|
|
934
1097
|
recordEvent: (laneId, event) => appendWatcherEvent(watcherEventsPath(hqRoot, laneId), event, deps.runawayToolEventAppend),
|
|
935
|
-
notifyLane: (laneId, text) =>
|
|
1098
|
+
notifyLane: (laneId, text) => appendDropboxMessageOnce(hqRoot, laneId, {
|
|
936
1099
|
at: now.toISOString(),
|
|
937
1100
|
message_id: `runaway-search-guard:${laneId}:${now.getTime()}`,
|
|
938
1101
|
kind: "system_notice",
|
|
@@ -977,6 +1140,7 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
977
1140
|
if (deps.terminalKeepAliveCandidate) {
|
|
978
1141
|
return runTerminalKeepAliveCandidatePass(hqRoot, lanes, summary, deps, now, deps.terminalKeepAliveCandidate);
|
|
979
1142
|
}
|
|
1143
|
+
await reconcileBlockedEscalations(hqRoot, lanes, summary, deps, now);
|
|
980
1144
|
await resumeDueQuotaHeldLanes(hqRoot, lanes, summary, deps, now);
|
|
981
1145
|
let presenceConnected;
|
|
982
1146
|
try {
|
|
@@ -1134,7 +1298,8 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
1134
1298
|
markError(summary, err);
|
|
1135
1299
|
liveness = null;
|
|
1136
1300
|
}
|
|
1137
|
-
|
|
1301
|
+
const mustWaitForOperatorDelivery = question.hop === 1 && audience.kind === "session";
|
|
1302
|
+
if (!mustWaitForOperatorDelivery && shouldAdvanceAudienceEnded(audience, liveness)) {
|
|
1138
1303
|
const nextHop = question.hop + 1;
|
|
1139
1304
|
const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
|
|
1140
1305
|
const result = casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending" && current.hop === question.hop, (current) => {
|
|
@@ -1147,6 +1312,7 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
1147
1312
|
return {
|
|
1148
1313
|
...current,
|
|
1149
1314
|
hop: nextHop,
|
|
1315
|
+
delivered_at: null,
|
|
1150
1316
|
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
1151
1317
|
};
|
|
1152
1318
|
}, deps.dryRun);
|
|
@@ -1190,6 +1356,7 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
1190
1356
|
return {
|
|
1191
1357
|
...current,
|
|
1192
1358
|
hop: current.hop + 1,
|
|
1359
|
+
delivered_at: null,
|
|
1193
1360
|
routing_history: appendCappedHistory(current.routing_history, entry),
|
|
1194
1361
|
};
|
|
1195
1362
|
}, deps.dryRun);
|
|
@@ -92,6 +92,8 @@ export interface SpawnLaneDeps {
|
|
|
92
92
|
runCompaction?: RunCompactionFn;
|
|
93
93
|
/** Finds a session transcript under an account home. Injected in tests. */
|
|
94
94
|
locateTranscript?: (home: string, sessionId: string) => string | null;
|
|
95
|
+
/** Internal loop-lane override for the single provider turn's working directory. */
|
|
96
|
+
workingDirectory?: string;
|
|
95
97
|
}
|
|
96
98
|
export interface ProviderLaunch {
|
|
97
99
|
command: string;
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -1993,13 +1993,15 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1993
1993
|
// state and the caller sees the error. Admission and question resume keep
|
|
1994
1994
|
// today's behaviour of naming the terminal reason on the record.
|
|
1995
1995
|
const markPreSpawnFailure = trigger.kind !== "message" && trigger.kind !== "resume";
|
|
1996
|
-
const plan = pendingResume?.
|
|
1997
|
-
?
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
:
|
|
1996
|
+
const plan = pendingResume?.fresh_thread === true
|
|
1997
|
+
? { mode: "fresh" }
|
|
1998
|
+
: pendingResume?.trigger === "answered_questions"
|
|
1999
|
+
? pendingResume.session_id !== undefined && providerSupportsSessionResume(existing.provider)
|
|
2000
|
+
? { mode: "resume", session_id: pendingResume.session_id }
|
|
2001
|
+
: planLaneLaunch(existing, { kind: "answered_questions" })
|
|
2002
|
+
: pendingResume?.session_id !== undefined && providerSupportsSessionResume(existing.provider)
|
|
2003
|
+
? { mode: "resume", session_id: pendingResume.session_id }
|
|
2004
|
+
: planLaneLaunch(existing, trigger);
|
|
2003
2005
|
if (existing.host.kind === "outpost") {
|
|
2004
2006
|
refuseOutpostSelfTarget(hqRoot, existing, deps);
|
|
2005
2007
|
}
|
|
@@ -2602,7 +2604,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2602
2604
|
let workerPid;
|
|
2603
2605
|
let watcherPid;
|
|
2604
2606
|
try {
|
|
2605
|
-
workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
|
|
2607
|
+
workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, deps.workingDirectory ?? pendingResume?.working_directory ?? hqRoot, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
|
|
2606
2608
|
writePidRecord(path.join(runDir, "worker.pid"), {
|
|
2607
2609
|
pid: workerPid,
|
|
2608
2610
|
starttime: readProcStarttime(workerPid) ?? undefined,
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -537,6 +537,7 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
537
537
|
});
|
|
538
538
|
}
|
|
539
539
|
for (const field of [
|
|
540
|
+
"blocked_since",
|
|
540
541
|
"keep_alive_opted_in_at",
|
|
541
542
|
"keep_alive_last_resume_at",
|
|
542
543
|
"keep_alive_next_eligible_at",
|
|
@@ -547,6 +548,24 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
547
548
|
if (raw[field] !== undefined)
|
|
548
549
|
requireTimestamp(raw[field], field, filePath);
|
|
549
550
|
}
|
|
551
|
+
if (raw.escalations !== undefined) {
|
|
552
|
+
if (!Array.isArray(raw.escalations)) {
|
|
553
|
+
throw new LaneSchemaError("escalations must be an array", { path: filePath, field: "escalations" });
|
|
554
|
+
}
|
|
555
|
+
for (const [index, value] of raw.escalations.entries()) {
|
|
556
|
+
const field = `escalations[${index}]`;
|
|
557
|
+
if (!isPlainObject(value) || Object.keys(value).some((key) => !["at", "to", "hours"].includes(key))) {
|
|
558
|
+
throw new LaneSchemaError(`${field} must contain only at, to, and hours`, { path: filePath, field });
|
|
559
|
+
}
|
|
560
|
+
requireTimestamp(value.at, `${field}.at`, filePath);
|
|
561
|
+
if (typeof value.to !== "string" || value.to.trim() === "") {
|
|
562
|
+
throw new LaneSchemaError(`${field}.to must be a non-empty string`, { path: filePath, field: `${field}.to` });
|
|
563
|
+
}
|
|
564
|
+
if (typeof value.hours !== "number" || !Number.isFinite(value.hours) || value.hours < 6) {
|
|
565
|
+
throw new LaneSchemaError(`${field}.hours must be a finite number of at least 6`, { path: filePath, field: `${field}.hours` });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
550
569
|
requireString(raw.project_id, "project_id");
|
|
551
570
|
if (typeof raw.story_id !== "string" ||
|
|
552
571
|
(raw.story_id.trim() === "" && raw.no_story_reason === undefined)) {
|
|
@@ -1165,9 +1184,27 @@ export function parseQuestionRecord(raw, filePath) {
|
|
|
1165
1184
|
value: delivered_at,
|
|
1166
1185
|
});
|
|
1167
1186
|
}
|
|
1187
|
+
const seen_at = raw.seen_at === undefined ? null : raw.seen_at;
|
|
1188
|
+
if (seen_at !== null &&
|
|
1189
|
+
(typeof seen_at !== "string" || !Number.isFinite(Date.parse(seen_at)))) {
|
|
1190
|
+
throw new LaneSchemaError("seen_at must be a valid timestamp or null", {
|
|
1191
|
+
path: filePath,
|
|
1192
|
+
value: seen_at,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
const seen_session_id = raw.seen_session_id === undefined ? null : raw.seen_session_id;
|
|
1196
|
+
if (seen_session_id !== null &&
|
|
1197
|
+
(typeof seen_session_id !== "string" || !seen_session_id.trim())) {
|
|
1198
|
+
throw new LaneSchemaError("seen_session_id must be a non-empty string or null", {
|
|
1199
|
+
path: filePath,
|
|
1200
|
+
value: seen_session_id,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1168
1203
|
return {
|
|
1169
1204
|
...raw,
|
|
1170
1205
|
delivered_at,
|
|
1206
|
+
seen_at,
|
|
1207
|
+
seen_session_id,
|
|
1171
1208
|
...(raw.priority === null ? { priority: undefined } : {}),
|
|
1172
1209
|
};
|
|
1173
1210
|
}
|
|
@@ -1362,12 +1399,15 @@ export function mergeQuestionRecords(live, sidecars) {
|
|
|
1362
1399
|
const all = [live, ...sidecars];
|
|
1363
1400
|
const base = higherVersionRecord(live, sidecars);
|
|
1364
1401
|
const deliveredAt = all
|
|
1402
|
+
.filter((record) => record.status === "answered")
|
|
1365
1403
|
.map((record) => record.delivered_at)
|
|
1366
1404
|
.filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value)))
|
|
1367
1405
|
.sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? null;
|
|
1368
1406
|
return {
|
|
1369
1407
|
...base,
|
|
1370
1408
|
delivered_at: deliveredAt,
|
|
1409
|
+
seen_at: base.seen_at ?? null,
|
|
1410
|
+
seen_session_id: base.seen_session_id ?? null,
|
|
1371
1411
|
answer_history: unionHistory(all.map(questionAnswerHistory), entryIdentity, questionAnswerHistory(base)),
|
|
1372
1412
|
routing_history: unionHistory(all.map((record) => record.routing_history), entryIdentity, base.routing_history),
|
|
1373
1413
|
};
|
|
@@ -1631,14 +1671,19 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
|
|
|
1631
1671
|
// `current` is the pre-update snapshot and stays untouched: the updater
|
|
1632
1672
|
// receives a deep clone, so mutating its argument in place cannot reach
|
|
1633
1673
|
// the record the ownership check compares against.
|
|
1634
|
-
const
|
|
1674
|
+
const proposed = updater(structuredClone(current));
|
|
1675
|
+
const updated = current.state === "blocked" && proposed.state !== "blocked"
|
|
1676
|
+
? { ...proposed, blocked_since: undefined }
|
|
1677
|
+
: proposed;
|
|
1635
1678
|
for (const key of Object.keys(updated)) {
|
|
1636
1679
|
if (key === "version" || key === "updated_at" || fieldUnchanged(current[key], updated[key]))
|
|
1637
1680
|
continue;
|
|
1638
1681
|
const allowedWriters = LANE_FIELD_WRITERS[key];
|
|
1682
|
+
const transitionClearedBlockedSince = key === "blocked_since" &&
|
|
1683
|
+
current.state === "blocked" && updated.state !== "blocked" && updated.blocked_since === undefined;
|
|
1639
1684
|
// Unknown forward-version fields are preserved, never assigned an
|
|
1640
1685
|
// ownership rule by a v1 reader.
|
|
1641
|
-
if (allowedWriters && !allowedWriters.includes(writer)) {
|
|
1686
|
+
if (allowedWriters && !allowedWriters.includes(writer) && !transitionClearedBlockedSince) {
|
|
1642
1687
|
throw new LanesError("lane_writer_unauthorized", `Writer ${writer} cannot write lane field ${key}`, { writer, field: key });
|
|
1643
1688
|
}
|
|
1644
1689
|
}
|
|
@@ -1909,6 +1954,8 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
1909
1954
|
schema_version: init.schema_version ?? QUESTION_SCHEMA_VERSION,
|
|
1910
1955
|
question_id,
|
|
1911
1956
|
delivered_at: init.delivered_at ?? null,
|
|
1957
|
+
seen_at: init.seen_at ?? null,
|
|
1958
|
+
seen_session_id: init.seen_session_id ?? null,
|
|
1912
1959
|
version: 1,
|
|
1913
1960
|
routing_history: init.routing_history ?? [],
|
|
1914
1961
|
};
|
|
@@ -546,6 +546,10 @@ export interface LaneRecord {
|
|
|
546
546
|
senior: SeniorRef;
|
|
547
547
|
senior_history: SeniorHistoryEntry[];
|
|
548
548
|
state: LaneState;
|
|
549
|
+
/** Start of the current uninterrupted blocked episode; never inferred from an envelope. */
|
|
550
|
+
blocked_since?: string;
|
|
551
|
+
/** Durable six-hour blocked-lane escalation history. */
|
|
552
|
+
escalations?: LaneEscalationEntry[];
|
|
549
553
|
queued_reason?: QueuedReason;
|
|
550
554
|
queued_details?: QueuedDetails;
|
|
551
555
|
/** Time the current queue turn began; old records fall back to created_at. */
|
|
@@ -573,6 +577,8 @@ export interface LaneRecord {
|
|
|
573
577
|
keep_alive?: boolean;
|
|
574
578
|
/** Whether completed keep-alive stories wait for queued work after a done turn. */
|
|
575
579
|
keep_alive_standing?: boolean;
|
|
580
|
+
/** Conduct loop lanes keep one registered worker identity while phases are queued. */
|
|
581
|
+
loop_mode?: true;
|
|
576
582
|
/** Time this lane most recently explicitly opted into keep-alive. */
|
|
577
583
|
keep_alive_opted_in_at?: string;
|
|
578
584
|
/** Why reconcile declined to resume this lane most recently. */
|
|
@@ -765,6 +771,11 @@ export interface LaneRecord {
|
|
|
765
771
|
/** Last successful lane_progress mesh delivery, preserved across watcher restarts. */
|
|
766
772
|
last_progress_event_at?: string;
|
|
767
773
|
}
|
|
774
|
+
export interface LaneEscalationEntry {
|
|
775
|
+
at: string;
|
|
776
|
+
to: string;
|
|
777
|
+
hours: number;
|
|
778
|
+
}
|
|
768
779
|
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
769
780
|
* `company` is normalized from absence for pre-attribution legacy records. */
|
|
770
781
|
export declare const REQUIRED_LANE_RECORD_FIELDS: readonly ["schema_version", "lane_id", "project_id", "story_id", "depth", "senior", "senior_history", "state", "priority", "provider", "host", "version", "created_at", "updated_at", "attribution_history"];
|
|
@@ -809,6 +820,10 @@ export interface PendingResume {
|
|
|
809
820
|
trigger: "answered_questions" | "message" | "monitor_event" | "system_notice" | "resume" | "watcher";
|
|
810
821
|
/** True when this queued resume was initiated automatically by keep-alive. */
|
|
811
822
|
keep_alive?: true;
|
|
823
|
+
/** Working directory for a deferred loop phase. */
|
|
824
|
+
working_directory?: string;
|
|
825
|
+
/** Start this deferred conduct phase with a fresh provider session. */
|
|
826
|
+
fresh_thread?: true;
|
|
812
827
|
resumed_from?: "interrupted" | "done" | "blocked" | "failed" | "awaiting_input";
|
|
813
828
|
from_failed?: true;
|
|
814
829
|
/** Senior that authorized a failed-lane resume, rechecked at the claim CAS. */
|
|
@@ -930,6 +945,9 @@ export interface QuestionRecord {
|
|
|
930
945
|
version: number;
|
|
931
946
|
/** Null until the owner starts a resume carrying this answer; absent on old records. */
|
|
932
947
|
delivered_at?: string | null;
|
|
948
|
+
/** When the current session audience last saw this pending question. */
|
|
949
|
+
seen_at?: string | null;
|
|
950
|
+
seen_session_id?: string | null;
|
|
933
951
|
body: QuestionBodyRef;
|
|
934
952
|
routing_history: RoutingHistoryEntry[];
|
|
935
953
|
answer?: QuestionAnswer;
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -230,11 +230,11 @@ export const LANE_FIELD_WRITERS = {
|
|
|
230
230
|
depth: ["command"], senior: ["command"], senior_history: ["command"],
|
|
231
231
|
hire_grant: ["creator", "command"],
|
|
232
232
|
released_hire_grant: ["command"],
|
|
233
|
-
state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "watcher", "reconcile"], queued_details: ["command", "reconcile", "spawn", "watcher"], queued_at: ["creator", "command", "spawn", "watcher", "reconcile"], admission_reserved_at: ["creator", "command", "reconcile", "spawn", "watcher"],
|
|
233
|
+
state: ["creator", "command", "watcher", "reconcile", "spawn"], blocked_since: ["watcher", "reconcile"], escalations: ["reconcile"], queued_reason: ["creator", "command", "spawn", "watcher", "reconcile"], queued_details: ["command", "reconcile", "spawn", "watcher"], queued_at: ["creator", "command", "spawn", "watcher", "reconcile"], admission_reserved_at: ["creator", "command", "reconcile", "spawn", "watcher"],
|
|
234
234
|
pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
|
|
235
235
|
pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
|
|
236
236
|
priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], registry_worker: ["creator", "command", "spawn"], registry_worker_approval_history: ["command", "watcher", "reconcile"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
|
|
237
|
-
keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile", "spawn"], keep_alive_history: ["reconcile"], keep_alive_blocked_resume_streak: ["reconcile", "spawn", "watcher"], keep_alive_last_resume_at: ["command", "reconcile", "spawn"], keep_alive_next_eligible_at: ["command", "reconcile", "spawn"], keep_alive_exhausted_at: ["command", "reconcile", "spawn"],
|
|
237
|
+
keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile", "spawn"], keep_alive_history: ["reconcile"], keep_alive_blocked_resume_streak: ["reconcile", "spawn", "watcher"], keep_alive_last_resume_at: ["command", "reconcile", "spawn"], keep_alive_next_eligible_at: ["command", "reconcile", "spawn"], keep_alive_exhausted_at: ["command", "reconcile", "spawn"], loop_mode: ["creator"],
|
|
238
238
|
provider: ["creator", "command"], provider_source: ["creator", "command"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_source: ["creator", "command"], profile_settings_pinned: ["command"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], fallback_question_cancellation_failures: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
|
|
239
239
|
version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command", "watcher", "reconcile"], story_switch_pending: ["command", "watcher", "reconcile"], story_switch_unacknowledged: ["watcher", "reconcile"],
|
|
240
240
|
last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
|
|
@@ -469,6 +469,9 @@ function applyTerminalToLaneWithResult(hqRoot, laneId, classification, opts = {}
|
|
|
469
469
|
...current,
|
|
470
470
|
...runResultFields(hqRoot, current, at),
|
|
471
471
|
state: effectiveClassification.state,
|
|
472
|
+
blocked_since: effectiveClassification.state === "blocked"
|
|
473
|
+
? current.state === "blocked" ? current.blocked_since ?? at : at
|
|
474
|
+
: undefined,
|
|
472
475
|
queued_reason: undefined,
|
|
473
476
|
queued_details: undefined,
|
|
474
477
|
stalled: undefined,
|