@indigoai-us/hq-cli 5.215.0 → 5.217.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 +23 -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/keep-alive.d.ts +1 -0
- package/dist/lib/lanes/keep-alive.js +1 -0
- 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/reconcile.d.ts +2 -0
- package/dist/lib/lanes/reconcile.js +234 -77
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +6 -1
- package/dist/lib/lanes/store.js +8 -0
- package/dist/lib/lanes/types.d.ts +5 -1
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.d.ts +3 -1
- package/dist/lib/lanes/watcher.js +175 -15
- package/package.json +1 -1
|
@@ -21,11 +21,11 @@ import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecor
|
|
|
21
21
|
import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
22
22
|
import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
23
23
|
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
24
|
-
import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, } from "./keep-alive.js";
|
|
24
|
+
import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, } from "./keep-alive.js";
|
|
25
25
|
import { providerSupportsSessionResume } from "./delivery.js";
|
|
26
26
|
import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
27
27
|
import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
|
|
28
|
-
import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, writeWatcherDecision, } from "./watcher.js";
|
|
28
|
+
import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
|
|
29
29
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
|
|
30
30
|
import { laneStories, storyStatusForEnvelope } from "./stories.js";
|
|
31
31
|
import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
|
|
@@ -800,7 +800,7 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
800
800
|
const keepAliveFlagCompanies = new Map();
|
|
801
801
|
const keepAliveHireGrantFlagCompanies = new Map();
|
|
802
802
|
for (const lane of lanes) {
|
|
803
|
-
if (!(lane.state === "done" || lane.state === "killed" || lane.state === "cancelled" || lane.state === "failed") ||
|
|
803
|
+
if (!(lane.state === "done" || lane.state === "interrupted" || lane.state === "killed" || lane.state === "cancelled" || lane.state === "failed") ||
|
|
804
804
|
(lane.keep_alive !== true && lane.worker_profile === undefined) ||
|
|
805
805
|
!laneRunsHere(lane, deps.host) ||
|
|
806
806
|
(lane.state === "done" && (lane.terminal_reason === PROVIDER_REJECTED_REASON ||
|
|
@@ -838,7 +838,7 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
838
838
|
]);
|
|
839
839
|
await withAllLaneAdmissionLocks(hqRoot, async () => {
|
|
840
840
|
const freshLanes = loadLaneRecords(hqRoot, summary);
|
|
841
|
-
|
|
841
|
+
resumeKeepAlive(hqRoot, freshLanes, summary, deps, now, keepAliveFlagByCompany, keepAliveHireGrantFlagByCompany);
|
|
842
842
|
const drainLanes = loadLaneRecords(hqRoot, summary);
|
|
843
843
|
const freshQuestions = loadQuestionRecords(hqRoot, summary);
|
|
844
844
|
await admitQueued(hqRoot, drainLanes, summary, deps, now);
|
|
@@ -1059,6 +1059,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1059
1059
|
state: repairedClassification.state,
|
|
1060
1060
|
queued_reason: undefined,
|
|
1061
1061
|
terminal_reason: repairedClassification.reason ?? "host_lost",
|
|
1062
|
+
interrupted_at: repairedClassification.state === "interrupted" ? nowIso : undefined,
|
|
1062
1063
|
...(providerRejection
|
|
1063
1064
|
? { terminal_details: providerRejectionDetails(providerRejection, current) }
|
|
1064
1065
|
: {}),
|
|
@@ -1175,6 +1176,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1175
1176
|
state: "interrupted",
|
|
1176
1177
|
queued_reason: undefined,
|
|
1177
1178
|
terminal_reason: "host_lost",
|
|
1179
|
+
interrupted_at: nowIso,
|
|
1178
1180
|
}), deps.dryRun);
|
|
1179
1181
|
if (result.changed) {
|
|
1180
1182
|
summary.dead_host.push({
|
|
@@ -1392,7 +1394,8 @@ function keepAliveProgressResetByHuman(lane) {
|
|
|
1392
1394
|
if (count > 0 && !Number.isFinite(lastAutoMs))
|
|
1393
1395
|
return true;
|
|
1394
1396
|
const messageMs = Date.parse(lane.last_message_at ?? "");
|
|
1395
|
-
const resumeMs = lane.last_resume?.text === LEAD_LANE_KEEP_ALIVE_RESUME_TEXT
|
|
1397
|
+
const resumeMs = lane.last_resume?.text === LEAD_LANE_KEEP_ALIVE_RESUME_TEXT ||
|
|
1398
|
+
lane.last_resume?.text === LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT
|
|
1396
1399
|
? Number.NaN
|
|
1397
1400
|
: Date.parse(lane.last_resume?.at ?? "");
|
|
1398
1401
|
return Number.isFinite(lastAutoMs) &&
|
|
@@ -1433,6 +1436,36 @@ function recordKeepAliveSkip(hqRoot, lane, reason, summary, deps, detail) {
|
|
|
1433
1436
|
});
|
|
1434
1437
|
}
|
|
1435
1438
|
}
|
|
1439
|
+
function readLatestInterruptedClassificationAt(hqRoot, lane) {
|
|
1440
|
+
const logPath = watcherOutLogPath(hqRoot, lane.lane_id);
|
|
1441
|
+
let body;
|
|
1442
|
+
try {
|
|
1443
|
+
body = fs.readFileSync(logPath, "utf8");
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
if (err.code === "ENOENT")
|
|
1447
|
+
return undefined;
|
|
1448
|
+
throw err;
|
|
1449
|
+
}
|
|
1450
|
+
let latestClassification;
|
|
1451
|
+
for (const line of body.split(/\r?\n/).filter(Boolean)) {
|
|
1452
|
+
const parsed = JSON.parse(line);
|
|
1453
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1454
|
+
throw new Error("watcher log contains a non-object decision record");
|
|
1455
|
+
}
|
|
1456
|
+
const record = parsed;
|
|
1457
|
+
if (record.event === "classification")
|
|
1458
|
+
latestClassification = record;
|
|
1459
|
+
}
|
|
1460
|
+
const at = latestClassification?.at;
|
|
1461
|
+
if (latestClassification?.state !== "interrupted" ||
|
|
1462
|
+
latestClassification.reason !== lane.terminal_reason ||
|
|
1463
|
+
typeof at !== "string" ||
|
|
1464
|
+
!Number.isFinite(Date.parse(at)) ||
|
|
1465
|
+
new Date(Date.parse(at)).toISOString() !== at)
|
|
1466
|
+
return undefined;
|
|
1467
|
+
return at;
|
|
1468
|
+
}
|
|
1436
1469
|
function latestEnvelopeMtimeMs(hqRoot, lane, ref) {
|
|
1437
1470
|
const filePath = path.join(laneRunDir(hqRoot, lane.lane_id), ref);
|
|
1438
1471
|
try {
|
|
@@ -1442,7 +1475,7 @@ function latestEnvelopeMtimeMs(hqRoot, lane, ref) {
|
|
|
1442
1475
|
throw new LanesError("keep_alive_envelope_timestamp_unavailable", `Cannot stat latest envelope for lane ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}`, { lane_id: lane.lane_id, path: filePath });
|
|
1443
1476
|
}
|
|
1444
1477
|
}
|
|
1445
|
-
function
|
|
1478
|
+
function resumeKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, hireGrantFlagByCompany) {
|
|
1446
1479
|
const nowMs = now.getTime();
|
|
1447
1480
|
const nowIso = now.toISOString();
|
|
1448
1481
|
const config = loadLanesConfig(hqRoot);
|
|
@@ -1456,34 +1489,97 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1456
1489
|
markError(summary, err);
|
|
1457
1490
|
}
|
|
1458
1491
|
}
|
|
1459
|
-
for (const
|
|
1460
|
-
if (!laneRunsHere(
|
|
1492
|
+
for (const originalLane of lanes) {
|
|
1493
|
+
if (!laneRunsHere(originalLane, deps.host))
|
|
1461
1494
|
continue;
|
|
1462
|
-
if (
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1495
|
+
if (originalLane.state === "killed" ||
|
|
1496
|
+
originalLane.state === "cancelled" ||
|
|
1497
|
+
originalLane.state === "failed") {
|
|
1498
|
+
if (originalLane.keep_alive === true &&
|
|
1499
|
+
flagByCompany.get(keepAliveCompanyKey(originalLane.company)) === true)
|
|
1500
|
+
recordKeepAliveSkip(hqRoot, originalLane, "terminal_state", summary, deps);
|
|
1466
1501
|
continue;
|
|
1467
1502
|
}
|
|
1468
|
-
if (
|
|
1469
|
-
(
|
|
1503
|
+
if ((originalLane.state !== "done" && originalLane.state !== "interrupted") ||
|
|
1504
|
+
(originalLane.keep_alive !== true && originalLane.worker_profile === undefined))
|
|
1470
1505
|
continue;
|
|
1471
|
-
const
|
|
1472
|
-
|
|
1473
|
-
if (
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1506
|
+
const initialState = originalLane.state;
|
|
1507
|
+
let lane = originalLane;
|
|
1508
|
+
if (initialState === "done") {
|
|
1509
|
+
const envelope = readLatestEnvelopeRecord(hqRoot, lane.lane_id)?.envelope;
|
|
1510
|
+
if (!envelope || envelope.decision !== "done")
|
|
1511
|
+
continue;
|
|
1512
|
+
if (envelope.story_complete === true) {
|
|
1513
|
+
if (hasKeepAliveProgress(lane)) {
|
|
1514
|
+
casLaneIf(hqRoot, lane.lane_id, (current) => current.version === lane.version &&
|
|
1515
|
+
current.state === "done" &&
|
|
1516
|
+
current.keep_alive === lane.keep_alive, (current) => ({ ...clearedKeepAliveProgress(current), keep_alive_blocked_reason: undefined }), deps.dryRun);
|
|
1517
|
+
}
|
|
1518
|
+
continue;
|
|
1480
1519
|
}
|
|
1481
|
-
continue;
|
|
1482
1520
|
}
|
|
1483
1521
|
if (flagByCompany.get(keepAliveCompanyKey(lane.company)) !== true)
|
|
1484
1522
|
continue;
|
|
1485
|
-
|
|
1486
|
-
|
|
1523
|
+
// Persist a stable timestamp for legacy interrupted rows before a later
|
|
1524
|
+
// keep-alive status write can advance their general updated_at clock. The
|
|
1525
|
+
// watcher classification is the interruption time; updated_at is only an
|
|
1526
|
+
// upper bound and can postdate explicit opt-in.
|
|
1527
|
+
if (initialState === "interrupted" && lane.keep_alive === true && lane.interrupted_at === undefined) {
|
|
1528
|
+
let interruptedAt;
|
|
1529
|
+
try {
|
|
1530
|
+
interruptedAt = readLatestInterruptedClassificationAt(hqRoot, lane);
|
|
1531
|
+
}
|
|
1532
|
+
catch (err) {
|
|
1533
|
+
const detail = `watcher classification read failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1534
|
+
recordKeepAliveSkip(hqRoot, lane, "missing_interruption_time", summary, deps, detail);
|
|
1535
|
+
const skip = summary.skipped[summary.skipped.length - 1];
|
|
1536
|
+
if (skip) {
|
|
1537
|
+
summary.ok = false;
|
|
1538
|
+
logSkip(skip);
|
|
1539
|
+
}
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
if (interruptedAt === undefined) {
|
|
1543
|
+
recordKeepAliveSkip(hqRoot, lane, "missing_interruption_time", summary, deps, "no latest matching watcher classification event");
|
|
1544
|
+
const skip = summary.skipped[summary.skipped.length - 1];
|
|
1545
|
+
if (skip) {
|
|
1546
|
+
summary.ok = false;
|
|
1547
|
+
logSkip(skip);
|
|
1548
|
+
}
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
try {
|
|
1552
|
+
const backfill = casLaneIf(hqRoot, lane.lane_id, (current) => current.version === lane.version &&
|
|
1553
|
+
current.state === "interrupted" &&
|
|
1554
|
+
current.keep_alive === true &&
|
|
1555
|
+
current.terminal_reason === lane.terminal_reason &&
|
|
1556
|
+
current.interrupted_at === undefined, (current) => ({ ...current, interrupted_at: interruptedAt }), deps.dryRun);
|
|
1557
|
+
lane = backfill.record;
|
|
1558
|
+
if (lane.interrupted_at === undefined) {
|
|
1559
|
+
recordKeepAliveSkip(hqRoot, lane, "missing_interruption_time", summary, deps, "lane changed before watcher classification timestamp could be recorded");
|
|
1560
|
+
const skip = summary.skipped[summary.skipped.length - 1];
|
|
1561
|
+
if (skip) {
|
|
1562
|
+
summary.ok = false;
|
|
1563
|
+
logSkip(skip);
|
|
1564
|
+
}
|
|
1565
|
+
continue;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
catch (err) {
|
|
1569
|
+
markDirty(summary, {
|
|
1570
|
+
kind: "lane",
|
|
1571
|
+
id: lane.lane_id,
|
|
1572
|
+
why: `keep-alive interruption timestamp backfill failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1573
|
+
});
|
|
1574
|
+
continue;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (initialState === "interrupted" && lane.terminal_reason !== "exited_with_partial_output") {
|
|
1578
|
+
recordKeepAliveSkip(hqRoot, lane, "terminal_state", summary, deps, lane.terminal_reason ?? "missing interruption reason");
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
if (initialState === "done" && (lane.terminal_reason === PROVIDER_REJECTED_REASON ||
|
|
1582
|
+
lane.terminal_reason === "account_not_logged_in")) {
|
|
1487
1583
|
recordKeepAliveSkip(hqRoot, lane, "terminal_state", summary, deps, lane.terminal_reason);
|
|
1488
1584
|
continue;
|
|
1489
1585
|
}
|
|
@@ -1496,20 +1592,35 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1496
1592
|
recordKeepAliveSkip(hqRoot, lane, "missing_opt_in_time", summary, deps);
|
|
1497
1593
|
continue;
|
|
1498
1594
|
}
|
|
1499
|
-
let
|
|
1500
|
-
|
|
1501
|
-
|
|
1595
|
+
let eligibilityAtMs;
|
|
1596
|
+
if (initialState === "done") {
|
|
1597
|
+
const latest = readLatestEnvelopeRecord(hqRoot, lane.lane_id);
|
|
1598
|
+
if (!latest || latest.envelope.decision !== "done" || latest.envelope.story_complete === true)
|
|
1599
|
+
continue;
|
|
1600
|
+
try {
|
|
1601
|
+
eligibilityAtMs = latestEnvelopeMtimeMs(hqRoot, lane, latest.ref);
|
|
1602
|
+
}
|
|
1603
|
+
catch (err) {
|
|
1604
|
+
markDirty(summary, {
|
|
1605
|
+
kind: "lane",
|
|
1606
|
+
id: lane.lane_id,
|
|
1607
|
+
why: `keep-alive envelope timestamp unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
1608
|
+
});
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1502
1611
|
}
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
}
|
|
1509
|
-
continue;
|
|
1612
|
+
else {
|
|
1613
|
+
eligibilityAtMs = Date.parse(lane.interrupted_at ?? "");
|
|
1614
|
+
if (!Number.isFinite(eligibilityAtMs)) {
|
|
1615
|
+
recordKeepAliveSkip(hqRoot, lane, "missing_interruption_time", summary, deps);
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1510
1618
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1619
|
+
const initialTimeBlockReason = initialState === "done"
|
|
1620
|
+
? "envelope_before_opt_in"
|
|
1621
|
+
: "interruption_before_opt_in";
|
|
1622
|
+
if (eligibilityAtMs <= optedInAtMs) {
|
|
1623
|
+
recordKeepAliveSkip(hqRoot, lane, initialTimeBlockReason, summary, deps);
|
|
1513
1624
|
continue;
|
|
1514
1625
|
}
|
|
1515
1626
|
if (retiredLaneIds === null) {
|
|
@@ -1521,33 +1632,54 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1521
1632
|
recordKeepAliveSkip(hqRoot, lane, exclusion, summary, deps);
|
|
1522
1633
|
continue;
|
|
1523
1634
|
}
|
|
1635
|
+
const providerSessionId = lane.provider_session_id ?? lane.last_resume?.session_id;
|
|
1524
1636
|
if (!providerSupportsSessionResume(lane.provider) ||
|
|
1525
|
-
|
|
1637
|
+
typeof providerSessionId !== "string" ||
|
|
1638
|
+
providerSessionId.trim().length === 0) {
|
|
1639
|
+
if (initialState === "interrupted") {
|
|
1640
|
+
recordKeepAliveSkip(hqRoot, lane, "terminal_state", summary, deps, "provider session cannot be resumed");
|
|
1641
|
+
}
|
|
1526
1642
|
continue;
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1643
|
+
}
|
|
1644
|
+
// This second read is intentionally independent of the pass snapshot. The
|
|
1645
|
+
// final timestamp comparison below closes the opt-in race before the CAS.
|
|
1646
|
+
const rechecked = deps.readKeepAliveRecheckLane?.(hqRoot, lane.lane_id) ?? readLane(hqRoot, lane.lane_id);
|
|
1647
|
+
// Dry-run backfills only in memory. Carry that simulated legacy timestamp
|
|
1648
|
+
// through the fresh read when the stored lane version has not changed.
|
|
1649
|
+
const current = deps.dryRun &&
|
|
1650
|
+
initialState === "interrupted" &&
|
|
1651
|
+
originalLane.interrupted_at === undefined &&
|
|
1652
|
+
lane.interrupted_at !== undefined &&
|
|
1653
|
+
rechecked.version === lane.version &&
|
|
1654
|
+
rechecked.interrupted_at === undefined
|
|
1655
|
+
? { ...rechecked, interrupted_at: lane.interrupted_at }
|
|
1656
|
+
: rechecked;
|
|
1657
|
+
if (current.state !== initialState || !laneRunsHere(current, deps.host))
|
|
1534
1658
|
continue;
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
if (!currentEnvelope || currentEnvelope.decision !== "done")
|
|
1659
|
+
if (current.keep_alive !== true) {
|
|
1660
|
+
recordKeepAliveSkip(hqRoot, current, "not_opted_in", summary, deps);
|
|
1538
1661
|
continue;
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
latestLane.state === "done" &&
|
|
1543
|
-
keepAliveStillEnabled(latestLane), (latestLane) => ({ ...clearedKeepAliveProgress(latestLane), keep_alive_blocked_reason: undefined }), deps.dryRun);
|
|
1544
|
-
}
|
|
1662
|
+
}
|
|
1663
|
+
if (initialState === "interrupted" && current.terminal_reason !== "exited_with_partial_output") {
|
|
1664
|
+
recordKeepAliveSkip(hqRoot, current, "terminal_state", summary, deps, current.terminal_reason ?? "missing interruption reason");
|
|
1545
1665
|
continue;
|
|
1546
1666
|
}
|
|
1547
|
-
|
|
1548
|
-
|
|
1667
|
+
let currentEligibilityAtMs;
|
|
1668
|
+
if (initialState === "done") {
|
|
1669
|
+
const currentEnvelope = readLatestEnvelopeRecord(hqRoot, current.lane_id);
|
|
1670
|
+
if (!currentEnvelope || currentEnvelope.envelope.decision !== "done")
|
|
1671
|
+
continue;
|
|
1672
|
+
if (currentEnvelope.envelope.story_complete === true) {
|
|
1673
|
+
if (hasKeepAliveProgress(current)) {
|
|
1674
|
+
casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
|
|
1675
|
+
latestLane.state === "done" &&
|
|
1676
|
+
latestLane.keep_alive === true &&
|
|
1677
|
+
latestLane.keep_alive_opted_in_at === current.keep_alive_opted_in_at, (latestLane) => ({ ...clearedKeepAliveProgress(latestLane), keep_alive_blocked_reason: undefined }), deps.dryRun);
|
|
1678
|
+
}
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1549
1681
|
try {
|
|
1550
|
-
|
|
1682
|
+
currentEligibilityAtMs = latestEnvelopeMtimeMs(hqRoot, current, currentEnvelope.ref);
|
|
1551
1683
|
}
|
|
1552
1684
|
catch (err) {
|
|
1553
1685
|
markDirty(summary, {
|
|
@@ -1557,11 +1689,26 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1557
1689
|
});
|
|
1558
1690
|
continue;
|
|
1559
1691
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1692
|
+
}
|
|
1693
|
+
else {
|
|
1694
|
+
currentEligibilityAtMs = Date.parse(current.interrupted_at ?? "");
|
|
1695
|
+
if (!Number.isFinite(currentEligibilityAtMs)) {
|
|
1696
|
+
recordKeepAliveSkip(hqRoot, current, "missing_interruption_time", summary, deps);
|
|
1562
1697
|
continue;
|
|
1563
1698
|
}
|
|
1564
1699
|
}
|
|
1700
|
+
const currentOptedInAtMs = Date.parse(current.keep_alive_opted_in_at ?? "");
|
|
1701
|
+
if (!Number.isFinite(currentOptedInAtMs)) {
|
|
1702
|
+
recordKeepAliveSkip(hqRoot, current, "missing_opt_in_time", summary, deps);
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
const currentTimeBlockReason = initialState === "done"
|
|
1706
|
+
? "envelope_before_opt_in"
|
|
1707
|
+
: "interruption_before_opt_in";
|
|
1708
|
+
if (currentEligibilityAtMs <= currentOptedInAtMs) {
|
|
1709
|
+
recordKeepAliveSkip(hqRoot, current, currentTimeBlockReason, summary, deps);
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1565
1712
|
if (retiredLaneIds === null) {
|
|
1566
1713
|
recordKeepAliveSkip(hqRoot, current, "retired_list_invalid", summary, deps);
|
|
1567
1714
|
continue;
|
|
@@ -1572,11 +1719,10 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1572
1719
|
recordKeepAliveSkip(hqRoot, current, currentExclusion, summary, deps);
|
|
1573
1720
|
continue;
|
|
1574
1721
|
}
|
|
1575
|
-
const
|
|
1576
|
-
if (current.
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
providerSessionId.trim().length === 0)
|
|
1722
|
+
const currentSessionId = current.provider_session_id ?? current.last_resume?.session_id;
|
|
1723
|
+
if (!providerSupportsSessionResume(current.provider) ||
|
|
1724
|
+
typeof currentSessionId !== "string" ||
|
|
1725
|
+
currentSessionId.trim().length === 0)
|
|
1580
1726
|
continue;
|
|
1581
1727
|
const resetByHuman = keepAliveProgressResetByHuman(current);
|
|
1582
1728
|
const attempts = resetByHuman ? 0 : current.keep_alive_resume_count ?? 0;
|
|
@@ -1584,8 +1730,9 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1584
1730
|
if (current.keep_alive_exhausted_at !== undefined)
|
|
1585
1731
|
continue;
|
|
1586
1732
|
const exhausted = casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
|
|
1587
|
-
latestLane.state ===
|
|
1588
|
-
|
|
1733
|
+
latestLane.state === current.state &&
|
|
1734
|
+
latestLane.keep_alive === true &&
|
|
1735
|
+
latestLane.keep_alive_opted_in_at === current.keep_alive_opted_in_at &&
|
|
1589
1736
|
(latestLane.keep_alive_resume_count ?? 0) >= LEAD_LANE_KEEP_ALIVE_MAX_RESUMES &&
|
|
1590
1737
|
latestLane.keep_alive_exhausted_at === undefined, (latestLane) => ({ ...latestLane, keep_alive_exhausted_at: nowIso }), deps.dryRun);
|
|
1591
1738
|
if (exhausted.changed && !deps.dryRun) {
|
|
@@ -1593,13 +1740,16 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1593
1740
|
event: "keep_alive_exhausted",
|
|
1594
1741
|
at: nowIso,
|
|
1595
1742
|
attempt: attempts,
|
|
1743
|
+
resumed_from: current.state,
|
|
1596
1744
|
});
|
|
1597
1745
|
}
|
|
1598
1746
|
continue;
|
|
1599
1747
|
}
|
|
1600
1748
|
const attempt = attempts + 1;
|
|
1601
|
-
const
|
|
1602
|
-
|
|
1749
|
+
const terminalAtMs = initialState === "done"
|
|
1750
|
+
? Date.parse(current.updated_at)
|
|
1751
|
+
: Date.parse(current.interrupted_at ?? "");
|
|
1752
|
+
const attemptEligibleMs = terminalAtMs + leadLaneKeepAliveBackoffMs(attempt);
|
|
1603
1753
|
const storedNextMs = resetByHuman
|
|
1604
1754
|
? Number.NaN
|
|
1605
1755
|
: Date.parse(current.keep_alive_next_eligible_at ?? "");
|
|
@@ -1609,8 +1759,9 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1609
1759
|
if (nowMs < nextEligibleMs) {
|
|
1610
1760
|
if (resetByHuman && hasKeepAliveProgress(current)) {
|
|
1611
1761
|
casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
|
|
1612
|
-
latestLane.state ===
|
|
1613
|
-
|
|
1762
|
+
latestLane.state === current.state &&
|
|
1763
|
+
latestLane.keep_alive === true &&
|
|
1764
|
+
latestLane.keep_alive_opted_in_at === current.keep_alive_opted_in_at, (latestLane) => clearedKeepAliveProgress(latestLane), deps.dryRun);
|
|
1614
1765
|
}
|
|
1615
1766
|
continue;
|
|
1616
1767
|
}
|
|
@@ -1628,9 +1779,14 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1628
1779
|
});
|
|
1629
1780
|
continue;
|
|
1630
1781
|
}
|
|
1782
|
+
const resumedFrom = current.state;
|
|
1783
|
+
const resumeText = resumedFrom === "interrupted"
|
|
1784
|
+
? LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT
|
|
1785
|
+
: LEAD_LANE_KEEP_ALIVE_RESUME_TEXT;
|
|
1631
1786
|
const queue = (expectedSeniorId) => casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
|
|
1632
|
-
latestLane.state ===
|
|
1633
|
-
|
|
1787
|
+
latestLane.state === current.state &&
|
|
1788
|
+
latestLane.keep_alive === true &&
|
|
1789
|
+
latestLane.keep_alive_opted_in_at === current.keep_alive_opted_in_at &&
|
|
1634
1790
|
(expectedSeniorId === undefined ||
|
|
1635
1791
|
(latestLane.senior.kind === "lane" && latestLane.senior.id === expectedSeniorId)), (latestLane) => ({
|
|
1636
1792
|
...latestLane,
|
|
@@ -1641,10 +1797,10 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1641
1797
|
terminal_reason: undefined,
|
|
1642
1798
|
terminal_details: undefined,
|
|
1643
1799
|
pending_resume: {
|
|
1644
|
-
session_id:
|
|
1800
|
+
session_id: currentSessionId,
|
|
1645
1801
|
trigger: "resume",
|
|
1646
|
-
resumed_from:
|
|
1647
|
-
text:
|
|
1802
|
+
resumed_from: resumedFrom,
|
|
1803
|
+
text: resumeText,
|
|
1648
1804
|
compact: true,
|
|
1649
1805
|
at: nowIso,
|
|
1650
1806
|
},
|
|
@@ -1693,7 +1849,7 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1693
1849
|
continue;
|
|
1694
1850
|
summary.resumed.push({
|
|
1695
1851
|
id: current.lane_id,
|
|
1696
|
-
from:
|
|
1852
|
+
from: resumedFrom,
|
|
1697
1853
|
to: "queued",
|
|
1698
1854
|
reason: "keep_alive",
|
|
1699
1855
|
});
|
|
@@ -1703,6 +1859,7 @@ function resumeDoneKeepAlive(hqRoot, lanes, summary, deps, now, flagByCompany, h
|
|
|
1703
1859
|
at: nowIso,
|
|
1704
1860
|
attempt,
|
|
1705
1861
|
next_eligible_at: nextEligibleAt,
|
|
1862
|
+
resumed_from: resumedFrom,
|
|
1706
1863
|
});
|
|
1707
1864
|
}
|
|
1708
1865
|
}
|
|
@@ -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
|
@@ -414,6 +414,8 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
414
414
|
"not_opted_in",
|
|
415
415
|
"missing_opt_in_time",
|
|
416
416
|
"envelope_before_opt_in",
|
|
417
|
+
"interruption_before_opt_in",
|
|
418
|
+
"missing_interruption_time",
|
|
417
419
|
"retired",
|
|
418
420
|
"superseded",
|
|
419
421
|
"retired_list_invalid",
|
|
@@ -443,6 +445,7 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
443
445
|
"keep_alive_last_resume_at",
|
|
444
446
|
"keep_alive_next_eligible_at",
|
|
445
447
|
"keep_alive_exhausted_at",
|
|
448
|
+
"interrupted_at",
|
|
446
449
|
]) {
|
|
447
450
|
if (raw[field] !== undefined)
|
|
448
451
|
requireTimestamp(raw[field], field, filePath);
|
|
@@ -470,6 +473,11 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
470
473
|
new Set(raw.pending_question_ids).size !== raw.pending_question_ids.length)) {
|
|
471
474
|
throw new LaneSchemaError("pending_question_ids must be an array of unique non-empty ids", { path: filePath, field: "pending_question_ids" });
|
|
472
475
|
}
|
|
476
|
+
if (raw.envelope_validation_errors !== undefined && (!Array.isArray(raw.envelope_validation_errors) ||
|
|
477
|
+
raw.envelope_validation_errors.length === 0 ||
|
|
478
|
+
raw.envelope_validation_errors.some((error) => typeof error !== "string" || error.trim() === ""))) {
|
|
479
|
+
throw new LaneSchemaError("envelope_validation_errors must be a non-empty array of non-empty strings", { path: filePath, field: "envelope_validation_errors" });
|
|
480
|
+
}
|
|
473
481
|
requireTimestamp(raw.created_at, "created_at", filePath);
|
|
474
482
|
requireTimestamp(raw.updated_at, "updated_at", filePath);
|
|
475
483
|
if (raw.queued_reason !== undefined && !isQueuedReason(raw.queued_reason)) {
|
|
@@ -359,7 +359,7 @@ export interface LaneRecord {
|
|
|
359
359
|
/** Time this lane most recently explicitly opted into keep-alive. */
|
|
360
360
|
keep_alive_opted_in_at?: string;
|
|
361
361
|
/** Why reconcile declined to resume this lane most recently. */
|
|
362
|
-
keep_alive_blocked_reason?: "not_opted_in" | "missing_opt_in_time" | "envelope_before_opt_in" | "retired" | "superseded" | "retired_list_invalid" | "terminal_state";
|
|
362
|
+
keep_alive_blocked_reason?: "not_opted_in" | "missing_opt_in_time" | "envelope_before_opt_in" | "interruption_before_opt_in" | "missing_interruption_time" | "retired" | "superseded" | "retired_list_invalid" | "terminal_state";
|
|
363
363
|
/** Consecutive automatic keep-alive resumes since the last human reset. */
|
|
364
364
|
keep_alive_resume_count?: number;
|
|
365
365
|
/** Time the most recent automatic keep-alive resume was queued. */
|
|
@@ -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;
|
|
@@ -449,6 +451,8 @@ export interface LaneRecord {
|
|
|
449
451
|
/** Set when the reconcile pass force-admits under the starvation bound. */
|
|
450
452
|
admitted_reason?: string;
|
|
451
453
|
terminal_reason?: string;
|
|
454
|
+
/** Classification time when this lane entered interrupted; stable across later metadata writes. */
|
|
455
|
+
interrupted_at?: string;
|
|
452
456
|
/**
|
|
453
457
|
* Structured detail behind a named `terminal_reason` (dispatch, pickup and
|
|
454
458
|
* spawn failures), the missing-binary search path behind
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -230,9 +230,9 @@ export const LANE_FIELD_WRITERS = {
|
|
|
230
230
|
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"],
|
|
231
231
|
last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
|
|
232
232
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
233
|
-
admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
233
|
+
admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], interrupted_at: ["watcher", "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";
|