@indigoai-us/hq-cli 5.131.0 → 5.133.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 +147 -0
- package/dist/command-catalog.generated.d.ts +12 -0
- package/dist/command-catalog.generated.js +16 -0
- package/dist/commands/lanes.d.ts +15 -4
- package/dist/commands/lanes.js +478 -48
- package/dist/commands/mesh.js +24 -2
- package/dist/commands/update.js +1 -1
- package/dist/lib/bot/paths.d.ts +7 -5
- package/dist/lib/bot/paths.js +8 -1
- package/dist/lib/bot/prompt.d.ts +0 -2
- package/dist/lib/bot/prompt.js +5 -11
- package/dist/lib/bot/room-policy.d.ts +1 -24
- package/dist/lib/bot/room-policy.js +2 -35
- package/dist/lib/bot/run.d.ts +7 -8
- package/dist/lib/bot/run.js +21 -33
- package/dist/lib/bot/session.d.ts +1 -1
- package/dist/lib/bot/session.js +1 -1
- package/dist/lib/lanes/accounts.d.ts +6 -0
- package/dist/lib/lanes/accounts.js +2 -1
- package/dist/lib/lanes/config.d.ts +4 -0
- package/dist/lib/lanes/config.js +9 -0
- package/dist/lib/lanes/constants.d.ts +3 -0
- package/dist/lib/lanes/constants.js +3 -0
- package/dist/lib/lanes/index.d.ts +11 -3
- package/dist/lib/lanes/index.js +6 -1
- package/dist/lib/lanes/mesh-events.js +4 -0
- package/dist/lib/lanes/models.d.ts +78 -0
- package/dist/lib/lanes/models.js +205 -0
- package/dist/lib/lanes/move-account.d.ts +56 -0
- package/dist/lib/lanes/move-account.js +193 -0
- package/dist/lib/lanes/provider-rejection.d.ts +86 -0
- package/dist/lib/lanes/provider-rejection.js +258 -0
- package/dist/lib/lanes/quota.d.ts +23 -3
- package/dist/lib/lanes/quota.js +88 -14
- package/dist/lib/lanes/reconcile-install.d.ts +24 -3
- package/dist/lib/lanes/reconcile-install.js +48 -12
- package/dist/lib/lanes/reconcile.d.ts +5 -2
- package/dist/lib/lanes/reconcile.js +135 -13
- package/dist/lib/lanes/spawn.js +26 -5
- package/dist/lib/lanes/store.js +10 -0
- package/dist/lib/lanes/types.d.ts +51 -2
- package/dist/lib/lanes/types.js +5 -3
- package/dist/lib/lanes/watcher.d.ts +27 -0
- package/dist/lib/lanes/watcher.js +68 -5
- package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
- package/dist/lib/mesh/live/backfill-held.js +37 -14
- package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/doctor.js +16 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
- package/dist/lib/mesh/live/daemon/install.js +100 -7
- package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
- package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
- package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
- package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
- package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
- package/dist/lib/mesh/live/daemon/presence.js +10 -9
- package/dist/lib/mesh/live/daemon/run.d.ts +18 -0
- package/dist/lib/mesh/live/daemon/run.js +65 -6
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
- package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
- package/dist/lib/mesh/live/emit-evidence.js +20 -2
- package/dist/lib/work-context/company.d.ts +16 -0
- package/dist/lib/work-context/company.js +46 -14
- package/dist/utils/self-update.js +4 -1
- package/package.json +2 -2
|
@@ -8,25 +8,30 @@
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
11
|
-
import { accountMaxRunning, loadLanesConfig } from "./config.js";
|
|
11
|
+
import { accountMaxRunning, loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
|
|
12
|
+
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
12
13
|
import { LanesError, isLaneVersionConflict } from "./errors.js";
|
|
13
14
|
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, } from "./liveness.js";
|
|
14
15
|
import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, spawnEnvelopeRepair, spawnLaneWorker, } from "./spawn.js";
|
|
15
16
|
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
16
17
|
import { applyThrottleDecision, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
17
|
-
import { readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
18
|
+
import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
18
19
|
import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
|
|
19
20
|
import { SILENCE_MS, classifyLaneExit, isPidAlive, readLatestEnvelope, readLatestEnvelopeFile, readLatestEnvelopeRecord, } from "./watcher.js";
|
|
20
21
|
import { createLaneMesh, emitLaneDecisionEvents } from "./mesh-events.js";
|
|
21
|
-
import { decideEnvelopeRepair, envelopeQuestionIds } from "./watcher.js";
|
|
22
|
+
import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
|
|
23
|
+
import { providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
|
|
22
24
|
/** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
|
|
23
25
|
export const QUESTION_AGE_MS = 60 * 60 * 1000;
|
|
24
|
-
/** Spec §6.6 + §9:
|
|
25
|
-
export const MAX_AGED_ADVANCES = 1;
|
|
26
|
+
/** Spec §6.6 + §9: a depth-3 worker has two hops to age toward the user. */
|
|
27
|
+
export const MAX_AGED_ADVANCES = MAX_LANE_DEPTH - 1;
|
|
26
28
|
/** Aligns with watcher silence: a host that has not reported for this long is dead. */
|
|
27
29
|
export const DEAD_HOST_MS = SILENCE_MS;
|
|
28
30
|
export const RECONCILE_LOCK_STALE_MS = 30_000;
|
|
29
31
|
export const RECONCILE_LOCK_TIMEOUT_MS = 5_000;
|
|
32
|
+
/** A pickup probe that has not cleared its marker within this window is abandoned. */
|
|
33
|
+
export const PICKUP_VALIDATION_MAX_AGE_MS = 15 * 60 * 1000;
|
|
34
|
+
export const PICKUP_VALIDATION_TIMEOUT_REASON = "pickup_validation_timeout";
|
|
30
35
|
export const RECONCILE_UNIT_NAME = "hq-lanes-reconcile.service";
|
|
31
36
|
export const RECONCILE_TIMER_NAME = "hq-lanes-reconcile.timer";
|
|
32
37
|
function logSkip(skip) {
|
|
@@ -308,6 +313,89 @@ function casQuestionIf(hqRoot, questionId, shouldWrite, updater, dryRun) {
|
|
|
308
313
|
}
|
|
309
314
|
throw new LanesError("version_conflict", `Exhausted CAS retries for question ${questionId}`, { question_id: questionId });
|
|
310
315
|
}
|
|
316
|
+
async function emitAgedQuestionAtUser(summary, deps, lane, question) {
|
|
317
|
+
if (deps.dryRun)
|
|
318
|
+
return;
|
|
319
|
+
const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
|
|
320
|
+
if (!mesh || !lane.company || !lane.thread_id)
|
|
321
|
+
return;
|
|
322
|
+
const timeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
|
|
323
|
+
let timer;
|
|
324
|
+
try {
|
|
325
|
+
await Promise.race([
|
|
326
|
+
mesh.appendThreadEvent(lane.company, lane.thread_id, "lane_question_at_user", { lane_id: lane.lane_id, question_id: question.question_id }),
|
|
327
|
+
new Promise((_resolve, reject) => {
|
|
328
|
+
timer = setTimeout(() => reject(new Error(`question-at-user mesh emit timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
329
|
+
}),
|
|
330
|
+
]);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
markDirty(summary, {
|
|
334
|
+
kind: "question",
|
|
335
|
+
id: question.question_id,
|
|
336
|
+
why: `question-at-user mesh emit failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
if (timer)
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function pickupValidationPendingAtMs(lane) {
|
|
345
|
+
const raw = lane.pickup_validation_pending_since ?? lane.updated_at;
|
|
346
|
+
const at = Date.parse(raw);
|
|
347
|
+
return Number.isFinite(at) ? at : null;
|
|
348
|
+
}
|
|
349
|
+
function pickupValidationExpired(lane, nowMs) {
|
|
350
|
+
const pendingAtMs = pickupValidationPendingAtMs(lane);
|
|
351
|
+
return pendingAtMs === null || nowMs - pendingAtMs > PICKUP_VALIDATION_MAX_AGE_MS;
|
|
352
|
+
}
|
|
353
|
+
function expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now) {
|
|
354
|
+
const nowMs = now.getTime();
|
|
355
|
+
for (const lane of lanes) {
|
|
356
|
+
if (lane.state !== "queued" ||
|
|
357
|
+
lane.pickup_validation_pending !== true ||
|
|
358
|
+
!laneRunsHere(lane, deps.host) ||
|
|
359
|
+
!pickupValidationExpired(lane, nowMs)) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const result = casLaneIf(hqRoot, lane.lane_id, (current) => current.state === "queued" &&
|
|
364
|
+
current.pickup_validation_pending === true &&
|
|
365
|
+
pickupValidationExpired(current, nowMs), (current) => {
|
|
366
|
+
const pendingAtMs = pickupValidationPendingAtMs(current);
|
|
367
|
+
return {
|
|
368
|
+
...current,
|
|
369
|
+
state: "failed",
|
|
370
|
+
queued_reason: undefined,
|
|
371
|
+
pickup_validation_pending: undefined,
|
|
372
|
+
pickup_validation_pending_since: undefined,
|
|
373
|
+
terminal_reason: PICKUP_VALIDATION_TIMEOUT_REASON,
|
|
374
|
+
terminal_details: {
|
|
375
|
+
pickup_validation_pending_since: current.pickup_validation_pending_since ?? current.updated_at,
|
|
376
|
+
age_ms: pendingAtMs === null ? null : Math.max(0, nowMs - pendingAtMs),
|
|
377
|
+
max_age_ms: PICKUP_VALIDATION_MAX_AGE_MS,
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}, deps.dryRun);
|
|
381
|
+
if (result.changed) {
|
|
382
|
+
summary.aged.push({
|
|
383
|
+
id: lane.lane_id,
|
|
384
|
+
from: "queued",
|
|
385
|
+
to: "failed",
|
|
386
|
+
reason: PICKUP_VALIDATION_TIMEOUT_REASON,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
markDirty(summary, {
|
|
392
|
+
kind: "lane",
|
|
393
|
+
id: lane.lane_id,
|
|
394
|
+
why: `pickup validation expiry failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
311
399
|
function fileNonEmpty(filePath) {
|
|
312
400
|
try {
|
|
313
401
|
return fs.statSync(filePath).size > 0;
|
|
@@ -431,13 +519,29 @@ function classifyDeadLocalLane(hqRoot, laneId) {
|
|
|
431
519
|
path.join(runDir, "logs", "last-message.txt"),
|
|
432
520
|
].some((file) => fileNonEmpty(file));
|
|
433
521
|
const lane = readLane(hqRoot, laneId);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
522
|
+
// The same routing as the watcher's exit path (watcher.ts): a provider
|
|
523
|
+
// failure on the stream is read first, a 429 or quota-worded one becomes
|
|
524
|
+
// the quota signal, any other becomes provider_rejected, and only a stream
|
|
525
|
+
// with no failure object falls back to the quota reader.
|
|
526
|
+
const workerOutOffset = readSpawnManifest(runDir).worker_out_offset ?? 0;
|
|
527
|
+
const now = new Date();
|
|
528
|
+
const routed = envelope
|
|
529
|
+
? { quota: null, rejection: null }
|
|
530
|
+
: routeProviderFailure(readProviderRejection(runDir, lane.provider, workerOutOffset), () => readQuotaSignal(runDir, lane.model, now, workerOutOffset), lane.model, now);
|
|
531
|
+
// As in the watcher: a reset-less 429 gets the provider's default hold.
|
|
532
|
+
const quota = routed.quota
|
|
533
|
+
? applyDefaultQuotaHold(routed.quota, providerQuotaDefaultHoldMs(loadLanesConfig(hqRoot).providers[lane.provider]), now)
|
|
534
|
+
: null;
|
|
437
535
|
return {
|
|
438
|
-
classification: classifyLaneExit({
|
|
536
|
+
classification: classifyLaneExit({
|
|
537
|
+
envelope,
|
|
538
|
+
hasPartialOutput,
|
|
539
|
+
quota,
|
|
540
|
+
providerRejection: routed.rejection,
|
|
541
|
+
}),
|
|
439
542
|
latest,
|
|
440
543
|
quota,
|
|
544
|
+
providerRejection: routed.rejection,
|
|
441
545
|
lane,
|
|
442
546
|
};
|
|
443
547
|
}
|
|
@@ -572,6 +676,10 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
572
676
|
to: String(nextHop),
|
|
573
677
|
reason: "unanswered_timeout",
|
|
574
678
|
});
|
|
679
|
+
if (nextAudience.kind === "user") {
|
|
680
|
+
const originLane = laneById.get(question.lane_id) ?? readLane(hqRoot, question.lane_id);
|
|
681
|
+
await emitAgedQuestionAtUser(summary, deps, originLane, result.record);
|
|
682
|
+
}
|
|
575
683
|
}
|
|
576
684
|
}
|
|
577
685
|
}
|
|
@@ -722,12 +830,17 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
722
830
|
continue;
|
|
723
831
|
}
|
|
724
832
|
try {
|
|
725
|
-
const { classification, latest, quota, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id);
|
|
833
|
+
const { classification, latest, quota, providerRejection, lane: classified } = classifyDeadLocalLane(hqRoot, lane.lane_id);
|
|
726
834
|
// Match the watcher's one-repair decision before classifying an
|
|
727
|
-
// unparseable envelope after both processes have died.
|
|
835
|
+
// unparseable envelope after both processes have died. As in the
|
|
836
|
+
// watcher, a provider rejection or a quota signal skips the repair.
|
|
728
837
|
const rawLatest = readLatestEnvelopeFile(hqRoot, lane.lane_id);
|
|
729
838
|
let invalidEnvelope = null;
|
|
730
|
-
if (!deps.dryRun &&
|
|
839
|
+
if (!deps.dryRun &&
|
|
840
|
+
!providerRejection &&
|
|
841
|
+
!quota &&
|
|
842
|
+
rawLatest &&
|
|
843
|
+
rawLatest.envelope === null) {
|
|
731
844
|
const repair = decideEnvelopeRepair(hqRoot, lane.lane_id, rawLatest, nowMs, deps.spawnRepair ?? spawnEnvelopeRepair, () => undefined);
|
|
732
845
|
if (repair.action === "spawned") {
|
|
733
846
|
armLaneWatcher(hqRoot, lane.lane_id, deps.spawnLaneDeps);
|
|
@@ -757,6 +870,9 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
757
870
|
state: repairedClassification.state,
|
|
758
871
|
queued_reason: undefined,
|
|
759
872
|
terminal_reason: repairedClassification.reason ?? "host_lost",
|
|
873
|
+
...(providerRejection
|
|
874
|
+
? { terminal_details: providerRejectionDetails(providerRejection, current) }
|
|
875
|
+
: {}),
|
|
760
876
|
...(quota?.quota_reset_at ? { quota_reset_at: quota.quota_reset_at } : {}),
|
|
761
877
|
...(quota?.marker ? { model_quota_marker: quota.marker } : {}),
|
|
762
878
|
last_host_report_at: nowIso,
|
|
@@ -837,8 +953,14 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
837
953
|
}
|
|
838
954
|
}
|
|
839
955
|
async function admitQueued(hqRoot, lanes, summary, deps, now) {
|
|
956
|
+
expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now);
|
|
957
|
+
// A lane whose pickup probe is still out is not admitted: its record is
|
|
958
|
+
// about to be bound or failed by the pickup, and a worker spawned now
|
|
959
|
+
// would keep running behind that write.
|
|
840
960
|
const localQueued = lanes
|
|
841
|
-
.filter((lane) => lane.state === "queued" &&
|
|
961
|
+
.filter((lane) => lane.state === "queued" &&
|
|
962
|
+
lane.pickup_validation_pending !== true &&
|
|
963
|
+
laneRunsHere(lane, deps.host))
|
|
842
964
|
.sort(compareQueueOrder);
|
|
843
965
|
if (localQueued.length === 0)
|
|
844
966
|
return;
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -979,13 +979,19 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
979
979
|
}
|
|
980
980
|
return existing;
|
|
981
981
|
}
|
|
982
|
+
// A queued lane that a move parked at the target's cap carries the turn
|
|
983
|
+
// it was resumed with: the admit spawn continues that session instead of
|
|
984
|
+
// starting fresh from the brief.
|
|
985
|
+
const pendingResume = launch.trigger === undefined && existing.state === "queued" ? existing.pending_resume : undefined;
|
|
982
986
|
const trigger = launch.trigger ?? inferTrigger(existing);
|
|
983
987
|
// A message into a finished lane must not turn that lane `failed` when the
|
|
984
988
|
// account cannot be bound: nothing has started yet, so the record keeps its
|
|
985
989
|
// state and the caller sees the error. Admission and question resume keep
|
|
986
990
|
// today's behaviour of naming the terminal reason on the record.
|
|
987
991
|
const markPreSpawnFailure = trigger.kind !== "message" && trigger.kind !== "resume";
|
|
988
|
-
const plan =
|
|
992
|
+
const plan = pendingResume !== undefined && providerSupportsSessionResume(existing.provider)
|
|
993
|
+
? { mode: "resume", session_id: pendingResume.session_id }
|
|
994
|
+
: planLaneLaunch(existing, trigger);
|
|
989
995
|
if (existing.host.kind === "outpost") {
|
|
990
996
|
refuseOutpostSelfTarget(hqRoot, existing, deps);
|
|
991
997
|
}
|
|
@@ -1065,8 +1071,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1065
1071
|
? trigger.text
|
|
1066
1072
|
: trigger.kind === "resume"
|
|
1067
1073
|
? trigger.text
|
|
1068
|
-
:
|
|
1069
|
-
resumedFrom: trigger.kind === "resume" ? trigger.from :
|
|
1074
|
+
: pendingResume?.text,
|
|
1075
|
+
resumedFrom: trigger.kind === "resume" ? trigger.from : pendingResume?.resumed_from,
|
|
1070
1076
|
answersPath: trigger.kind === "answered_questions" && existsSync(answersPath)
|
|
1071
1077
|
? answersPath
|
|
1072
1078
|
: undefined,
|
|
@@ -1119,7 +1125,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1119
1125
|
// Resume-time compaction (compaction.ts). Runs before the claim so the
|
|
1120
1126
|
// lane is never `running` without a worker while `/compact` works.
|
|
1121
1127
|
let compaction;
|
|
1122
|
-
|
|
1128
|
+
const compactWanted = pendingResume?.compact ?? launch.compact;
|
|
1129
|
+
if (resume && compactWanted !== false) {
|
|
1123
1130
|
const nowFn = deps.now ?? (() => new Date());
|
|
1124
1131
|
const decision = compactionDue(existing, nowFn().getTime(), providerCompactAfterMs(config.providers[lane.provider]));
|
|
1125
1132
|
if (decision.due) {
|
|
@@ -1169,6 +1176,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1169
1176
|
...current,
|
|
1170
1177
|
state: "running",
|
|
1171
1178
|
queued_reason: undefined,
|
|
1179
|
+
// An account move's reservation ends with the claim.
|
|
1180
|
+
pending_resume: undefined,
|
|
1172
1181
|
account: resolved.id,
|
|
1173
1182
|
...(model !== undefined ? { model } : {}),
|
|
1174
1183
|
effort_source: effort.source,
|
|
@@ -1187,10 +1196,22 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
1187
1196
|
}
|
|
1188
1197
|
if (compaction)
|
|
1189
1198
|
next.last_compaction = { ...compaction, resume_at: nowIso };
|
|
1190
|
-
if (resume &&
|
|
1199
|
+
if (resume && pendingResume !== undefined) {
|
|
1191
1200
|
next.last_resume = {
|
|
1192
1201
|
session_id: resume.session_id,
|
|
1193
1202
|
at: nowIso,
|
|
1203
|
+
account: resolved.id,
|
|
1204
|
+
trigger: pendingResume.trigger,
|
|
1205
|
+
...(pendingResume.message_id !== undefined ? { message_id: pendingResume.message_id } : {}),
|
|
1206
|
+
...(pendingResume.resumed_from !== undefined ? { resumed_from: pendingResume.resumed_from } : {}),
|
|
1207
|
+
...(pendingResume.text !== undefined ? { text: pendingResume.text } : {}),
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
else if (resume && trigger.kind !== "admit") {
|
|
1211
|
+
next.last_resume = {
|
|
1212
|
+
session_id: resume.session_id,
|
|
1213
|
+
at: nowIso,
|
|
1214
|
+
account: resolved.id,
|
|
1194
1215
|
trigger: trigger.kind,
|
|
1195
1216
|
...(trigger.kind === "message" ? { message_id: trigger.message_id } : {}),
|
|
1196
1217
|
...(trigger.kind === "resume"
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -325,6 +325,11 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
325
325
|
path: filePath,
|
|
326
326
|
});
|
|
327
327
|
}
|
|
328
|
+
if (raw.account_history !== undefined && !Array.isArray(raw.account_history)) {
|
|
329
|
+
throw new LaneSchemaError("account_history must be an array when present", {
|
|
330
|
+
path: filePath,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
328
333
|
if (!Array.isArray(raw.attribution_history)) {
|
|
329
334
|
throw new LaneSchemaError("attribution_history must be an array", {
|
|
330
335
|
path: filePath,
|
|
@@ -466,6 +471,11 @@ export function mergeLaneRecords(live, sidecars) {
|
|
|
466
471
|
...base,
|
|
467
472
|
senior_history: unionHistory(all.map((record) => record.senior_history), entryIdentity, base.senior_history),
|
|
468
473
|
attribution_history: unionHistory(all.map((record) => record.attribution_history), attributionIdentity, base.attribution_history),
|
|
474
|
+
...(all.some((record) => record.account_history !== undefined)
|
|
475
|
+
? {
|
|
476
|
+
account_history: unionHistory(all.map((record) => record.account_history ?? []), entryIdentity, base.account_history ?? []),
|
|
477
|
+
}
|
|
478
|
+
: {}),
|
|
469
479
|
};
|
|
470
480
|
}
|
|
471
481
|
export function mergeQuestionRecords(live, sidecars) {
|
|
@@ -250,6 +250,15 @@ export interface LaneRecord {
|
|
|
250
250
|
senior_history: SeniorHistoryEntry[];
|
|
251
251
|
state: LaneState;
|
|
252
252
|
queued_reason?: QueuedReason;
|
|
253
|
+
/**
|
|
254
|
+
* Set by `hq lanes pickup` when it stores an outpost lane and cleared by
|
|
255
|
+
* the account bind once the model probe has finished. While set, the
|
|
256
|
+
* reconcile pass does not admit the lane: a spawn during the probe would
|
|
257
|
+
* leave a running worker behind a record the probe result then rewrites.
|
|
258
|
+
*/
|
|
259
|
+
pickup_validation_pending?: true;
|
|
260
|
+
/** When the pickup validation marker was written. */
|
|
261
|
+
pickup_validation_pending_since?: string;
|
|
253
262
|
priority: number;
|
|
254
263
|
worker?: string;
|
|
255
264
|
/** Registry-relative directory of the resolved worker, when --worker was set. */
|
|
@@ -258,6 +267,20 @@ export interface LaneRecord {
|
|
|
258
267
|
worker_pack?: string;
|
|
259
268
|
provider: LaneProvider;
|
|
260
269
|
account?: string;
|
|
270
|
+
/**
|
|
271
|
+
* Every operator move of the lane's session to another account of the
|
|
272
|
+
* same provider (`hq lanes resume|message --account`), newest last, capped
|
|
273
|
+
* like the other histories.
|
|
274
|
+
*/
|
|
275
|
+
account_history?: AccountHistoryEntry[];
|
|
276
|
+
/**
|
|
277
|
+
* Set by an account move (`--account`) until the spawn claims the lane:
|
|
278
|
+
* the turn the move was asked to run. While set it reserves a slot on the
|
|
279
|
+
* account for the capacity check of later moves. When the target was at
|
|
280
|
+
* its cap the lane is queued there and the reconcile pass resumes the
|
|
281
|
+
* recorded session with this turn instead of starting fresh from the brief.
|
|
282
|
+
*/
|
|
283
|
+
pending_resume?: PendingResume;
|
|
261
284
|
host: LaneHost;
|
|
262
285
|
/**
|
|
263
286
|
* Creating machine's identity when `host.kind` is `outpost` (spec §5.2).
|
|
@@ -308,8 +331,10 @@ export interface LaneRecord {
|
|
|
308
331
|
terminal_reason?: string;
|
|
309
332
|
/**
|
|
310
333
|
* Structured detail behind a named `terminal_reason` (dispatch, pickup and
|
|
311
|
-
* spawn failures),
|
|
312
|
-
* `provider_binary_missing
|
|
334
|
+
* spawn failures), the missing-binary search path behind
|
|
335
|
+
* `provider_binary_missing`, or the provider's own message, status, model
|
|
336
|
+
* and account behind `provider_rejected`. Written by the command, spawn
|
|
337
|
+
* and watcher writers.
|
|
313
338
|
*/
|
|
314
339
|
terminal_details?: Record<string, unknown>;
|
|
315
340
|
/** SSM command id of the `hq outposts exec` pickup that started this lane. */
|
|
@@ -368,9 +393,27 @@ export interface LaneEnvelopeRepair {
|
|
|
368
393
|
envelope_ref?: string;
|
|
369
394
|
at: string;
|
|
370
395
|
}
|
|
396
|
+
export interface AccountHistoryEntry {
|
|
397
|
+
from: string | null;
|
|
398
|
+
to: string;
|
|
399
|
+
at: string;
|
|
400
|
+
reason: "operator_move";
|
|
401
|
+
}
|
|
402
|
+
export interface PendingResume {
|
|
403
|
+
session_id: string;
|
|
404
|
+
trigger: "message" | "resume";
|
|
405
|
+
resumed_from?: "interrupted" | "done" | "blocked";
|
|
406
|
+
text?: string;
|
|
407
|
+
message_id?: string;
|
|
408
|
+
/** False when the command passed --no-compact; the deferred spawn honours it. */
|
|
409
|
+
compact?: boolean;
|
|
410
|
+
at: string;
|
|
411
|
+
}
|
|
371
412
|
export interface LaneResumeRecord {
|
|
372
413
|
session_id: string;
|
|
373
414
|
at: string;
|
|
415
|
+
/** Account the resumed run was bound to. */
|
|
416
|
+
account?: string;
|
|
374
417
|
trigger: "answered_questions" | "message" | "resume";
|
|
375
418
|
message_id?: string;
|
|
376
419
|
/**
|
|
@@ -508,6 +551,12 @@ export interface ProviderConfig {
|
|
|
508
551
|
* means the five-minute default.
|
|
509
552
|
*/
|
|
510
553
|
progress_event_min_interval_ms?: number;
|
|
554
|
+
/**
|
|
555
|
+
* Hold applied to an account after a 429 whose message names no reset
|
|
556
|
+
* time: the account is skipped for this long. Absent means the
|
|
557
|
+
* fifteen-minute default.
|
|
558
|
+
*/
|
|
559
|
+
quota_default_hold_ms?: number;
|
|
511
560
|
accounts: Record<string, AccountConfig>;
|
|
512
561
|
}
|
|
513
562
|
export interface ThrottleConfig {
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -198,13 +198,15 @@ export const LANE_FIELD_WRITERS = {
|
|
|
198
198
|
project_id: ["command"], story_id: ["command"], thread_id: ["creator", "command"],
|
|
199
199
|
depth: ["command"], senior: ["command"], senior_history: ["command"],
|
|
200
200
|
state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"],
|
|
201
|
+
pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
|
|
202
|
+
pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
|
|
201
203
|
priority: ["command"], worker: ["creator"], worker_path: ["creator"], worker_pack: ["creator"],
|
|
202
|
-
provider: ["creator"], account: ["creator", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
|
|
204
|
+
provider: ["creator"], account: ["creator", "spawn", "command"], account_history: ["command"], pending_resume: ["command", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
|
|
203
205
|
version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
|
|
204
|
-
last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn"], effort: ["creator", "spawn"], effort_source: ["creator", "spawn"],
|
|
206
|
+
last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], effort: ["creator", "spawn"], effort_source: ["creator", "spawn"],
|
|
205
207
|
last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
|
|
206
208
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
207
|
-
admitted_reason: ["reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn"],
|
|
209
|
+
admitted_reason: ["reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
208
210
|
quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
|
|
209
211
|
provider_session_id: ["watcher"], provider_session_id_absent: ["watcher"], last_resume: ["spawn"], last_run_usage: ["watcher"], envelope_repair: ["watcher", "reconcile"], last_progress_event_at: ["watcher"],
|
|
210
212
|
};
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { type LaneEnvelope, type LaneRecord, type LaneState } from "./types.js";
|
|
35
35
|
import { type QuotaSignal } from "./quota.js";
|
|
36
|
+
import { type ProviderRejection } from "./provider-rejection.js";
|
|
36
37
|
import { type LaneMesh } from "./mesh-events.js";
|
|
37
38
|
import { type EnvelopeRepairSpawnInput } from "./spawn.js";
|
|
38
39
|
/** Spec §5.7: no signal for 20 minutes while the process is still alive. */
|
|
@@ -73,6 +74,7 @@ export interface WatcherTickInput {
|
|
|
73
74
|
invalidEnvelope?: {
|
|
74
75
|
errors: string[];
|
|
75
76
|
} | null;
|
|
77
|
+
providerRejection?: ProviderRejection | null;
|
|
76
78
|
}
|
|
77
79
|
export interface WatcherTickResult {
|
|
78
80
|
emit?: WatcherSignal;
|
|
@@ -97,7 +99,30 @@ export declare function classifyLaneExit(input: {
|
|
|
97
99
|
invalidEnvelope?: {
|
|
98
100
|
errors: string[];
|
|
99
101
|
} | null;
|
|
102
|
+
/**
|
|
103
|
+
* Set when the worker's output stream carries a provider-level turn
|
|
104
|
+
* failure (codex `turn.failed`, a Claude error result with an HTTP status,
|
|
105
|
+
* a grok error object). Failed, never interrupted: the provider refused
|
|
106
|
+
* the turn, so there is nothing to resume and no envelope to repair.
|
|
107
|
+
*/
|
|
108
|
+
providerRejection?: ProviderRejection | null;
|
|
100
109
|
}): TerminalClassification;
|
|
110
|
+
/**
|
|
111
|
+
* One decision for an exited worker's output. An explicit provider status
|
|
112
|
+
* wins over the quota text heuristics: a 429 from any provider (including
|
|
113
|
+
* codex `turn.failed` and grok error objects, which the claude-shaped quota
|
|
114
|
+
* reader never sees) becomes a quota signal; any other explicit status is a
|
|
115
|
+
* rejection even when its text sounds like a quota message (a 402 "requires
|
|
116
|
+
* usage credits" is a rejection, not a hold). A failure with no status at
|
|
117
|
+
* all is matched against the quota text heuristics first: a status-less
|
|
118
|
+
* "usage limit" / "rate limit" turn.failed still creates the account hold;
|
|
119
|
+
* any other status-less message is a rejection. Only when the stream
|
|
120
|
+
* carries no failure object does the quota reader's own read apply.
|
|
121
|
+
*/
|
|
122
|
+
export declare function routeProviderFailure(failure: ProviderRejection | null, readQuota: () => QuotaSignal | null, model: string | undefined, now: Date): {
|
|
123
|
+
quota: QuotaSignal | null;
|
|
124
|
+
rejection: ProviderRejection | null;
|
|
125
|
+
};
|
|
101
126
|
export declare function tickWatcher(input: WatcherTickInput): WatcherTickResult;
|
|
102
127
|
export declare function watcherEventsPath(hqRoot: string, laneId: string): string;
|
|
103
128
|
export declare function watcherOutLogPath(hqRoot: string, laneId: string): string;
|
|
@@ -119,6 +144,8 @@ export interface ApplyTerminalOpts {
|
|
|
119
144
|
envelopeRef?: string;
|
|
120
145
|
at?: string;
|
|
121
146
|
quota?: QuotaSignal | null;
|
|
147
|
+
/** Written to `terminal_details` with the model and account the run used. */
|
|
148
|
+
providerRejection?: ProviderRejection | null;
|
|
122
149
|
}
|
|
123
150
|
export declare function stalledReason(silenceMs: number): string;
|
|
124
151
|
/**
|
|
@@ -38,9 +38,10 @@ import { raiseEnvelopeQuestions } from "./questions.js";
|
|
|
38
38
|
import { isTerminalLaneState, parseLaneEnvelope, } from "./types.js";
|
|
39
39
|
import { readLane, retryUpdateLane } from "./store.js";
|
|
40
40
|
import { readWorkerRunResult } from "./delivery.js";
|
|
41
|
-
import { readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
41
|
+
import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
42
|
+
import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
|
|
42
43
|
import { readToolActivity, toolActivityPath } from "./dropbox.js";
|
|
43
|
-
import { loadLanesConfig, providerProgressEventMinIntervalMs } from "./config.js";
|
|
44
|
+
import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
|
|
44
45
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, progressEventDue, } from "./mesh-events.js";
|
|
45
46
|
import { envelopeQuestionId } from "./questions.js";
|
|
46
47
|
import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, } from "./spawn.js";
|
|
@@ -86,6 +87,9 @@ export function classifyLaneExit(input) {
|
|
|
86
87
|
return { state: "blocked" };
|
|
87
88
|
throw new LanesError("unknown_envelope_decision", `Unknown envelope decision ${JSON.stringify(input.envelope.decision)}. Accepted: done, ask, blocked`, { accepted: ["done", "ask", "blocked"], value: input.envelope.decision });
|
|
88
89
|
}
|
|
90
|
+
if (input.providerRejection) {
|
|
91
|
+
return { state: "failed", reason: PROVIDER_REJECTED_REASON };
|
|
92
|
+
}
|
|
89
93
|
if (input.invalidEnvelope) {
|
|
90
94
|
return { state: "failed", reason: ENVELOPE_INVALID_REASON };
|
|
91
95
|
}
|
|
@@ -100,6 +104,34 @@ export function classifyLaneExit(input) {
|
|
|
100
104
|
}
|
|
101
105
|
return { state: "failed", reason: "exited_without_envelope" };
|
|
102
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* One decision for an exited worker's output. An explicit provider status
|
|
109
|
+
* wins over the quota text heuristics: a 429 from any provider (including
|
|
110
|
+
* codex `turn.failed` and grok error objects, which the claude-shaped quota
|
|
111
|
+
* reader never sees) becomes a quota signal; any other explicit status is a
|
|
112
|
+
* rejection even when its text sounds like a quota message (a 402 "requires
|
|
113
|
+
* usage credits" is a rejection, not a hold). A failure with no status at
|
|
114
|
+
* all is matched against the quota text heuristics first: a status-less
|
|
115
|
+
* "usage limit" / "rate limit" turn.failed still creates the account hold;
|
|
116
|
+
* any other status-less message is a rejection. Only when the stream
|
|
117
|
+
* carries no failure object does the quota reader's own read apply.
|
|
118
|
+
*/
|
|
119
|
+
export function routeProviderFailure(failure, readQuota, model, now) {
|
|
120
|
+
if (failure && failure.status === QUOTA_STATUS) {
|
|
121
|
+
return {
|
|
122
|
+
quota: quotaSignalFromFields({ api_error_status: QUOTA_STATUS, is_error: true, result: failure.message }, model, now),
|
|
123
|
+
rejection: null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (failure && failure.status === null) {
|
|
127
|
+
const quota = quotaSignalFromFields({ api_error_status: undefined, is_error: true, result: failure.message }, model, now);
|
|
128
|
+
if (quota)
|
|
129
|
+
return { quota, rejection: null };
|
|
130
|
+
}
|
|
131
|
+
if (failure)
|
|
132
|
+
return { quota: null, rejection: failure };
|
|
133
|
+
return { quota: readQuota(), rejection: null };
|
|
134
|
+
}
|
|
103
135
|
export function tickWatcher(input) {
|
|
104
136
|
const silenceMs = input.silenceMs ?? SILENCE_MS;
|
|
105
137
|
const at = new Date(input.nowMs).toISOString();
|
|
@@ -109,6 +141,7 @@ export function tickWatcher(input) {
|
|
|
109
141
|
hasPartialOutput: input.hasPartialOutput,
|
|
110
142
|
quota: input.quota,
|
|
111
143
|
invalidEnvelope: input.invalidEnvelope,
|
|
144
|
+
providerRejection: input.providerRejection,
|
|
112
145
|
});
|
|
113
146
|
return {
|
|
114
147
|
emit: { kind: "terminal", at, classification },
|
|
@@ -301,6 +334,9 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
301
334
|
if (opts.quota?.marker) {
|
|
302
335
|
next.model_quota_marker = opts.quota.marker;
|
|
303
336
|
}
|
|
337
|
+
if (opts.providerRejection) {
|
|
338
|
+
next.terminal_details = providerRejectionDetails(opts.providerRejection, current);
|
|
339
|
+
}
|
|
304
340
|
if (opts.envelope && opts.envelopeRef) {
|
|
305
341
|
next.last_envelope = {
|
|
306
342
|
ref: opts.envelopeRef,
|
|
@@ -589,19 +625,37 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
589
625
|
const hasPartialOutput = logFiles.some((file) => fileNonEmpty(file));
|
|
590
626
|
let quota = null;
|
|
591
627
|
let quotaLane = null;
|
|
628
|
+
let providerRejection = null;
|
|
592
629
|
if (!processAlive && !envelope) {
|
|
593
630
|
try {
|
|
594
631
|
quotaLane = readLane(hqRoot, laneId);
|
|
595
|
-
|
|
632
|
+
const workerOutOffset = readSpawnManifest(runDir).worker_out_offset ?? 0;
|
|
633
|
+
const routed = routeProviderFailure(readProviderRejection(runDir, quotaLane.provider, workerOutOffset), () => readQuotaSignal(runDir, quotaLane.model, new Date(now()), workerOutOffset), quotaLane.model, new Date(now()));
|
|
634
|
+
// A reset-less 429 still holds the account: the configured default
|
|
635
|
+
// hold becomes its reset, recorded as such.
|
|
636
|
+
quota = routed.quota
|
|
637
|
+
? applyDefaultQuotaHold(routed.quota, providerQuotaDefaultHoldMs(loadLanesConfig(hqRoot).providers[quotaLane.provider]), new Date(now()))
|
|
638
|
+
: null;
|
|
639
|
+
providerRejection = routed.rejection;
|
|
596
640
|
}
|
|
597
641
|
catch (err) {
|
|
598
642
|
process.stderr.write(`hq lanes watcher: quota read failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
599
643
|
}
|
|
600
644
|
}
|
|
601
645
|
// Spec §4: the process exited and its envelope file does not parse.
|
|
602
|
-
// Exactly one repair turn, then failed with both errors.
|
|
646
|
+
// Exactly one repair turn, then failed with both errors. A provider
|
|
647
|
+
// rejection skips the repair: the provider refused the turn, so a
|
|
648
|
+
// second turn on the same model and account would be refused the same
|
|
649
|
+
// way. A quota signal skips it too: a repair turn on an exhausted account
|
|
650
|
+
// fails the same way, and would end the lane envelope_invalid without
|
|
651
|
+
// ever recording the hold. The quota result applies immediately instead.
|
|
603
652
|
let invalidEnvelope = null;
|
|
604
|
-
if (!processAlive &&
|
|
653
|
+
if (!processAlive &&
|
|
654
|
+
!envelope &&
|
|
655
|
+
!providerRejection &&
|
|
656
|
+
!quota &&
|
|
657
|
+
latestFile &&
|
|
658
|
+
latestFile.envelope === null) {
|
|
605
659
|
const repair = decideEnvelopeRepair(hqRoot, laneId, latestFile, now(), spawnRepair, logDecision);
|
|
606
660
|
if (repair.action === "spawned") {
|
|
607
661
|
lastSignalMs = now();
|
|
@@ -626,6 +680,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
626
680
|
silenceMs,
|
|
627
681
|
quota,
|
|
628
682
|
invalidEnvelope,
|
|
683
|
+
providerRejection,
|
|
629
684
|
});
|
|
630
685
|
if (processAlive) {
|
|
631
686
|
maybeHeartbeatLane(hqRoot, laneId, now());
|
|
@@ -702,6 +757,13 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
702
757
|
state: classification.state,
|
|
703
758
|
reason: classification.reason,
|
|
704
759
|
...(invalidEnvelope ? { envelope_errors: invalidEnvelope.errors } : {}),
|
|
760
|
+
...(providerRejection
|
|
761
|
+
? {
|
|
762
|
+
provider_status: providerRejection.status,
|
|
763
|
+
provider_message: providerRejection.message,
|
|
764
|
+
retryable: providerRejection.retryable,
|
|
765
|
+
}
|
|
766
|
+
: {}),
|
|
705
767
|
});
|
|
706
768
|
if (quota &&
|
|
707
769
|
quotaLane?.account &&
|
|
@@ -719,6 +781,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
|
|
|
719
781
|
envelopeRef: latest?.ref,
|
|
720
782
|
at: tick.emit.at,
|
|
721
783
|
quota,
|
|
784
|
+
providerRejection,
|
|
722
785
|
});
|
|
723
786
|
const updated = terminalWrite.record;
|
|
724
787
|
logDecision({
|
|
@@ -49,15 +49,16 @@ export interface HeldSessionRef {
|
|
|
49
49
|
/** Task id carried on the event, if any. */
|
|
50
50
|
task?: string;
|
|
51
51
|
/**
|
|
52
|
-
* Per-event local evidence retained while coalescing a held session.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* Per-event local evidence retained while coalescing a held session. A
|
|
53
|
+
* session id may be reused. The current instance is state.bindingEpisodeId,
|
|
54
|
+
* never the event timestamp: a later `at` on another episode is still stale.
|
|
55
55
|
*/
|
|
56
56
|
eventEvidence?: Array<{
|
|
57
57
|
hqRoot?: string;
|
|
58
58
|
cwd?: string;
|
|
59
59
|
harness?: string;
|
|
60
60
|
at?: string;
|
|
61
|
+
bindingEpisodeId?: string;
|
|
61
62
|
}>;
|
|
62
63
|
}
|
|
63
64
|
export interface BackfillHeldResult {
|