@indigoai-us/hq-cli 5.300.0 → 5.302.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 +42 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +99 -12
- package/assets/scaffold/core/scripts/token-usage-report.sh +9 -0
- package/dist/command-catalog.generated.d.ts +32 -3
- package/dist/command-catalog.generated.js +39 -3
- package/dist/commands/__fixtures__/access-vault.d.ts +2 -0
- package/dist/commands/__fixtures__/access-vault.js +1 -1
- package/dist/commands/access.d.ts +2 -1
- package/dist/commands/access.js +58 -49
- package/dist/commands/bot.js +1 -0
- package/dist/commands/cloud-provision.js +51 -2
- package/dist/commands/core-checkpoint.js +1 -0
- package/dist/commands/core-company-index-scope-flag.d.ts +3 -0
- package/dist/commands/core-company-index-scope-flag.js +6 -0
- package/dist/commands/core-handoff-fast.js +1 -0
- package/dist/commands/core-handoff.js +2 -0
- package/dist/commands/core-job-native-utils.d.ts +6 -0
- package/dist/commands/core-job-native-utils.js +80 -0
- package/dist/commands/core-job-native.d.ts +2 -4
- package/dist/commands/core-job-native.js +6 -10
- package/dist/commands/core-job-notify-native.d.ts +36 -0
- package/dist/commands/core-job-notify-native.js +359 -0
- package/dist/commands/core-job-remediate-native.d.ts +26 -0
- package/dist/commands/core-job-remediate-native.js +374 -0
- package/dist/commands/core.js +26 -8
- package/dist/commands/deploy-comments.js +5 -2
- package/dist/commands/files.js +31 -14
- package/dist/commands/integrations-api.d.ts +6 -6
- package/dist/commands/integrations-api.js +4 -13
- package/dist/commands/integrations-connect.d.ts +3 -0
- package/dist/commands/integrations-connect.js +21 -2
- package/dist/commands/integrations-manage.js +1 -4
- package/dist/commands/lanes-message.js +43 -5
- package/dist/commands/lanes-questions.d.ts +4 -2
- package/dist/commands/lanes-questions.js +123 -2
- package/dist/commands/lanes-resume.js +25 -4
- package/dist/commands/lanes-shared.d.ts +15 -1
- package/dist/commands/lanes-watch.js +5 -1
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +92 -9
- package/dist/commands/meetings.js +35 -16
- package/dist/commands/monitor.js +17 -2
- package/dist/commands/report.js +5 -2
- package/dist/commands/rescue.d.ts +4 -0
- package/dist/commands/rescue.js +27 -17
- package/dist/commands/safe-extract.d.ts +4 -4
- package/dist/commands/safe-extract.js +6 -7
- package/dist/lib/agent-kit/fallback.d.ts +1 -0
- package/dist/lib/agent-kit/fallback.js +1 -0
- package/dist/lib/bot/daemon.d.ts +5 -1
- package/dist/lib/bot/daemon.js +4 -4
- package/dist/lib/core-utils/soft-timeout.js +1 -1
- package/dist/lib/core-utils/token-usage-report.js +17 -3
- package/dist/lib/deploy-apps.d.ts +13 -0
- package/dist/lib/deploy-apps.js +24 -0
- package/dist/lib/index-render/companies.d.ts +4 -1
- package/dist/lib/index-render/companies.js +66 -3
- package/dist/lib/index-render/index.d.ts +1 -0
- package/dist/lib/index-render/index.js +2 -1
- package/dist/lib/index-render/projects.js +82 -5
- package/dist/lib/index-render/shared.d.ts +1 -1
- package/dist/lib/index-render/shared.js +4 -2
- package/dist/lib/lanes/delivery.d.ts +7 -3
- package/dist/lib/lanes/delivery.js +18 -8
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/keep-alive.d.ts +13 -3
- package/dist/lib/lanes/keep-alive.js +29 -5
- package/dist/lib/lanes/outpost-dispatch.d.ts +5 -0
- package/dist/lib/lanes/outpost-dispatch.js +12 -0
- package/dist/lib/lanes/reconcile.d.ts +1 -0
- package/dist/lib/lanes/reconcile.js +358 -38
- package/dist/lib/lanes/spawn.d.ts +1 -0
- package/dist/lib/lanes/spawn.js +20 -10
- package/dist/lib/lanes/store.js +24 -0
- package/dist/lib/lanes/story-ledger.d.ts +2 -0
- package/dist/lib/lanes/story-ledger.js +8 -0
- package/dist/lib/lanes/types.d.ts +26 -4
- package/dist/lib/lanes/types.js +33 -1
- package/dist/lib/lanes/watch.d.ts +1 -0
- package/dist/lib/lanes/watch.js +19 -6
- package/dist/lib/lanes/watcher.js +139 -9
- package/dist/lib/mesh/live/daemon/mqtt-events.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/mqtt-events.js +2 -0
- package/dist/lib/mesh/live/daemon/mqtt-events.test-d.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/mqtt-events.test-d.js +10 -0
- package/dist/lib/mesh/live/daemon/presence.d.ts +2 -2
- package/dist/lib/mesh/live/daemon/run.js +36 -2
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +2 -2
- package/dist/lib/search-index/background.d.ts +1 -0
- package/dist/lib/search-index/background.js +1 -0
- package/dist/utils/self-update.js +51 -4
- package/dist/utils/staged-npm-install.d.ts +16 -0
- package/dist/utils/staged-npm-install.js +264 -0
- package/dist/utils/update-command-supervisor.cjs +54 -1
- package/dist/utils/version-gate.d.ts +8 -0
- package/dist/utils/version-gate.js +83 -16
- package/package.json +2 -2
- package/dist/lib/install-path-compatibility-flag.d.ts +0 -9
- package/dist/lib/install-path-compatibility-flag.js +0 -12
|
@@ -185,6 +185,12 @@ export function assertAuthMode(value) {
|
|
|
185
185
|
throw new IntegrationsCliError("--auth must be none, key, or oauth.", { expected: true });
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
+
/** RFC 7230 token characters accepted for an HTTP field name. */
|
|
189
|
+
export function assertAuthHeader(value) {
|
|
190
|
+
if (value !== undefined && !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(value)) {
|
|
191
|
+
throw new IntegrationsCliError("--auth-header must be a non-empty HTTP header name using RFC 7230 token characters.", { expected: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
188
194
|
/** Single-quote a value for a copy-pasteable shell command. */
|
|
189
195
|
export function shellQuote(value) {
|
|
190
196
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -904,7 +910,9 @@ function reportInstall(result, opts, expectedRevivedConnectionId) {
|
|
|
904
910
|
*/
|
|
905
911
|
async function connectApp(token, companyUid, app, opts, expectedRevivedConnectionId) {
|
|
906
912
|
const target = await resolveTarget(token, companyUid, app, opts, expectedRevivedConnectionId !== undefined);
|
|
907
|
-
const authMode = opts.
|
|
913
|
+
const authMode = opts.authHeader
|
|
914
|
+
? "key"
|
|
915
|
+
: opts.auth ?? target.authClass;
|
|
908
916
|
assertUserClientApplicable(opts, target, authMode);
|
|
909
917
|
assertScopesApplicable(opts, target, authMode);
|
|
910
918
|
if (authMode === "oauth") {
|
|
@@ -922,7 +930,13 @@ async function connectApp(token, companyUid, app, opts, expectedRevivedConnectio
|
|
|
922
930
|
const result = await installIntegration(token, companyUid, {
|
|
923
931
|
...target.ref,
|
|
924
932
|
...(bearerToken
|
|
925
|
-
? {
|
|
933
|
+
? {
|
|
934
|
+
authMode: "bearer",
|
|
935
|
+
bearerToken,
|
|
936
|
+
...(opts.authHeader
|
|
937
|
+
? { authScheme: { placement: "header", header: opts.authHeader } }
|
|
938
|
+
: {}),
|
|
939
|
+
}
|
|
926
940
|
: authMode === "none"
|
|
927
941
|
? { authMode: "none" }
|
|
928
942
|
: {}),
|
|
@@ -1173,6 +1187,7 @@ export function registerConnectCommands(integrations) {
|
|
|
1173
1187
|
.option("--display-name <name>", "Display name for a custom --mcp-url")
|
|
1174
1188
|
.option("--token <key>", "API key (prefer --token-stdin: --token lands in shell history)")
|
|
1175
1189
|
.option("--token-stdin", "Read the API key from stdin")
|
|
1190
|
+
.option("--auth-header <name>", "Send the API key in this HTTP header (implies --auth key)")
|
|
1176
1191
|
.option("--auth <mode>", "Force the auth mode: none, key, or oauth (default: detect)")
|
|
1177
1192
|
.option("--client-id <id>", "Client id of an OAuth app you registered with the provider yourself")
|
|
1178
1193
|
.option("--client-secret-stdin", "Read that app's client secret from stdin")
|
|
@@ -1183,6 +1198,10 @@ export function registerConnectCommands(integrations) {
|
|
|
1183
1198
|
.option("--json", "Machine-readable output")
|
|
1184
1199
|
.action(async (app, opts) => {
|
|
1185
1200
|
assertAuthMode(opts.auth);
|
|
1201
|
+
assertAuthHeader(opts.authHeader);
|
|
1202
|
+
if (opts.authHeader && opts.auth !== undefined && opts.auth !== "key") {
|
|
1203
|
+
throw new IntegrationsCliError("--auth-header can only be used with --auth key.", { expected: true });
|
|
1204
|
+
}
|
|
1186
1205
|
const token = await ensureCognitoIdToken();
|
|
1187
1206
|
const companyUid = await getCompanyUid(token, opts.company);
|
|
1188
1207
|
if (await refuseIfAlreadyConnected(token, companyUid, app, opts))
|
|
@@ -610,9 +610,6 @@ export function registerManageCommands(integrations) {
|
|
|
610
610
|
const companyUid = await getCompanyUid(token, opts.company);
|
|
611
611
|
const connection = await resolveConnection(token, companyUid, app, opts);
|
|
612
612
|
const installationId = connection.installation?.id;
|
|
613
|
-
if (!installationId) {
|
|
614
|
-
throw new IntegrationsCliError(`${connectionLabel(connection)} was not installed through the app catalog, so it cannot be disconnected from here.`, { expected: true });
|
|
615
|
-
}
|
|
616
613
|
if (!opts.yes) {
|
|
617
614
|
const label = connectionLabel(connection);
|
|
618
615
|
// Warning + prompt go to stderr: with --json, stdout must stay parseable,
|
|
@@ -625,7 +622,7 @@ export function registerManageCommands(integrations) {
|
|
|
625
622
|
throw new IntegrationsCliError("Not disconnected. Re-run with --yes if you are sure.", { expected: true });
|
|
626
623
|
}
|
|
627
624
|
}
|
|
628
|
-
const result = await uninstallIntegration(token, companyUid, installationId);
|
|
625
|
+
const result = await uninstallIntegration(token, companyUid, installationId ? { installationId } : { connectionId: connection.id });
|
|
629
626
|
if (opts.json) {
|
|
630
627
|
printJson(result);
|
|
631
628
|
return;
|
|
@@ -247,6 +247,7 @@ export function createLaneMessageHandlers(deps) {
|
|
|
247
247
|
}
|
|
248
248
|
let lane = resolution.lane;
|
|
249
249
|
if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
|
|
250
|
+
const fallbackAt = lane.state === "awaiting_input" ? ctx.now().toISOString() : undefined;
|
|
250
251
|
const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
|
|
251
252
|
const supersededMessageCallerId = callerLaneId === undefined || resolution.remoteOnly
|
|
252
253
|
? undefined
|
|
@@ -262,9 +263,14 @@ export function createLaneMessageHandlers(deps) {
|
|
|
262
263
|
...opts,
|
|
263
264
|
...(supersededMessageCallerId === undefined ? {} : { supersededMessageCallerId }),
|
|
264
265
|
...(supersededMessageSessionId === undefined ? {} : { supersededMessageSessionId }),
|
|
265
|
-
},
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
}, {
|
|
267
|
+
...(resolution.remoteOnly
|
|
268
|
+
? { syncPrimary: false, expectedOwnerHost: resolution.ownerHost }
|
|
269
|
+
: {}),
|
|
270
|
+
...(fallbackAt === undefined ? {} : {
|
|
271
|
+
afterFallbackResume: () => deps.cancelFallbackQuestionsForLane(ctx, lane, "message", fallbackAt),
|
|
272
|
+
}),
|
|
273
|
+
});
|
|
268
274
|
return;
|
|
269
275
|
}
|
|
270
276
|
assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
|
|
@@ -350,6 +356,19 @@ export function createLaneMessageHandlers(deps) {
|
|
|
350
356
|
if (opts.account !== undefined && !keepCurrentAccount && !providerSessionPortable(lane.provider)) {
|
|
351
357
|
throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
|
|
352
358
|
}
|
|
359
|
+
const pendingAnswerableQuestion = lane.state === "awaiting_input"
|
|
360
|
+
? await deps.hasPendingQuestionForLane(ctx, lane)
|
|
361
|
+
: false;
|
|
362
|
+
if (pendingAnswerableQuestion) {
|
|
363
|
+
const message = deliveryRefusalReason(lane);
|
|
364
|
+
throw new LanesError("lane_not_accepting_messages", message, {
|
|
365
|
+
lane_id: laneId,
|
|
366
|
+
state: lane.state,
|
|
367
|
+
provider: lane.provider,
|
|
368
|
+
message,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const awaitingInputFallback = lane.state === "awaiting_input" && !pendingAnswerableQuestion;
|
|
353
372
|
// One decision point for how a follow-up reaches this lane (delivery.ts).
|
|
354
373
|
// `none` refuses before anything is written: no inbox append, no spawn.
|
|
355
374
|
const mechanism = laneDeliveryMechanism(lane);
|
|
@@ -488,6 +507,8 @@ export function createLaneMessageHandlers(deps) {
|
|
|
488
507
|
});
|
|
489
508
|
lane = move.lane;
|
|
490
509
|
if (move.queued) {
|
|
510
|
+
storySwitchDeliveryStarted = true;
|
|
511
|
+
deps.onLaneMessageQueued?.(laneId, "account_move");
|
|
491
512
|
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
492
513
|
}
|
|
493
514
|
}
|
|
@@ -513,8 +534,11 @@ export function createLaneMessageHandlers(deps) {
|
|
|
513
534
|
accountSelection: selection,
|
|
514
535
|
});
|
|
515
536
|
lane = move.lane;
|
|
516
|
-
if (move.queued)
|
|
537
|
+
if (move.queued) {
|
|
538
|
+
storySwitchDeliveryStarted = true;
|
|
539
|
+
deps.onLaneMessageQueued?.(laneId, "account_move");
|
|
517
540
|
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
541
|
+
}
|
|
518
542
|
}
|
|
519
543
|
else {
|
|
520
544
|
lane = persistClaudeAccountSelection(hqRoot, lane, selection);
|
|
@@ -531,9 +555,13 @@ export function createLaneMessageHandlers(deps) {
|
|
|
531
555
|
...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
|
|
532
556
|
});
|
|
533
557
|
}
|
|
558
|
+
let questionCancellation = null;
|
|
534
559
|
if (move?.queued) {
|
|
535
560
|
// Parked at the target's cap: reconcile resumes the session there.
|
|
536
561
|
markAccepted();
|
|
562
|
+
questionCancellation = awaitingInputFallback
|
|
563
|
+
? await deps.cancelFallbackQuestionsForLane(ctx, lane, "message", at)
|
|
564
|
+
: null;
|
|
537
565
|
}
|
|
538
566
|
else {
|
|
539
567
|
const dispatched = await dispatchLaneDelivery(lane, {
|
|
@@ -565,7 +593,7 @@ export function createLaneMessageHandlers(deps) {
|
|
|
565
593
|
}
|
|
566
594
|
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
567
595
|
queuedOnAdmission = true;
|
|
568
|
-
|
|
596
|
+
const queued = casUpdateLane(hqRoot, laneId, (current) => {
|
|
569
597
|
if (current.version !== lane.version || current.state !== lane.state) {
|
|
570
598
|
throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while message admission was checked`, {
|
|
571
599
|
lane_id: laneId,
|
|
@@ -592,6 +620,9 @@ export function createLaneMessageHandlers(deps) {
|
|
|
592
620
|
},
|
|
593
621
|
};
|
|
594
622
|
});
|
|
623
|
+
storySwitchDeliveryStarted = true;
|
|
624
|
+
deps.onLaneMessageQueued?.(laneId, "admission");
|
|
625
|
+
return queued;
|
|
595
626
|
}
|
|
596
627
|
const started = spawnLaneWorker(hqRoot, lane, {
|
|
597
628
|
env: ctx.env,
|
|
@@ -629,6 +660,9 @@ export function createLaneMessageHandlers(deps) {
|
|
|
629
660
|
if (dispatched.mechanism === "resume")
|
|
630
661
|
resumed = dispatched.value;
|
|
631
662
|
markAccepted();
|
|
663
|
+
questionCancellation = awaitingInputFallback
|
|
664
|
+
? await deps.cancelFallbackQuestionsForLane(ctx, lane, "message", at)
|
|
665
|
+
: null;
|
|
632
666
|
}
|
|
633
667
|
// Every accepted message moves the compaction reference forward.
|
|
634
668
|
const next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
@@ -671,6 +705,10 @@ export function createLaneMessageHandlers(deps) {
|
|
|
671
705
|
inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
|
|
672
706
|
drain_hook: drainHook,
|
|
673
707
|
warning,
|
|
708
|
+
...(questionCancellation === null ? {} : {
|
|
709
|
+
cancelled_questions: questionCancellation.cancelled_question_ids,
|
|
710
|
+
question_cancellation_failures: questionCancellation.failures,
|
|
711
|
+
}),
|
|
674
712
|
resumed_session_id: resumed?.last_resume?.session_id ?? null,
|
|
675
713
|
compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
676
714
|
? resumed.last_compaction
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { type QuestionBody, type QuestionRecord } from "../lib/lanes/index.js";
|
|
2
|
-
import type { Ctx, LaneQuestionsHandlerDeps } from "./lanes-shared.js";
|
|
1
|
+
import { type QuestionBody, type LaneRecord, type QuestionRecord } from "../lib/lanes/index.js";
|
|
2
|
+
import type { Ctx, FallbackQuestionCancellationResult, LaneQuestionsHandlerDeps } from "./lanes-shared.js";
|
|
3
3
|
export declare function resolveAnswerChoice(choice: string, options: string[], freeform: boolean): string;
|
|
4
4
|
export declare function createLaneQuestionsHandlers(deps: LaneQuestionsHandlerDeps): {
|
|
5
|
+
hasPendingQuestionForLane: (ctx: Ctx, lane: LaneRecord) => Promise<boolean>;
|
|
6
|
+
cancelFallbackQuestionsForLane: (ctx: Ctx, lane: LaneRecord, trigger: "message" | "resume", at: string) => Promise<FallbackQuestionCancellationResult>;
|
|
5
7
|
runQuestionsList: (ctx: Ctx, opts: {
|
|
6
8
|
allHosts?: boolean;
|
|
7
9
|
for?: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
|
|
1
|
+
import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionCancelCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
|
|
2
2
|
import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
3
3
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
4
4
|
import { appendCappedHistory, createQuestion, isLanesError, listSessionLiveness, parseLivenessState, parseQuestionRecord, parseQuestionStatus, readDaemonPresenceConnected, readLane, readQuestion, wakeAnsweredLaneNow, sessionLiveness, appendDropboxMessage, answerDropboxMessage, laneAcceptsDropboxDelivery, laneIdForAnswerDelivery, writeQuestionBody } from "../lib/lanes/index.js";
|
|
@@ -20,7 +20,7 @@ export function resolveAnswerChoice(choice, options, freeform) {
|
|
|
20
20
|
throw new LanesError("off_menu_choice", `Choice ${JSON.stringify(choice)} is not on the menu. Pass --freeform to allow it.`, { choice, options });
|
|
21
21
|
}
|
|
22
22
|
export function createLaneQuestionsHandlers(deps) {
|
|
23
|
-
const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
|
|
23
|
+
const { CAPACITY_PROBE_TIMEOUT_MS, MAX_PLACEMENT_OUTPOSTS, OUTPOST_QUESTION_LOOKUP_CONCURRENCY, assertLaneRunsHereForOperatorMutation, assertQuestionAnswerAuthorized, assertQuestionCompanyMatchesLane, casUpdateQuestion, casUpdateLane, effectiveLaneQuestionCompany, emitLaneMeshEvent, groupQuestionLanesByCompany, isQuestionAnswer, laneIsHostedHere, livenessDepsFromCtx, loadAllLanes, loadAllQuestions, mapWithConcurrency, mintMessageId, parseOutpostQuestionAudience, parseOutpostQuestionBody, parseRemoteJson, parseSeniorRef, questionCompanyForLane, questionCompanyRefMatches, questionListCompanyArg, readOutpostQuestionRecords, readQuestionBody, printJson, readSessionMeta, reconcileHostFromCtx, resolveActiveQuestionCompany, resolveAudience, sameQuestionAnswer, sameQuestionCompany, seniorOfSession, writeAnswerDeliveryFailed } = deps;
|
|
24
24
|
function requireQuestionBody(hqRoot, question) {
|
|
25
25
|
const body = readQuestionBody(hqRoot, question);
|
|
26
26
|
if (!body) {
|
|
@@ -52,6 +52,16 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
52
52
|
return audience.kind === "user";
|
|
53
53
|
return audience.kind === filter.kind && audience.id === filter.id;
|
|
54
54
|
}
|
|
55
|
+
function questionAudienceMatchesSenior(audience, lane, ctx) {
|
|
56
|
+
if (audienceMatches(audience, lane.senior))
|
|
57
|
+
return true;
|
|
58
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
59
|
+
if (sessionId && audienceMatches(audience, { kind: "session", id: sessionId }))
|
|
60
|
+
return true;
|
|
61
|
+
const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
|
|
62
|
+
return callerLaneId !== undefined && callerLaneId.length > 0 &&
|
|
63
|
+
audienceMatches(audience, { kind: "lane", id: callerLaneId });
|
|
64
|
+
}
|
|
55
65
|
function parseForFilter(raw, ctx) {
|
|
56
66
|
const trimmed = raw.trim();
|
|
57
67
|
if (trimmed === "me") {
|
|
@@ -384,6 +394,115 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
384
394
|
errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()],
|
|
385
395
|
};
|
|
386
396
|
}
|
|
397
|
+
async function cancelFallbackQuestionsForLane(ctx, lane, trigger, at) {
|
|
398
|
+
const hqRoot = ctx.hqRoot();
|
|
399
|
+
const caller = ctx.env.HQ_LANE_ID?.trim() || sessionIdFromEnv(ctx.env) || "user";
|
|
400
|
+
const reason = `Fallback ${trigger} by ${caller} at ${at}`;
|
|
401
|
+
const failures = [];
|
|
402
|
+
const cancelledQuestionIds = [];
|
|
403
|
+
const remoteOwner = lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane);
|
|
404
|
+
let pending;
|
|
405
|
+
try {
|
|
406
|
+
pending = remoteOwner
|
|
407
|
+
? (await readOutpostQuestionRecords(ctx, lane.host.id, effectiveLaneQuestionCompany(lane, hqRoot)))
|
|
408
|
+
.map(({ question }) => question)
|
|
409
|
+
.filter((question) => question.origin.id === lane.lane_id && question.status === "pending")
|
|
410
|
+
: loadAllQuestions(hqRoot).filter((question) => question.origin.id === lane.lane_id && question.status === "pending");
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
failures.push({ error: dispatchErrorMessage(err) });
|
|
414
|
+
pending = [];
|
|
415
|
+
}
|
|
416
|
+
for (const question of pending) {
|
|
417
|
+
try {
|
|
418
|
+
if (remoteOwner) {
|
|
419
|
+
const outpostId = lane.host.id;
|
|
420
|
+
const dispatched = await ctx.dispatchOutpostExec({
|
|
421
|
+
outpostId,
|
|
422
|
+
command: buildOutpostQuestionCancelCommand({ questionId: question.question_id, reason }),
|
|
423
|
+
timeoutSeconds: Math.ceil(CAPACITY_PROBE_TIMEOUT_MS / 1000),
|
|
424
|
+
waitMs: CAPACITY_PROBE_TIMEOUT_MS,
|
|
425
|
+
});
|
|
426
|
+
const remote = parseRemoteJson(dispatched.stdout, "Outpost question cancel");
|
|
427
|
+
const remoteQuestion = remote?.question !== null && typeof remote?.question === "object"
|
|
428
|
+
? remote.question
|
|
429
|
+
: null;
|
|
430
|
+
if (dispatched.done !== true || dispatched.status !== "Success" || dispatched.exitCode !== 0 ||
|
|
431
|
+
remote?.ok !== true || remote.action !== "questions.cancel" ||
|
|
432
|
+
remoteQuestion?.question_id !== question.question_id || remoteQuestion.status !== "cancelled") {
|
|
433
|
+
throw new LanesError("outpost_question_cancel_unconfirmed", `Outpost ${outpostId} did not confirm cancellation of question ${question.question_id}`, { lane_id: lane.lane_id, question_id: question.question_id, outpost_id: outpostId });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
const cancellationEntry = {
|
|
438
|
+
hop: question.hop,
|
|
439
|
+
audience: resolveAudience(hqRoot, question, ctx),
|
|
440
|
+
at,
|
|
441
|
+
reason: "cancelled",
|
|
442
|
+
detail: reason,
|
|
443
|
+
};
|
|
444
|
+
const updated = casUpdateQuestion(hqRoot, question.question_id, (current) => {
|
|
445
|
+
if (current.status !== "pending")
|
|
446
|
+
return current;
|
|
447
|
+
return {
|
|
448
|
+
...current,
|
|
449
|
+
status: "cancelled",
|
|
450
|
+
routing_history: appendCappedHistory(current.routing_history, cancellationEntry),
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
if (updated.status !== "cancelled")
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
cancelledQuestionIds.push(question.question_id);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
failures.push({ question_id: question.question_id, error: dispatchErrorMessage(err) });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (failures.length > 0) {
|
|
463
|
+
const failure = {
|
|
464
|
+
at,
|
|
465
|
+
caller,
|
|
466
|
+
trigger,
|
|
467
|
+
question_ids: failures.flatMap((entry) => entry.question_id ? [entry.question_id] : []),
|
|
468
|
+
error: failures.map((entry) => entry.error).join("; "),
|
|
469
|
+
};
|
|
470
|
+
try {
|
|
471
|
+
casUpdateLane(hqRoot, lane.lane_id, (current) => {
|
|
472
|
+
const history = current.fallback_question_cancellation_failures ?? [];
|
|
473
|
+
const duplicate = history.some((entry) => entry.at === failure.at && entry.caller === caller && entry.trigger === trigger);
|
|
474
|
+
return duplicate
|
|
475
|
+
? current
|
|
476
|
+
: {
|
|
477
|
+
...current,
|
|
478
|
+
fallback_question_cancellation_failures: [...history, failure].slice(-20),
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
const message = `Could not record fallback question cancellation failure for lane ${lane.lane_id}: ${dispatchErrorMessage(err)}\n`;
|
|
484
|
+
process.stderr.write(message);
|
|
485
|
+
failures.push({ error: `Failure record could not be persisted: ${dispatchErrorMessage(err)}` });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return { cancelled_question_ids: cancelledQuestionIds, failures };
|
|
489
|
+
}
|
|
490
|
+
async function hasPendingQuestionForLane(ctx, lane) {
|
|
491
|
+
const hqRoot = ctx.hqRoot();
|
|
492
|
+
const local = loadAllQuestions(hqRoot).some((question) => question.lane_id === lane.lane_id &&
|
|
493
|
+
question.status === "pending" &&
|
|
494
|
+
questionAudienceMatchesSenior(resolveAudience(hqRoot, question, ctx), lane, ctx));
|
|
495
|
+
if (local)
|
|
496
|
+
return true;
|
|
497
|
+
if (lane.host.kind !== "outpost" || laneIsHostedHere(ctx, lane))
|
|
498
|
+
return false;
|
|
499
|
+
// Reuse the owner lookup used by questions list/answer. An unreachable
|
|
500
|
+
// owner leaves no answerable question, so the message/resume path can recover.
|
|
501
|
+
const lookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
|
|
502
|
+
return lookup.questions.some(({ question, lane: ownerLane, audience }) => ownerLane.lane_id === lane.lane_id &&
|
|
503
|
+
question.status === "pending" &&
|
|
504
|
+
questionAudienceMatchesSenior(audience, ownerLane, ctx));
|
|
505
|
+
}
|
|
387
506
|
function syncPrimaryQuestionDelivery(hqRoot, owner) {
|
|
388
507
|
const deliveredAt = owner.question.delivered_at;
|
|
389
508
|
if (!deliveredAt)
|
|
@@ -870,6 +989,8 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
870
989
|
});
|
|
871
990
|
}
|
|
872
991
|
return {
|
|
992
|
+
hasPendingQuestionForLane,
|
|
993
|
+
cancelFallbackQuestionsForLane,
|
|
873
994
|
runQuestionsList,
|
|
874
995
|
runQuestionsShow,
|
|
875
996
|
runQuestionsAnswer,
|
|
@@ -75,7 +75,8 @@ export function createLaneResumeHandlers(deps) {
|
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
77
|
* `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
|
|
78
|
-
* continues a lane in `interrupted`, `done` or `blocked`,
|
|
78
|
+
* continues a lane in `interrupted`, `done` or `blocked`, an `awaiting_input`
|
|
79
|
+
* lane with no answerable pending question, or a failed lane
|
|
79
80
|
* whose only terminal reason is an invalid envelope after its repair turn,
|
|
80
81
|
* on its recorded
|
|
81
82
|
* provider session. `hq lanes message` keeps refusing `interrupted`; this
|
|
@@ -124,12 +125,21 @@ export function createLaneResumeHandlers(deps) {
|
|
|
124
125
|
throw new LanesError("worker_profile_hash_mismatch", "Profile re-pin context is only valid on an Outpost owner dispatch", { lane_id: laneId });
|
|
125
126
|
}
|
|
126
127
|
if (resolution.remoteOnly || (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane))) {
|
|
128
|
+
const fallbackAt = lane.state === "awaiting_input" ? ctx.now().toISOString() : undefined;
|
|
127
129
|
if (!resolution.remoteOnly) {
|
|
128
130
|
await withResumeHireAdmission(ctx, lane, () => undefined);
|
|
129
131
|
}
|
|
130
132
|
await dispatchOutpostLaneCommand(ctx, lane, "resume", opts, resolution.remoteOnly
|
|
131
|
-
? {
|
|
132
|
-
|
|
133
|
+
? {
|
|
134
|
+
syncPrimary: false,
|
|
135
|
+
expectedOwnerHost: resolution.ownerHost,
|
|
136
|
+
...(fallbackAt === undefined ? {} : {
|
|
137
|
+
afterFallbackResume: () => deps.cancelFallbackQuestionsForLane(ctx, lane, "resume", fallbackAt),
|
|
138
|
+
}),
|
|
139
|
+
}
|
|
140
|
+
: fallbackAt === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: { afterFallbackResume: () => deps.cancelFallbackQuestionsForLane(ctx, lane, "resume", fallbackAt) });
|
|
133
143
|
return;
|
|
134
144
|
}
|
|
135
145
|
// Fail before provider/session work if a terminal child would consume a
|
|
@@ -171,7 +181,10 @@ export function createLaneResumeHandlers(deps) {
|
|
|
171
181
|
opts.compact === false)) {
|
|
172
182
|
throw new LanesError("worker_profile_recovery_options_invalid", "Queued worker-profile recovery accepts only --worker; rerun hq lanes resume after reconcile admits the lane", { lane_id: laneId });
|
|
173
183
|
}
|
|
174
|
-
const
|
|
184
|
+
const pendingQuestion = lane.state === "awaiting_input"
|
|
185
|
+
? await deps.hasPendingQuestionForLane(ctx, lane)
|
|
186
|
+
: false;
|
|
187
|
+
const refusal = queuedProfileRecovery || (lane.state === "awaiting_input" && !pendingQuestion)
|
|
175
188
|
? null
|
|
176
189
|
: resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
|
|
177
190
|
if (refusal !== null) {
|
|
@@ -444,6 +457,7 @@ export function createLaneResumeHandlers(deps) {
|
|
|
444
457
|
trigger: {
|
|
445
458
|
kind: "resume",
|
|
446
459
|
from: resumedFrom,
|
|
460
|
+
...(resumedFrom === "awaiting_input" && !pendingQuestion ? { allowAwaitingInput: true } : {}),
|
|
447
461
|
...(opts.fromFailed === true ? { fromFailed: true } : {}),
|
|
448
462
|
...(failedResumeExpectedSenior !== undefined
|
|
449
463
|
? { expectedSenior: failedResumeExpectedSenior }
|
|
@@ -460,6 +474,9 @@ export function createLaneResumeHandlers(deps) {
|
|
|
460
474
|
}, hireGrantContext);
|
|
461
475
|
}, ctx.sleep);
|
|
462
476
|
}
|
|
477
|
+
const questionCancellation = resumedFrom === "awaiting_input" && !pendingQuestion
|
|
478
|
+
? await deps.cancelFallbackQuestionsForLane(ctx, lane, "resume", at)
|
|
479
|
+
: null;
|
|
463
480
|
if (deliveryText !== undefined && messageId !== undefined) {
|
|
464
481
|
// Same audit copy a message resume leaves; nothing drains it. Archive only
|
|
465
482
|
// after a successful spawn so a concurrent resume cannot leave a phantom.
|
|
@@ -519,6 +536,10 @@ export function createLaneResumeHandlers(deps) {
|
|
|
519
536
|
story_id: next.story_id,
|
|
520
537
|
stories: laneStories(next),
|
|
521
538
|
story_warning: storyWarning(next) ?? null,
|
|
539
|
+
...(questionCancellation === null ? {} : {
|
|
540
|
+
cancelled_questions: questionCancellation.cancelled_question_ids,
|
|
541
|
+
question_cancellation_failures: questionCancellation.failures,
|
|
542
|
+
}),
|
|
522
543
|
lane: next,
|
|
523
544
|
});
|
|
524
545
|
}
|
|
@@ -137,15 +137,26 @@ export type CommandLaneResolution = {
|
|
|
137
137
|
outpostId: string;
|
|
138
138
|
ownerHost: LaneRecord["host"];
|
|
139
139
|
};
|
|
140
|
+
export interface FallbackQuestionCancellationResult {
|
|
141
|
+
cancelled_question_ids: string[];
|
|
142
|
+
failures: Array<{
|
|
143
|
+
question_id?: string;
|
|
144
|
+
error: string;
|
|
145
|
+
}>;
|
|
146
|
+
}
|
|
140
147
|
export interface OutpostLaneDispatchBehavior {
|
|
141
148
|
/** Keep remote-only lanes out of the primary lane store. */
|
|
142
149
|
syncPrimary?: boolean;
|
|
143
150
|
/** Host identity returned by the remote record lookup for a remote-only lane. */
|
|
144
151
|
expectedOwnerHost?: LaneRecord["host"];
|
|
152
|
+
/** Run after a successful remote awaiting_input fallback, before printing. */
|
|
153
|
+
afterFallbackResume?: () => Promise<FallbackQuestionCancellationResult>;
|
|
145
154
|
}
|
|
146
155
|
export interface LaneMessageHandlerDeps {
|
|
147
156
|
/** Test seam invoked after a provider worker has been started. */
|
|
148
157
|
onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
|
|
158
|
+
/** Test seam invoked after a message has been durably queued for a lane. */
|
|
159
|
+
onLaneMessageQueued?: (laneId: string, path: "admission" | "account_move") => void;
|
|
149
160
|
archiveResumeMessage(hqRoot: string, laneId: string, message: {
|
|
150
161
|
message_id: string;
|
|
151
162
|
[key: string]: unknown;
|
|
@@ -175,6 +186,8 @@ export interface LaneMessageHandlerDeps {
|
|
|
175
186
|
recordResumeSetup(ctx: Ctx, lane: LaneRecord, input: RecordResumeSetupInput): LaneRecord;
|
|
176
187
|
resolveCommandLane(ctx: Ctx, laneId: string): Promise<CommandLaneResolution>;
|
|
177
188
|
resolveActiveQuestionCompany(ctx: Ctx, message?: string): Promise<LaneCompanyRef>;
|
|
189
|
+
hasPendingQuestionForLane(ctx: Ctx, lane: LaneRecord): Promise<boolean>;
|
|
190
|
+
cancelFallbackQuestionsForLane(ctx: Ctx, lane: LaneRecord, trigger: "message" | "resume", at: string): Promise<FallbackQuestionCancellationResult>;
|
|
178
191
|
resolveResumeProfile(ctx: Ctx, lane: LaneRecord, workerId: string): ResolvedResumeProfile;
|
|
179
192
|
selectAutomaticClaudeResumeAccount(ctx: Ctx, lane: LaneRecord, workerProfile?: ResolvedResumeProfile, options?: {
|
|
180
193
|
repairUnavailableAccount?: boolean;
|
|
@@ -186,7 +199,7 @@ export interface LaneMessageHandlerDeps {
|
|
|
186
199
|
validateResumeProfileModel(ctx: Ctx, lane: LaneRecord, profile: WorkerProfile): void;
|
|
187
200
|
withResumeHireAdmission<T>(ctx: Ctx, lane: LaneRecord, fn: () => T, grantContext?: OutpostHireGrantContext): Promise<T>;
|
|
188
201
|
}
|
|
189
|
-
export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "resolveResumeProfile" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
|
|
202
|
+
export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "onLaneWorkerStarted" | "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "hasPendingQuestionForLane" | "cancelFallbackQuestionsForLane" | "resolveResumeProfile" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
|
|
190
203
|
dispatchOutpostLaneCommand(ctx: Ctx, lane: LaneRecord, action: "resume", options: LaneResumeOptions, behavior?: OutpostLaneDispatchBehavior): Promise<void>;
|
|
191
204
|
authorizeFailedResume(ctx: Ctx, lane: LaneRecord, encodedContext?: string): string;
|
|
192
205
|
authorizeResumeProfileRepin(ctx: Ctx, lane: LaneRecord, profile: ResolvedResumeProfile | undefined, encodedContext: string | undefined): ResumeProfileRepinAuthorization | undefined;
|
|
@@ -380,6 +393,7 @@ export interface LaneQuestionsHandlerDeps {
|
|
|
380
393
|
assertQuestionAnswerAuthorized(ctx: Ctx, lane: LaneRecord): void;
|
|
381
394
|
assertQuestionCompanyMatchesLane(question: QuestionRecord, lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">, code: string, hqRoot?: string): void;
|
|
382
395
|
casUpdateQuestion(hqRoot: string, questionId: string, updater: (current: QuestionRecord) => QuestionRecord): QuestionRecord;
|
|
396
|
+
casUpdateLane(hqRoot: string, laneId: string, updater: (current: LaneRecord) => LaneRecord): LaneRecord;
|
|
383
397
|
effectiveLaneQuestionCompany(lane: Pick<LaneRecord, "company" | "mesh_company" | "project_id" | "lane_id">, hqRoot?: string): LaneCompanyRef | null;
|
|
384
398
|
emitLaneMeshEvent(ctx: Ctx, company: LaneCompanyRef | null | undefined, threadId: string | undefined, event: LaneMeshEvent, payload: Record<string, unknown>): Promise<void>;
|
|
385
399
|
groupQuestionLanesByCompany(hqRoot: string, lanes: LaneRecord[], fallbackCompany?: LaneCompanyRef): QuestionCompanyGroup[];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { accountStatus, coveredLaneIds, loadLanesConfig, MAX_WATCHED_LANES, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, monitorReminderText, runLaneWatch, } from "../lib/lanes/index.js";
|
|
2
|
-
import { LEAD_LANE_KEEP_ALIVE_FLAG } from "../lib/lanes/keep-alive.js";
|
|
2
|
+
import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_FLAG } from "../lib/lanes/keep-alive.js";
|
|
3
3
|
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
4
4
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
5
5
|
import { isLanesError, LanesError } from "../lib/lanes/errors.js";
|
|
@@ -50,6 +50,10 @@ export function createLaneWatchHandlers(deps) {
|
|
|
50
50
|
company: resolveLaneCompany(hqRoot, lane).company ?? undefined,
|
|
51
51
|
env: ctx.env,
|
|
52
52
|
}),
|
|
53
|
+
keepAliveNextCheckAtEnabled: (lane) => readLanesRegistryFlag(LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_FLAG, {
|
|
54
|
+
company: resolveLaneCompany(hqRoot, lane).company ?? undefined,
|
|
55
|
+
env: ctx.env,
|
|
56
|
+
}),
|
|
53
57
|
readAccountStatuses: async (lanes) => {
|
|
54
58
|
if (lanes.length === 0)
|
|
55
59
|
return [];
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export { emitLaneDecisionEvents };
|
|
|
31
31
|
export interface LanesCommandDeps {
|
|
32
32
|
/** Test seam for failures after a lane provider worker is started. */
|
|
33
33
|
onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
|
|
34
|
+
/** Test seam for failures after a message is durably queued. */
|
|
35
|
+
onLaneMessageQueued?: (laneId: string, path: "admission" | "account_move") => void;
|
|
34
36
|
hqRoot?: () => string;
|
|
35
37
|
/** Start HQ-root walk here instead of process.cwd(). Tests only. */
|
|
36
38
|
cwd?: () => string;
|