@indigoai-us/hq-cli 5.299.0 → 5.300.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 +6 -0
- package/dist/command-catalog.generated.d.ts +4 -4
- package/dist/command-catalog.generated.js +4 -4
- package/dist/commands/cloud.js +12 -7
- package/dist/commands/files.d.ts +1 -0
- package/dist/commands/files.js +59 -0
- package/dist/commands/lanes-message.js +68 -35
- package/dist/commands/lanes-resume.js +247 -210
- package/dist/commands/lanes-shared.d.ts +3 -1
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +6 -4
- package/dist/lib/lanes/reconcile.js +22 -4
- package/dist/lib/lanes/story-switch.d.ts +21 -0
- package/dist/lib/lanes/story-switch.js +75 -0
- package/dist/lib/lanes/types.d.ts +25 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.js +15 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.300.0] — 2026-09-30
|
|
6
|
+
- With the default-off hq-flags gate `files.share-empty-prefix-warning`, `hq files share` warns when a granted prefix has no files. A failed or timed-out list check does not block the grant.
|
|
7
|
+
|
|
8
|
+
- Lane messages and resumes that switch project or story attribution now tell the provider which story is active. Reused completion artifacts no longer close the new story.
|
|
9
|
+
- Make signed-out `hq sync` commands fail fast with `hq login` guidance when stdin or stdout is not a terminal, while keeping browser login in a terminal.
|
|
10
|
+
|
|
5
11
|
## [5.299.0] — 2026-09-30
|
|
6
12
|
- Add `hq lanes accounts clear-quota` and clear future quota marks after successful lane turns.
|
|
7
13
|
- On macOS, `hq daemon` retires a loaded legacy mesh LaunchAgent even when its plist is missing, and daemon uninstall boots the job out before deleting its plist. Mesh-owner wait logs now repeat at most every ten minutes.
|
|
@@ -8509,10 +8509,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8509
8509
|
readonly description: "Message text";
|
|
8510
8510
|
}, {
|
|
8511
8511
|
readonly flags: "--project <slug>";
|
|
8512
|
-
readonly description: "Re-point project attribution
|
|
8512
|
+
readonly description: "Re-point project attribution; a changed story is named in the resumed prompt";
|
|
8513
8513
|
}, {
|
|
8514
8514
|
readonly flags: "--story <id>";
|
|
8515
|
-
readonly description: "Re-point story attribution
|
|
8515
|
+
readonly description: "Re-point story attribution; a changed story is named in the resumed prompt";
|
|
8516
8516
|
}, {
|
|
8517
8517
|
readonly flags: "--account <id>";
|
|
8518
8518
|
readonly description: "Move the lane's session to this account of its provider before the message resumes it";
|
|
@@ -8602,10 +8602,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8602
8602
|
readonly description: "Required explanation for resuming from failed";
|
|
8603
8603
|
}, {
|
|
8604
8604
|
readonly flags: "--project <slug>";
|
|
8605
|
-
readonly description: "Re-point project attribution
|
|
8605
|
+
readonly description: "Re-point project attribution; a changed story is named in the resumed prompt";
|
|
8606
8606
|
}, {
|
|
8607
8607
|
readonly flags: "--story <id>";
|
|
8608
|
-
readonly description: "Re-point story attribution
|
|
8608
|
+
readonly description: "Re-point story attribution; a changed story is named in the resumed prompt";
|
|
8609
8609
|
}, {
|
|
8610
8610
|
readonly flags: "--account <id>";
|
|
8611
8611
|
readonly description: "Move the lane's session to this account of its provider before resuming";
|
|
@@ -10972,11 +10972,11 @@ export const COMMAND_CATALOG = [
|
|
|
10972
10972
|
},
|
|
10973
10973
|
{
|
|
10974
10974
|
"flags": "--project <slug>",
|
|
10975
|
-
"description": "Re-point project attribution
|
|
10975
|
+
"description": "Re-point project attribution; a changed story is named in the resumed prompt"
|
|
10976
10976
|
},
|
|
10977
10977
|
{
|
|
10978
10978
|
"flags": "--story <id>",
|
|
10979
|
-
"description": "Re-point story attribution
|
|
10979
|
+
"description": "Re-point story attribution; a changed story is named in the resumed prompt"
|
|
10980
10980
|
},
|
|
10981
10981
|
{
|
|
10982
10982
|
"flags": "--account <id>",
|
|
@@ -11094,11 +11094,11 @@ export const COMMAND_CATALOG = [
|
|
|
11094
11094
|
},
|
|
11095
11095
|
{
|
|
11096
11096
|
"flags": "--project <slug>",
|
|
11097
|
-
"description": "Re-point project attribution
|
|
11097
|
+
"description": "Re-point project attribution; a changed story is named in the resumed prompt"
|
|
11098
11098
|
},
|
|
11099
11099
|
{
|
|
11100
11100
|
"flags": "--story <id>",
|
|
11101
|
-
"description": "Re-point story attribution
|
|
11101
|
+
"description": "Re-point story attribution; a changed story is named in the resumed prompt"
|
|
11102
11102
|
},
|
|
11103
11103
|
{
|
|
11104
11104
|
"flags": "--account <id>",
|
package/dist/commands/cloud.js
CHANGED
|
@@ -53,6 +53,11 @@ export class SyncExitError extends Error {
|
|
|
53
53
|
this.health = opts.health ?? "failed";
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
async function ensureSyncCognitoToken() {
|
|
57
|
+
return ensureCognitoToken({
|
|
58
|
+
interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
56
61
|
/**
|
|
57
62
|
* Preserve the vault's typed write-scope refusal at the CLI boundary. This is
|
|
58
63
|
* deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
|
|
@@ -845,11 +850,11 @@ export function registerCloudCommands(program) {
|
|
|
845
850
|
}
|
|
846
851
|
// Upload creds are pre-vended in entityContext, but prefix filtering
|
|
847
852
|
// still needs vault-service membership/grant resolution.
|
|
848
|
-
const accessToken = await
|
|
853
|
+
const accessToken = await ensureSyncCognitoToken();
|
|
849
854
|
scopeVaultConfig = buildVaultConfig(accessToken);
|
|
850
855
|
}
|
|
851
856
|
else {
|
|
852
|
-
const accessToken = await
|
|
857
|
+
const accessToken = await ensureSyncCognitoToken();
|
|
853
858
|
vaultConfig = buildVaultConfig(accessToken);
|
|
854
859
|
scopeVaultConfig = vaultConfig;
|
|
855
860
|
}
|
|
@@ -1064,7 +1069,7 @@ export function registerCloudCommands(program) {
|
|
|
1064
1069
|
console.log(chalk.bold("\nHQ Sync — Pull"));
|
|
1065
1070
|
console.log(` HQ root: ${options.hqRoot}`);
|
|
1066
1071
|
console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
|
|
1067
|
-
const accessToken = await
|
|
1072
|
+
const accessToken = await ensureSyncCognitoToken();
|
|
1068
1073
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
1069
1074
|
// US-011 (2026-05-21 fix): resolve the caller's sync-config for
|
|
1070
1075
|
// the targeted membership BEFORE the pull, so we can (a) emit the
|
|
@@ -1368,7 +1373,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
1368
1373
|
console.log("");
|
|
1369
1374
|
let result;
|
|
1370
1375
|
try {
|
|
1371
|
-
const accessToken = await
|
|
1376
|
+
const accessToken = await ensureSyncCognitoToken();
|
|
1372
1377
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
1373
1378
|
const realClient = new VaultClient(vaultConfig);
|
|
1374
1379
|
const adapter = {
|
|
@@ -1461,7 +1466,7 @@ export async function runPullPersonal(hqRoot, onConflict) {
|
|
|
1461
1466
|
console.log(` HQ root: ${hqRoot}`);
|
|
1462
1467
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
1463
1468
|
try {
|
|
1464
|
-
const accessToken = await
|
|
1469
|
+
const accessToken = await ensureSyncCognitoToken();
|
|
1465
1470
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
1466
1471
|
const client = new VaultClient(vaultConfig);
|
|
1467
1472
|
const personalUid = await resolveCanonicalPersonUid({
|
|
@@ -1509,7 +1514,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
|
1509
1514
|
console.log("");
|
|
1510
1515
|
let result;
|
|
1511
1516
|
try {
|
|
1512
|
-
const accessToken = await
|
|
1517
|
+
const accessToken = await ensureSyncCognitoToken();
|
|
1513
1518
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
1514
1519
|
const realClient = new VaultClient(vaultConfig);
|
|
1515
1520
|
const author = resolveUploadAuthor();
|
|
@@ -1637,7 +1642,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1637
1642
|
console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
|
|
1638
1643
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
1639
1644
|
try {
|
|
1640
|
-
const accessToken = await
|
|
1645
|
+
const accessToken = await ensureSyncCognitoToken();
|
|
1641
1646
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
1642
1647
|
const author = resolveUploadAuthor();
|
|
1643
1648
|
// Resolve the target. For --personal, look up the canonical person and
|
package/dist/commands/files.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
export declare const FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG = "files.share-empty-prefix-warning";
|
|
2
3
|
/**
|
|
3
4
|
* Parse a human-friendly duration string ("15m", "1h", "24h", "2d") into
|
|
4
5
|
* milliseconds. Returns null on parse failure. Mirrors the parser used in
|
package/dist/commands/files.js
CHANGED
|
@@ -12,7 +12,10 @@ import { fetchChannels, matchChannelsByName } from "./dm.js";
|
|
|
12
12
|
import { resolveCompanySlug, resolvePersonWithRosterFallback, } from "./people.js";
|
|
13
13
|
import { findHqRoot } from "../utils/manifest.js";
|
|
14
14
|
import { maxLength } from "../utils/table-width.js";
|
|
15
|
+
import { resolveCompanyFlagGate } from "../lib/flag-registry.js";
|
|
15
16
|
const PERSON_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
|
|
17
|
+
export const FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG = "files.share-empty-prefix-warning";
|
|
18
|
+
const FILES_SHARE_EMPTY_PREFIX_CHECK_TIMEOUT_MS = 5_000;
|
|
16
19
|
function collectMentions(value, previous) {
|
|
17
20
|
return [...previous, value];
|
|
18
21
|
}
|
|
@@ -589,6 +592,56 @@ export function registerFilesCommand(program) {
|
|
|
589
592
|
// files-browse.ts) onto the same group without re-creating it.
|
|
590
593
|
return files;
|
|
591
594
|
}
|
|
595
|
+
/** Best-effort, default-off warning after the ACL grant has succeeded. */
|
|
596
|
+
async function warnIfEmptySharePrefix(params) {
|
|
597
|
+
if (isPrivateFolderPattern(params.prefix))
|
|
598
|
+
return;
|
|
599
|
+
let enabled;
|
|
600
|
+
try {
|
|
601
|
+
enabled = await resolveCompanyFlagGate(params.companyUid, params.token, FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG, { fallback: false }, () => false);
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
const errorClass = error instanceof Error ? error.name : "unknown error";
|
|
605
|
+
console.error(`hq: Could not resolve ${FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG} (${errorClass}); using its fallback.`);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (!enabled)
|
|
609
|
+
return;
|
|
610
|
+
const listPrefix = params.fullVault
|
|
611
|
+
? ""
|
|
612
|
+
: params.prefix.endsWith("*")
|
|
613
|
+
? params.prefix.slice(0, -1)
|
|
614
|
+
: params.prefix;
|
|
615
|
+
try {
|
|
616
|
+
const query = {
|
|
617
|
+
company: params.companyUid,
|
|
618
|
+
};
|
|
619
|
+
if (listPrefix)
|
|
620
|
+
query.prefix = listPrefix;
|
|
621
|
+
const listed = await vaultApiFetch({
|
|
622
|
+
token: params.token,
|
|
623
|
+
path: "/v1/files/list",
|
|
624
|
+
query,
|
|
625
|
+
signal: AbortSignal.timeout(FILES_SHARE_EMPTY_PREFIX_CHECK_TIMEOUT_MS),
|
|
626
|
+
});
|
|
627
|
+
if (!listed.ok)
|
|
628
|
+
throw new Error(`files list failed (${listed.status})`);
|
|
629
|
+
const body = (await listed.json());
|
|
630
|
+
const objects = Array.isArray(body.objects) ? body.objects : [];
|
|
631
|
+
const hasMatchingObject = params.fullVault || params.prefix.endsWith("*")
|
|
632
|
+
? objects.length > 0
|
|
633
|
+
: objects.some((object) => object.key === listPrefix);
|
|
634
|
+
if (!hasMatchingObject) {
|
|
635
|
+
console.warn(chalk.yellow(`Warning: no files exist under '${displayAclPattern(params.prefix)}'. ` +
|
|
636
|
+
`The recipient will see nothing until files sync there.`));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
const errorClass = error instanceof Error ? error.name : "unknown error";
|
|
641
|
+
console.warn(chalk.yellow(`Could not check whether '${displayAclPattern(params.prefix)}' has files; ` +
|
|
642
|
+
`the share succeeded (${errorClass}).`));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
592
645
|
async function resolveShareChannelTarget(params) {
|
|
593
646
|
const token = (await resolveVaultCredential()).token;
|
|
594
647
|
const channels = await fetchChannels(token);
|
|
@@ -757,6 +810,12 @@ async function runDirectGrant(params) {
|
|
|
757
810
|
}
|
|
758
811
|
const data = (await res.json());
|
|
759
812
|
const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
|
|
813
|
+
await warnIfEmptySharePrefix({
|
|
814
|
+
token,
|
|
815
|
+
companyUid,
|
|
816
|
+
prefix: canonicalPrefix,
|
|
817
|
+
fullVault: params.fullVault === true,
|
|
818
|
+
});
|
|
760
819
|
const verb = autoCreated ? "Created ACL and granted" : "Granted";
|
|
761
820
|
if (params.fullVault) {
|
|
762
821
|
console.log(chalk.green(`${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`));
|
|
@@ -8,6 +8,7 @@ import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
|
|
|
8
8
|
import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages } from "../lib/lanes/dropbox.js";
|
|
9
9
|
import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision } from "../lib/lanes/watcher.js";
|
|
10
10
|
import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
|
|
11
|
+
import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
|
|
11
12
|
import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
|
|
12
13
|
import { keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
|
|
13
14
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
@@ -212,8 +213,11 @@ export function createLaneMessageHandlers(deps) {
|
|
|
212
213
|
})();
|
|
213
214
|
let accepted = false;
|
|
214
215
|
let claimedMessages = [];
|
|
216
|
+
let storySwitchRollback;
|
|
217
|
+
let storySwitchDeliveryStarted = false;
|
|
215
218
|
const markAccepted = () => {
|
|
216
219
|
accepted = true;
|
|
220
|
+
storySwitchDeliveryStarted = true;
|
|
217
221
|
opts.onAccepted?.();
|
|
218
222
|
};
|
|
219
223
|
let text = opts.text;
|
|
@@ -392,19 +396,21 @@ export function createLaneMessageHandlers(deps) {
|
|
|
392
396
|
if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
|
|
393
397
|
validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
|
|
394
398
|
}
|
|
399
|
+
const notice = storySwitchNotice(lane.project_id, lane.story_id, projectId, storyId);
|
|
400
|
+
const promptedText = notice === undefined ? text : `${notice}\n\n${text}`;
|
|
395
401
|
const framedMonitorEvent = opts.monitorEvent === undefined
|
|
396
402
|
? undefined
|
|
397
403
|
: formatMonitorEvent({
|
|
398
404
|
monitorId: opts.monitorEvent.monitor_id,
|
|
399
405
|
description: opts.monitorEvent.description,
|
|
400
406
|
sequence: opts.monitorEvent.sequence,
|
|
401
|
-
text,
|
|
407
|
+
text: promptedText,
|
|
402
408
|
});
|
|
403
|
-
const deliveryText = framedMonitorEvent ??
|
|
409
|
+
const deliveryText = framedMonitorEvent ?? promptedText;
|
|
404
410
|
const dropboxMessage = {
|
|
405
411
|
at,
|
|
406
412
|
message_id: messageId,
|
|
407
|
-
text,
|
|
413
|
+
text: promptedText,
|
|
408
414
|
project_id: projectId,
|
|
409
415
|
story_id: storyId,
|
|
410
416
|
...(opts.monitorEvent === undefined ? {} : {
|
|
@@ -414,6 +420,52 @@ export function createLaneMessageHandlers(deps) {
|
|
|
414
420
|
sequence: opts.monitorEvent.sequence,
|
|
415
421
|
}),
|
|
416
422
|
};
|
|
423
|
+
if (projectId !== lane.project_id || storyId !== lane.story_id) {
|
|
424
|
+
lane = casUpdateLane(hqRoot, laneId, (current) => {
|
|
425
|
+
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
426
|
+
if (already)
|
|
427
|
+
return current;
|
|
428
|
+
const existingStory = laneStory(current, projectId, storyId);
|
|
429
|
+
storySwitchRollback = {
|
|
430
|
+
message_id: messageId,
|
|
431
|
+
project_id: current.project_id,
|
|
432
|
+
story_id: current.story_id,
|
|
433
|
+
no_story_reason: current.no_story_reason,
|
|
434
|
+
...(!existingStory ? {
|
|
435
|
+
added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
|
|
436
|
+
} : {}),
|
|
437
|
+
story_switch_unacknowledged: current.story_switch_unacknowledged,
|
|
438
|
+
};
|
|
439
|
+
return {
|
|
440
|
+
...current,
|
|
441
|
+
project_id: projectId,
|
|
442
|
+
story_id: storyId,
|
|
443
|
+
no_story_reason: storyId ? undefined : current.no_story_reason,
|
|
444
|
+
stories: laneStory(current, projectId, storyId)
|
|
445
|
+
? laneStories(current)
|
|
446
|
+
: [
|
|
447
|
+
...laneStories(current),
|
|
448
|
+
{ project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
|
|
449
|
+
],
|
|
450
|
+
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
451
|
+
project_id: projectId,
|
|
452
|
+
story_id: storyId,
|
|
453
|
+
message_id: messageId,
|
|
454
|
+
at,
|
|
455
|
+
}),
|
|
456
|
+
story_switch_pending: {
|
|
457
|
+
previous_project_id: current.project_id,
|
|
458
|
+
previous_story_id: current.story_id,
|
|
459
|
+
project_id: projectId,
|
|
460
|
+
story_id: storyId,
|
|
461
|
+
message_id: messageId,
|
|
462
|
+
at,
|
|
463
|
+
previous_artifact_keys: artifactIdentityKeys(current.last_envelope?.artifacts),
|
|
464
|
+
},
|
|
465
|
+
story_switch_unacknowledged: undefined,
|
|
466
|
+
};
|
|
467
|
+
});
|
|
468
|
+
}
|
|
417
469
|
let resumed = null;
|
|
418
470
|
let move = null;
|
|
419
471
|
let queuedOnAdmission = false;
|
|
@@ -491,6 +543,7 @@ export function createLaneMessageHandlers(deps) {
|
|
|
491
543
|
throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
|
|
492
544
|
}
|
|
493
545
|
appendDropboxMessage(hqRoot, laneId, dropboxMessage);
|
|
546
|
+
storySwitchDeliveryStarted = true;
|
|
494
547
|
return null;
|
|
495
548
|
},
|
|
496
549
|
resume: async () => {
|
|
@@ -565,6 +618,8 @@ export function createLaneMessageHandlers(deps) {
|
|
|
565
618
|
compact: opts.compact !== false,
|
|
566
619
|
effort: resumeEffort ?? recordedResumeEffort(lane),
|
|
567
620
|
});
|
|
621
|
+
storySwitchDeliveryStarted = true;
|
|
622
|
+
deps.onLaneWorkerStarted?.(laneId, "message");
|
|
568
623
|
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
569
624
|
return started;
|
|
570
625
|
}, hireGrantContext);
|
|
@@ -576,41 +631,10 @@ export function createLaneMessageHandlers(deps) {
|
|
|
576
631
|
markAccepted();
|
|
577
632
|
}
|
|
578
633
|
// Every accepted message moves the compaction reference forward.
|
|
579
|
-
|
|
634
|
+
const next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
580
635
|
...resetKeepAliveProgress(current),
|
|
581
636
|
last_message_at: at,
|
|
582
637
|
}));
|
|
583
|
-
if (projectId !== lane.project_id || storyId !== lane.story_id) {
|
|
584
|
-
next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
585
|
-
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
586
|
-
if (already)
|
|
587
|
-
return current;
|
|
588
|
-
return {
|
|
589
|
-
...current,
|
|
590
|
-
project_id: projectId,
|
|
591
|
-
story_id: storyId,
|
|
592
|
-
no_story_reason: storyId ? undefined : current.no_story_reason,
|
|
593
|
-
stories: laneStory(current, projectId, storyId)
|
|
594
|
-
? laneStories(current)
|
|
595
|
-
: [
|
|
596
|
-
...laneStories(current),
|
|
597
|
-
{
|
|
598
|
-
project_id: projectId,
|
|
599
|
-
story_id: storyId,
|
|
600
|
-
status: "in_progress",
|
|
601
|
-
added_at: at,
|
|
602
|
-
updated_at: at,
|
|
603
|
-
},
|
|
604
|
-
],
|
|
605
|
-
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
606
|
-
project_id: projectId,
|
|
607
|
-
story_id: storyId,
|
|
608
|
-
message_id: messageId,
|
|
609
|
-
at,
|
|
610
|
-
}),
|
|
611
|
-
};
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
638
|
// What this command can claim when it returns: the message is durably
|
|
615
639
|
// written (accepted). Delivery to the model is asynchronous in both
|
|
616
640
|
// mechanisms. `dropbox` delivers on the worker's next tool call, and only
|
|
@@ -660,6 +684,15 @@ export function createLaneMessageHandlers(deps) {
|
|
|
660
684
|
});
|
|
661
685
|
}
|
|
662
686
|
catch (err) {
|
|
687
|
+
if (storySwitchRollback !== undefined && !storySwitchDeliveryStarted) {
|
|
688
|
+
try {
|
|
689
|
+
casUpdateLane(hqRoot, laneId, (current) => rollbackStorySwitchAttribution(current, storySwitchRollback));
|
|
690
|
+
}
|
|
691
|
+
catch (rollbackErr) {
|
|
692
|
+
const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
693
|
+
process.stderr.write(`Could not roll back story attribution after message failure: ${rollbackMessage}\n`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
663
696
|
if (claimedMessages.length > 0 && !accepted) {
|
|
664
697
|
const error = err instanceof Error ? err.message : String(err);
|
|
665
698
|
writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
|
|
@@ -5,6 +5,7 @@ import { parseCliEffort } from "../lib/lanes/effort.js";
|
|
|
5
5
|
import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
|
|
6
6
|
import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
7
7
|
import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
|
|
8
|
+
import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
|
|
8
9
|
import { spawnLaneWorker } from "../lib/lanes/spawn.js";
|
|
9
10
|
import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, hasKeepAliveProgress, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
|
|
10
11
|
export function createLaneResumeHandlers(deps) {
|
|
@@ -195,6 +196,8 @@ export function createLaneResumeHandlers(deps) {
|
|
|
195
196
|
}
|
|
196
197
|
const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
|
|
197
198
|
const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
|
|
199
|
+
const notice = storySwitchNotice(lane.project_id, lane.story_id, projectId, storyId);
|
|
200
|
+
const deliveryText = notice === undefined ? text : `${notice}${text === undefined ? "" : `\n\n${text}`}`;
|
|
198
201
|
let requestedMeshCompany;
|
|
199
202
|
let meshThreadId;
|
|
200
203
|
if (opts.meshCompany !== undefined) {
|
|
@@ -253,41 +256,60 @@ export function createLaneResumeHandlers(deps) {
|
|
|
253
256
|
account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.selection.account,
|
|
254
257
|
model: opts.model,
|
|
255
258
|
});
|
|
256
|
-
let
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
...(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
259
|
+
let storySwitchRollback;
|
|
260
|
+
let storySwitchWorkerStarted = false;
|
|
261
|
+
if (messageId !== undefined && attributionChanged) {
|
|
262
|
+
lane = casUpdateLane(hqRoot, laneId, (current) => {
|
|
263
|
+
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
264
|
+
if (already)
|
|
265
|
+
return current;
|
|
266
|
+
const existingStory = laneStory(current, projectId, storyId);
|
|
267
|
+
storySwitchRollback = {
|
|
268
|
+
message_id: messageId,
|
|
269
|
+
project_id: current.project_id,
|
|
270
|
+
story_id: current.story_id,
|
|
271
|
+
no_story_reason: current.no_story_reason,
|
|
272
|
+
...(!existingStory ? {
|
|
273
|
+
added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
|
|
274
|
+
} : {}),
|
|
275
|
+
story_switch_unacknowledged: current.story_switch_unacknowledged,
|
|
276
|
+
};
|
|
277
|
+
return {
|
|
278
|
+
...current,
|
|
279
|
+
project_id: projectId,
|
|
280
|
+
story_id: storyId,
|
|
281
|
+
no_story_reason: storyId ? undefined : current.no_story_reason,
|
|
282
|
+
stories: laneStory(current, projectId, storyId)
|
|
283
|
+
? laneStories(current)
|
|
284
|
+
: [
|
|
285
|
+
...laneStories(current),
|
|
286
|
+
{ project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
|
|
287
|
+
],
|
|
288
|
+
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
289
|
+
project_id: projectId,
|
|
290
|
+
story_id: storyId,
|
|
291
|
+
message_id: messageId,
|
|
292
|
+
at,
|
|
293
|
+
}),
|
|
294
|
+
story_switch_pending: {
|
|
295
|
+
previous_project_id: current.project_id,
|
|
296
|
+
previous_story_id: current.story_id,
|
|
297
|
+
project_id: projectId,
|
|
298
|
+
story_id: storyId,
|
|
299
|
+
message_id: messageId,
|
|
300
|
+
at,
|
|
301
|
+
previous_artifact_keys: artifactIdentityKeys(current.last_envelope?.artifacts),
|
|
302
|
+
},
|
|
303
|
+
story_switch_unacknowledged: undefined,
|
|
304
|
+
};
|
|
276
305
|
});
|
|
277
|
-
lane = move.lane;
|
|
278
306
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
307
|
+
try {
|
|
308
|
+
let move = null;
|
|
309
|
+
if (opts.account !== undefined && !keepCurrentAccount) {
|
|
282
310
|
move = await prepareAccountMove(ctx, lane, {
|
|
283
|
-
account:
|
|
284
|
-
cliModel: undefined,
|
|
285
|
-
...(automaticAccountSelection.historyReason !== undefined
|
|
286
|
-
? { accountHistoryReason: automaticAccountSelection.historyReason }
|
|
287
|
-
: {}),
|
|
288
|
-
...(automaticAccountSelection.preservedModel !== undefined
|
|
289
|
-
? { preservedModel: automaticAccountSelection.preservedModel }
|
|
290
|
-
: {}),
|
|
311
|
+
account: opts.account,
|
|
312
|
+
cliModel: opts.model?.trim() || undefined,
|
|
291
313
|
...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
|
|
292
314
|
...(profileRepin !== undefined ? { profileRepin } : {}),
|
|
293
315
|
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
@@ -299,204 +321,219 @@ export function createLaneResumeHandlers(deps) {
|
|
|
299
321
|
...(failedResumeExpectedSenior !== undefined
|
|
300
322
|
? { expected_senior: failedResumeExpectedSenior }
|
|
301
323
|
: {}),
|
|
302
|
-
...(
|
|
324
|
+
...(deliveryText !== undefined ? { text: deliveryText } : {}),
|
|
303
325
|
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
304
326
|
compact: opts.compact !== false,
|
|
305
327
|
},
|
|
306
|
-
accountSelection: selection,
|
|
307
328
|
});
|
|
308
329
|
lane = move.lane;
|
|
309
330
|
}
|
|
310
|
-
else {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
resumed = await withLaneAdmissionLock(hqRoot, lane.provider, async () => {
|
|
331
|
-
const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
|
|
332
|
-
if (admission.action === "refuse" && admission.error)
|
|
333
|
-
throw admission.error;
|
|
334
|
-
return withResumeHireAdmission(ctx, lane, () => {
|
|
335
|
-
if (admission.action === "queue") {
|
|
336
|
-
const sessionId = lane.provider_session_id;
|
|
337
|
-
if (!sessionId) {
|
|
338
|
-
throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this resume`, {
|
|
339
|
-
lane_id: laneId,
|
|
340
|
-
provider: lane.provider,
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
queuedOnAdmission = true;
|
|
344
|
-
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
345
|
-
if (current.version !== lane.version || current.state !== lane.state) {
|
|
346
|
-
throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while resume admission was checked`, {
|
|
347
|
-
lane_id: laneId,
|
|
348
|
-
prepared_version: lane.version,
|
|
349
|
-
version: current.version,
|
|
350
|
-
state: current.state,
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
return {
|
|
354
|
-
...current,
|
|
355
|
-
state: "queued",
|
|
356
|
-
queued_reason: admission.reason ?? "throttle",
|
|
357
|
-
queued_details: undefined,
|
|
358
|
-
queued_at: at,
|
|
359
|
-
pending_resume: {
|
|
360
|
-
session_id: sessionId,
|
|
361
|
-
trigger: "resume",
|
|
362
|
-
caller: ctx.env.HQ_LANE_ID?.trim() || "user",
|
|
363
|
-
resumed_from: resumedFrom,
|
|
364
|
-
...(opts.fromFailed === true ? { from_failed: true } : {}),
|
|
365
|
-
...(failedResumeExpectedSenior !== undefined
|
|
366
|
-
? { expected_senior: failedResumeExpectedSenior }
|
|
367
|
-
: {}),
|
|
368
|
-
...(text !== undefined ? { text } : {}),
|
|
369
|
-
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
370
|
-
compact: opts.compact !== false,
|
|
371
|
-
at,
|
|
372
|
-
},
|
|
373
|
-
};
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
return spawnLaneWorker(hqRoot, lane, {
|
|
377
|
-
env: ctx.env,
|
|
378
|
-
modelDeps: {
|
|
379
|
-
readFileSync: ctx.readFileSync,
|
|
380
|
-
probe: ctx.probeProvider,
|
|
381
|
-
env: ctx.env,
|
|
382
|
-
},
|
|
383
|
-
existsSync: ctx.existsSync,
|
|
384
|
-
spawnProcess: ctx.spawnProcess,
|
|
385
|
-
proveDetached: ctx.proveDetached,
|
|
386
|
-
killProcess: ctx.killProcess,
|
|
387
|
-
now: ctx.now,
|
|
388
|
-
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
389
|
-
runCompaction: ctx.runCompaction,
|
|
390
|
-
locateTranscript: ctx.locateTranscript,
|
|
391
|
-
}, {
|
|
392
|
-
trigger: {
|
|
393
|
-
kind: "resume",
|
|
394
|
-
from: resumedFrom,
|
|
395
|
-
...(opts.fromFailed === true ? { fromFailed: true } : {}),
|
|
331
|
+
else if (automaticAccountSelection !== undefined) {
|
|
332
|
+
const selection = automaticAccountSelection.selection;
|
|
333
|
+
if (selection.account !== lane.account) {
|
|
334
|
+
move = await prepareAccountMove(ctx, lane, {
|
|
335
|
+
account: selection.account,
|
|
336
|
+
cliModel: undefined,
|
|
337
|
+
...(automaticAccountSelection.historyReason !== undefined
|
|
338
|
+
? { accountHistoryReason: automaticAccountSelection.historyReason }
|
|
339
|
+
: {}),
|
|
340
|
+
...(automaticAccountSelection.preservedModel !== undefined
|
|
341
|
+
? { preservedModel: automaticAccountSelection.preservedModel }
|
|
342
|
+
: {}),
|
|
343
|
+
...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
|
|
344
|
+
...(profileRepin !== undefined ? { profileRepin } : {}),
|
|
345
|
+
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
346
|
+
...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
|
|
347
|
+
pending: {
|
|
348
|
+
trigger: "resume",
|
|
349
|
+
resumed_from: resumedFrom,
|
|
350
|
+
...(opts.fromFailed === true ? { from_failed: true } : {}),
|
|
396
351
|
...(failedResumeExpectedSenior !== undefined
|
|
397
|
-
? {
|
|
352
|
+
? { expected_senior: failedResumeExpectedSenior }
|
|
398
353
|
: {}),
|
|
399
|
-
...(
|
|
354
|
+
...(deliveryText !== undefined ? { text: deliveryText } : {}),
|
|
400
355
|
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
356
|
+
compact: opts.compact !== false,
|
|
401
357
|
},
|
|
402
|
-
|
|
403
|
-
effort: resumeEffort ?? recordedResumeEffort(lane),
|
|
358
|
+
accountSelection: selection,
|
|
404
359
|
});
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
360
|
+
lane = move.lane;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
lane = persistClaudeAccountSelection(hqRoot, lane, selection);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
lane = recordResumeSetup(ctx, lane, {
|
|
367
|
+
...(move === null && resumeProfile !== undefined ? { profile: resumeProfile } : {}),
|
|
368
|
+
...(move === null && profileRepin !== undefined ? { profileRepin } : {}),
|
|
369
|
+
...(move === null && resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
370
|
+
...(keepAlive !== undefined ? { keepAlive } : {}),
|
|
371
|
+
...(move === null && keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
|
|
372
|
+
...(requestedMeshCompany !== undefined
|
|
373
|
+
? { meshCompany: requestedMeshCompany, threadId: meshThreadId }
|
|
374
|
+
: {}),
|
|
418
375
|
});
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
376
|
+
let queuedOnAdmission = false;
|
|
377
|
+
let resumed;
|
|
378
|
+
if (move?.queued) {
|
|
379
|
+
resumed = lane;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
resumed = await withLaneAdmissionLock(hqRoot, lane.provider, async () => {
|
|
383
|
+
const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
|
|
384
|
+
if (admission.action === "refuse" && admission.error)
|
|
385
|
+
throw admission.error;
|
|
386
|
+
return withResumeHireAdmission(ctx, lane, () => {
|
|
387
|
+
if (admission.action === "queue") {
|
|
388
|
+
const sessionId = lane.provider_session_id;
|
|
389
|
+
if (!sessionId) {
|
|
390
|
+
throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this resume`, {
|
|
391
|
+
lane_id: laneId,
|
|
392
|
+
provider: lane.provider,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
queuedOnAdmission = true;
|
|
396
|
+
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
397
|
+
if (current.version !== lane.version || current.state !== lane.state) {
|
|
398
|
+
throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while resume admission was checked`, {
|
|
399
|
+
lane_id: laneId,
|
|
400
|
+
prepared_version: lane.version,
|
|
401
|
+
version: current.version,
|
|
402
|
+
state: current.state,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
...current,
|
|
407
|
+
state: "queued",
|
|
408
|
+
queued_reason: admission.reason ?? "throttle",
|
|
409
|
+
queued_details: undefined,
|
|
410
|
+
queued_at: at,
|
|
411
|
+
pending_resume: {
|
|
412
|
+
session_id: sessionId,
|
|
413
|
+
trigger: "resume",
|
|
414
|
+
caller: ctx.env.HQ_LANE_ID?.trim() || "user",
|
|
415
|
+
resumed_from: resumedFrom,
|
|
416
|
+
...(opts.fromFailed === true ? { from_failed: true } : {}),
|
|
417
|
+
...(failedResumeExpectedSenior !== undefined
|
|
418
|
+
? { expected_senior: failedResumeExpectedSenior }
|
|
419
|
+
: {}),
|
|
420
|
+
...(deliveryText !== undefined ? { text: deliveryText } : {}),
|
|
421
|
+
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
422
|
+
compact: opts.compact !== false,
|
|
423
|
+
at,
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const started = spawnLaneWorker(hqRoot, lane, {
|
|
429
|
+
env: ctx.env,
|
|
430
|
+
modelDeps: {
|
|
431
|
+
readFileSync: ctx.readFileSync,
|
|
432
|
+
probe: ctx.probeProvider,
|
|
433
|
+
env: ctx.env,
|
|
434
|
+
},
|
|
435
|
+
existsSync: ctx.existsSync,
|
|
436
|
+
spawnProcess: ctx.spawnProcess,
|
|
437
|
+
proveDetached: ctx.proveDetached,
|
|
438
|
+
killProcess: ctx.killProcess,
|
|
439
|
+
now: ctx.now,
|
|
440
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
441
|
+
runCompaction: ctx.runCompaction,
|
|
442
|
+
locateTranscript: ctx.locateTranscript,
|
|
443
|
+
}, {
|
|
444
|
+
trigger: {
|
|
445
|
+
kind: "resume",
|
|
446
|
+
from: resumedFrom,
|
|
447
|
+
...(opts.fromFailed === true ? { fromFailed: true } : {}),
|
|
448
|
+
...(failedResumeExpectedSenior !== undefined
|
|
449
|
+
? { expectedSenior: failedResumeExpectedSenior }
|
|
450
|
+
: {}),
|
|
451
|
+
...(deliveryText !== undefined ? { text: deliveryText } : {}),
|
|
452
|
+
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
453
|
+
},
|
|
454
|
+
compact: opts.compact !== false,
|
|
455
|
+
effort: resumeEffort ?? recordedResumeEffort(lane),
|
|
456
|
+
});
|
|
457
|
+
storySwitchWorkerStarted = true;
|
|
458
|
+
deps.onLaneWorkerStarted?.(laneId, "resume");
|
|
459
|
+
return started;
|
|
460
|
+
}, hireGrantContext);
|
|
461
|
+
}, ctx.sleep);
|
|
462
|
+
}
|
|
463
|
+
if (deliveryText !== undefined && messageId !== undefined) {
|
|
464
|
+
// Same audit copy a message resume leaves; nothing drains it. Archive only
|
|
465
|
+
// after a successful spawn so a concurrent resume cannot leave a phantom.
|
|
466
|
+
archiveResumeMessage(hqRoot, laneId, {
|
|
427
467
|
at,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
failure: recoveredFailure,
|
|
431
|
-
}),
|
|
432
|
-
}));
|
|
433
|
-
}
|
|
434
|
-
if (text !== undefined) {
|
|
435
|
-
// An operator text moves the compaction reference forward, as a message does.
|
|
436
|
-
next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
437
|
-
...resetKeepAliveProgress(current),
|
|
438
|
-
last_message_at: at,
|
|
439
|
-
}));
|
|
440
|
-
}
|
|
441
|
-
if (messageId !== undefined && attributionChanged) {
|
|
442
|
-
next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
443
|
-
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
444
|
-
if (already)
|
|
445
|
-
return current;
|
|
446
|
-
return {
|
|
447
|
-
...current,
|
|
468
|
+
message_id: messageId,
|
|
469
|
+
text: deliveryText,
|
|
448
470
|
project_id: projectId,
|
|
449
471
|
story_id: storyId,
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
updated_at: at,
|
|
461
|
-
},
|
|
462
|
-
],
|
|
463
|
-
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
464
|
-
project_id: projectId,
|
|
465
|
-
story_id: storyId,
|
|
466
|
-
message_id: messageId,
|
|
472
|
+
resumed_from: resumedFrom,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
let next = resumed;
|
|
476
|
+
if (failedResumeActor !== undefined &&
|
|
477
|
+
failedResumeReason !== undefined &&
|
|
478
|
+
recoveredFailure !== undefined) {
|
|
479
|
+
next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
480
|
+
...current,
|
|
481
|
+
failed_resume_history: appendCappedHistory(current.failed_resume_history ?? [], {
|
|
467
482
|
at,
|
|
483
|
+
actor: failedResumeActor,
|
|
484
|
+
reason: failedResumeReason,
|
|
485
|
+
failure: recoveredFailure,
|
|
468
486
|
}),
|
|
469
|
-
};
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
if (deliveryText !== undefined) {
|
|
490
|
+
// An operator text moves the compaction reference forward, as a message does.
|
|
491
|
+
next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
492
|
+
...resetKeepAliveProgress(current),
|
|
493
|
+
last_message_at: at,
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
if (text === undefined && hasKeepAliveProgress(next)) {
|
|
497
|
+
next = casUpdateLane(hqRoot, laneId, (current) => resetKeepAliveProgress(current));
|
|
498
|
+
}
|
|
499
|
+
printJson({
|
|
500
|
+
ok: true,
|
|
501
|
+
action: "resume",
|
|
502
|
+
lane_id: laneId,
|
|
503
|
+
resumed_from: resumedFrom,
|
|
504
|
+
message_id: messageId ?? null,
|
|
505
|
+
text: text ?? null,
|
|
506
|
+
delivery: "resume",
|
|
507
|
+
delivery_status: move?.queued
|
|
508
|
+
? "queued_on_target_account"
|
|
509
|
+
: queuedOnAdmission
|
|
510
|
+
? "queued_on_admission"
|
|
511
|
+
: "worker_spawned",
|
|
512
|
+
account_move: move ? accountMoveSummary(move) : null,
|
|
513
|
+
resumed_session_id: resumed.last_resume?.session_id ?? null,
|
|
514
|
+
last_resume: next.last_resume ?? null,
|
|
515
|
+
compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
516
|
+
? resumed.last_compaction
|
|
517
|
+
: null,
|
|
518
|
+
project_id: next.project_id,
|
|
519
|
+
story_id: next.story_id,
|
|
520
|
+
stories: laneStories(next),
|
|
521
|
+
story_warning: storyWarning(next) ?? null,
|
|
522
|
+
lane: next,
|
|
470
523
|
});
|
|
471
524
|
}
|
|
472
|
-
|
|
473
|
-
|
|
525
|
+
catch (err) {
|
|
526
|
+
if (storySwitchRollback !== undefined && !storySwitchWorkerStarted) {
|
|
527
|
+
try {
|
|
528
|
+
casUpdateLane(hqRoot, laneId, (current) => rollbackStorySwitchAttribution(current, storySwitchRollback));
|
|
529
|
+
}
|
|
530
|
+
catch (rollbackErr) {
|
|
531
|
+
const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
532
|
+
process.stderr.write(`Could not roll back story attribution after resume failure: ${rollbackMessage}\n`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
throw err;
|
|
474
536
|
}
|
|
475
|
-
printJson({
|
|
476
|
-
ok: true,
|
|
477
|
-
action: "resume",
|
|
478
|
-
lane_id: laneId,
|
|
479
|
-
resumed_from: resumedFrom,
|
|
480
|
-
message_id: messageId ?? null,
|
|
481
|
-
text: text ?? null,
|
|
482
|
-
delivery: "resume",
|
|
483
|
-
delivery_status: move?.queued
|
|
484
|
-
? "queued_on_target_account"
|
|
485
|
-
: queuedOnAdmission
|
|
486
|
-
? "queued_on_admission"
|
|
487
|
-
: "worker_spawned",
|
|
488
|
-
account_move: move ? accountMoveSummary(move) : null,
|
|
489
|
-
resumed_session_id: resumed.last_resume?.session_id ?? null,
|
|
490
|
-
last_resume: next.last_resume ?? null,
|
|
491
|
-
compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
492
|
-
? resumed.last_compaction
|
|
493
|
-
: null,
|
|
494
|
-
project_id: next.project_id,
|
|
495
|
-
story_id: next.story_id,
|
|
496
|
-
stories: laneStories(next),
|
|
497
|
-
story_warning: storyWarning(next) ?? null,
|
|
498
|
-
lane: next,
|
|
499
|
-
});
|
|
500
537
|
}
|
|
501
538
|
return { runClaudeBalanceMoveTarget, runResume };
|
|
502
539
|
}
|
|
@@ -144,6 +144,8 @@ export interface OutpostLaneDispatchBehavior {
|
|
|
144
144
|
expectedOwnerHost?: LaneRecord["host"];
|
|
145
145
|
}
|
|
146
146
|
export interface LaneMessageHandlerDeps {
|
|
147
|
+
/** Test seam invoked after a provider worker has been started. */
|
|
148
|
+
onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
|
|
147
149
|
archiveResumeMessage(hqRoot: string, laneId: string, message: {
|
|
148
150
|
message_id: string;
|
|
149
151
|
[key: string]: unknown;
|
|
@@ -184,7 +186,7 @@ export interface LaneMessageHandlerDeps {
|
|
|
184
186
|
validateResumeProfileModel(ctx: Ctx, lane: LaneRecord, profile: WorkerProfile): void;
|
|
185
187
|
withResumeHireAdmission<T>(ctx: Ctx, lane: LaneRecord, fn: () => T, grantContext?: OutpostHireGrantContext): Promise<T>;
|
|
186
188
|
}
|
|
187
|
-
export interface LaneResumeHandlerDeps extends Pick<LaneMessageHandlerDeps, "archiveResumeMessage" | "assertLaneRunsHereForAdmission" | "assertLaneRunsHereForOperatorMutation" | "assessLaneAdmission" | "casUpdateLane" | "laneIsHostedHere" | "laneWithRecoveredProviderSession" | "mintMessageId" | "prepareAccountMove" | "persistClaudeAccountSelection" | "printJson" | "recordResumeSetup" | "resolveCommandLane" | "resolveResumeProfile" | "selectAutomaticClaudeResumeAccount" | "validateResumeProfileModel" | "withResumeHireAdmission"> {
|
|
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"> {
|
|
188
190
|
dispatchOutpostLaneCommand(ctx: Ctx, lane: LaneRecord, action: "resume", options: LaneResumeOptions, behavior?: OutpostLaneDispatchBehavior): Promise<void>;
|
|
189
191
|
authorizeFailedResume(ctx: Ctx, lane: LaneRecord, encodedContext?: string): string;
|
|
190
192
|
authorizeResumeProfileRepin(ctx: Ctx, lane: LaneRecord, profile: ResolvedResumeProfile | undefined, encodedContext: string | undefined): ResumeProfileRepinAuthorization | undefined;
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare const LANE_REMOTE_MIRROR_SYNC_FLAG = "lanes.remote-done-mirror";
|
|
|
29
29
|
/** The lane → mesh seam. Defined in lib/lanes/mesh-events.ts; the watcher shares it. */
|
|
30
30
|
export { emitLaneDecisionEvents };
|
|
31
31
|
export interface LanesCommandDeps {
|
|
32
|
+
/** Test seam for failures after a lane provider worker is started. */
|
|
33
|
+
onLaneWorkerStarted?: (laneId: string, command: "message" | "resume") => void;
|
|
32
34
|
hqRoot?: () => string;
|
|
33
35
|
/** Start HQ-root walk here instead of process.cwd(). Tests only. */
|
|
34
36
|
cwd?: () => string;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -7837,6 +7837,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
7837
7837
|
writeAnswerDeliveryFailed,
|
|
7838
7838
|
});
|
|
7839
7839
|
const messageHandlerDeps = {
|
|
7840
|
+
onLaneWorkerStarted: deps.onLaneWorkerStarted,
|
|
7840
7841
|
archiveResumeMessage,
|
|
7841
7842
|
assertLaneRunsHereForAdmission,
|
|
7842
7843
|
assertLaneRunsHereForOperatorMutation,
|
|
@@ -7862,6 +7863,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
7862
7863
|
};
|
|
7863
7864
|
const messageHandlers = createLaneMessageHandlers(messageHandlerDeps);
|
|
7864
7865
|
const resumeHandlers = createLaneResumeHandlers({
|
|
7866
|
+
onLaneWorkerStarted: deps.onLaneWorkerStarted,
|
|
7865
7867
|
archiveResumeMessage,
|
|
7866
7868
|
assertLaneRunsHereForAdmission,
|
|
7867
7869
|
assertLaneRunsHereForOperatorMutation,
|
|
@@ -8214,8 +8216,8 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
8214
8216
|
.description("Send a follow-up message to a lane")
|
|
8215
8217
|
.argument("<lane-id>", "Lane id")
|
|
8216
8218
|
.requiredOption("--text <msg>", "Message text")
|
|
8217
|
-
.option("--project <slug>", "Re-point project attribution
|
|
8218
|
-
.option("--story <id>", "Re-point story attribution
|
|
8219
|
+
.option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
|
|
8220
|
+
.option("--story <id>", "Re-point story attribution; a changed story is named in the resumed prompt")
|
|
8219
8221
|
.option("--account <id>", "Move the lane's session to this account of its provider before the message resumes it")
|
|
8220
8222
|
.option("--model <id>", "Model for the moved lane (with --account only)")
|
|
8221
8223
|
.option("--effort <level>", "Reasoning effort for the resumed provider turn")
|
|
@@ -8292,8 +8294,8 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
8292
8294
|
.option("--text <msg>", "Operator message for the resumed turn")
|
|
8293
8295
|
.option("--from-failed", "Explicitly resume a failed lane as its senior")
|
|
8294
8296
|
.option("--reason <text>", "Required explanation for resuming from failed")
|
|
8295
|
-
.option("--project <slug>", "Re-point project attribution
|
|
8296
|
-
.option("--story <id>", "Re-point story attribution
|
|
8297
|
+
.option("--project <slug>", "Re-point project attribution; a changed story is named in the resumed prompt")
|
|
8298
|
+
.option("--story <id>", "Re-point story attribution; a changed story is named in the resumed prompt")
|
|
8297
8299
|
.option("--account <id>", "Move the lane's session to this account of its provider before resuming")
|
|
8298
8300
|
.option("--model <id>", "Model for the moved lane (with --account only)")
|
|
8299
8301
|
.option("--effort <level>", "Reasoning effort for the resumed provider turn")
|
|
@@ -32,6 +32,7 @@ import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuesti
|
|
|
32
32
|
import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
|
|
33
33
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
|
|
34
34
|
import { activeLaneStories, laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
|
|
35
|
+
import { applyStorySwitchEnvelope } from "./story-switch.js";
|
|
35
36
|
import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
|
|
36
37
|
import { PROVIDER_REJECTED_REASON, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
|
|
37
38
|
/** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
|
|
@@ -1271,11 +1272,28 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1271
1272
|
}
|
|
1272
1273
|
if (terminalEnvelope && latest) {
|
|
1273
1274
|
const envelope = terminalEnvelope;
|
|
1274
|
-
|
|
1275
|
+
next.last_envelope = {
|
|
1276
|
+
ref: latest.ref,
|
|
1277
|
+
decision: envelope.decision,
|
|
1278
|
+
at: nowIso,
|
|
1279
|
+
artifacts: envelope.artifacts,
|
|
1280
|
+
...(envelope.blocked_reason !== undefined ? { blocked_reason: envelope.blocked_reason } : {}),
|
|
1281
|
+
};
|
|
1282
|
+
const storySwitch = applyStorySwitchEnvelope(current, envelope, nowIso, latest.ref);
|
|
1283
|
+
if (storySwitch.clearPending) {
|
|
1284
|
+
next.story_switch_pending = undefined;
|
|
1285
|
+
}
|
|
1286
|
+
if (storySwitch.storySwitchUnacknowledged !== undefined) {
|
|
1287
|
+
next.story_switch_unacknowledged = storySwitch.storySwitchUnacknowledged;
|
|
1288
|
+
next.attribution_history = storySwitch.attributionHistory ?? current.attribution_history;
|
|
1289
|
+
}
|
|
1290
|
+
const storyStatus = storySwitch.suppressCompletion
|
|
1291
|
+
? "in_progress"
|
|
1292
|
+
: terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
|
|
1275
1293
|
if (storyStatus !== undefined) {
|
|
1276
|
-
next.story_status = envelope.story_status;
|
|
1277
|
-
next.story_complete = envelope.story_complete;
|
|
1278
|
-
next.story_complete_reason = envelope.story_complete_reason;
|
|
1294
|
+
next.story_status = storySwitch.suppressCompletion ? "in_progress" : envelope.story_status;
|
|
1295
|
+
next.story_complete = storySwitch.suppressCompletion ? false : envelope.story_complete;
|
|
1296
|
+
next.story_complete_reason = storySwitch.suppressCompletion ? undefined : envelope.story_complete_reason;
|
|
1279
1297
|
next.stories = laneStories(current).map((story) => story.project_id === current.project_id && story.story_id === current.story_id
|
|
1280
1298
|
? {
|
|
1281
1299
|
...story,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AttributionHistoryEntry, LaneEnvelope, LaneRecord, StorySwitchUnacknowledgedNote } from "./types.js";
|
|
2
|
+
export declare function storySwitchNotice(previousProjectId: string, previousStoryId: string, projectId: string, storyId: string): string | undefined;
|
|
3
|
+
export declare function artifactIdentityKeys(artifacts: readonly unknown[] | undefined): string[];
|
|
4
|
+
export interface StorySwitchRollbackSnapshot {
|
|
5
|
+
message_id: string;
|
|
6
|
+
project_id: string;
|
|
7
|
+
story_id: string;
|
|
8
|
+
no_story_reason?: string;
|
|
9
|
+
added_story?: NonNullable<LaneRecord["stories"]>[number];
|
|
10
|
+
story_switch_unacknowledged: StorySwitchUnacknowledgedNote | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** Restore only the attribution write owned by this failed command attempt. */
|
|
13
|
+
export declare function rollbackStorySwitchAttribution(current: LaneRecord, snapshot: StorySwitchRollbackSnapshot): LaneRecord;
|
|
14
|
+
/** Apply and consume one attribution-switch marker when the next envelope arrives. */
|
|
15
|
+
export declare function applyStorySwitchEnvelope(lane: LaneRecord, envelope: LaneEnvelope, at: string, envelopeRef: string): {
|
|
16
|
+
suppressCompletion: boolean;
|
|
17
|
+
clearPending: boolean;
|
|
18
|
+
storySwitchUnacknowledged?: StorySwitchUnacknowledgedNote;
|
|
19
|
+
attributionHistory?: AttributionHistoryEntry[];
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=story-switch.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function storySwitchNotice(previousProjectId, previousStoryId, projectId, storyId) {
|
|
2
|
+
if (previousProjectId === projectId && previousStoryId === storyId)
|
|
3
|
+
return undefined;
|
|
4
|
+
const from = `${previousProjectId}/${previousStoryId || "(no story)"}`;
|
|
5
|
+
const to = `${projectId}/${storyId || "(no story)"}`;
|
|
6
|
+
return `Story changed: ${from} -> ${to}. The previous story is closed; report only on the new story in this turn's envelope.`;
|
|
7
|
+
}
|
|
8
|
+
export function artifactIdentityKeys(artifacts) {
|
|
9
|
+
if (!Array.isArray(artifacts))
|
|
10
|
+
return [];
|
|
11
|
+
return artifacts.flatMap((artifact) => {
|
|
12
|
+
if (typeof artifact === "string" && artifact.trim())
|
|
13
|
+
return [`value:${artifact.trim()}`];
|
|
14
|
+
if (artifact === null || typeof artifact !== "object" || Array.isArray(artifact))
|
|
15
|
+
return [];
|
|
16
|
+
const record = artifact;
|
|
17
|
+
const url = typeof record.url === "string" ? record.url.trim() : "";
|
|
18
|
+
const path = typeof record.path === "string" ? record.path.trim() : "";
|
|
19
|
+
const values = [url && `url:${url}`, path && `path:${path}`].filter((value) => Boolean(value));
|
|
20
|
+
return values;
|
|
21
|
+
}).sort();
|
|
22
|
+
}
|
|
23
|
+
/** Restore only the attribution write owned by this failed command attempt. */
|
|
24
|
+
export function rollbackStorySwitchAttribution(current, snapshot) {
|
|
25
|
+
if (current.story_switch_pending?.message_id !== snapshot.message_id)
|
|
26
|
+
return current;
|
|
27
|
+
const addedStory = snapshot.added_story;
|
|
28
|
+
const stories = (current.stories ?? []).filter((story) => !(addedStory && story.project_id === addedStory.project_id && story.story_id === addedStory.story_id &&
|
|
29
|
+
story.added_at === addedStory.added_at));
|
|
30
|
+
return {
|
|
31
|
+
...current,
|
|
32
|
+
project_id: snapshot.project_id,
|
|
33
|
+
story_id: snapshot.story_id,
|
|
34
|
+
no_story_reason: snapshot.no_story_reason,
|
|
35
|
+
stories,
|
|
36
|
+
attribution_history: current.attribution_history.filter((entry) => entry.message_id !== snapshot.message_id),
|
|
37
|
+
story_switch_pending: undefined,
|
|
38
|
+
story_switch_unacknowledged: snapshot.story_switch_unacknowledged,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function sameKeys(left, right) {
|
|
42
|
+
return left.length > 0 && left.length === right.length && left.every((key, index) => key === right[index]);
|
|
43
|
+
}
|
|
44
|
+
/** Apply and consume one attribution-switch marker when the next envelope arrives. */
|
|
45
|
+
export function applyStorySwitchEnvelope(lane, envelope, at, envelopeRef) {
|
|
46
|
+
const pending = lane.story_switch_pending;
|
|
47
|
+
if (!pending)
|
|
48
|
+
return { suppressCompletion: false, clearPending: false };
|
|
49
|
+
if (envelope.story_complete !== true)
|
|
50
|
+
return { suppressCompletion: false, clearPending: false };
|
|
51
|
+
const currentKeys = artifactIdentityKeys(envelope.artifacts);
|
|
52
|
+
const suppressCompletion = sameKeys(pending.previous_artifact_keys, currentKeys);
|
|
53
|
+
if (!suppressCompletion)
|
|
54
|
+
return { suppressCompletion: false, clearPending: true };
|
|
55
|
+
const note = {
|
|
56
|
+
event: "story_switch_unacknowledged",
|
|
57
|
+
previous_project_id: pending.previous_project_id,
|
|
58
|
+
previous_story_id: pending.previous_story_id,
|
|
59
|
+
project_id: pending.project_id,
|
|
60
|
+
story_id: pending.story_id,
|
|
61
|
+
message_id: pending.message_id,
|
|
62
|
+
envelope_ref: envelopeRef,
|
|
63
|
+
at,
|
|
64
|
+
artifact_keys: currentKeys,
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
suppressCompletion: true,
|
|
68
|
+
clearPending: true,
|
|
69
|
+
storySwitchUnacknowledged: note,
|
|
70
|
+
attributionHistory: lane.attribution_history.map((entry) => entry.message_id === pending.message_id
|
|
71
|
+
? { ...entry, story_switch_unacknowledged: note }
|
|
72
|
+
: entry),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=story-switch.js.map
|
|
@@ -351,6 +351,27 @@ export interface AttributionHistoryEntry {
|
|
|
351
351
|
story_id: string;
|
|
352
352
|
message_id: string;
|
|
353
353
|
at: string;
|
|
354
|
+
story_switch_unacknowledged?: StorySwitchUnacknowledgedNote;
|
|
355
|
+
}
|
|
356
|
+
export interface StorySwitchPending {
|
|
357
|
+
previous_project_id: string;
|
|
358
|
+
previous_story_id: string;
|
|
359
|
+
project_id: string;
|
|
360
|
+
story_id: string;
|
|
361
|
+
message_id: string;
|
|
362
|
+
at: string;
|
|
363
|
+
previous_artifact_keys: string[];
|
|
364
|
+
}
|
|
365
|
+
export interface StorySwitchUnacknowledgedNote {
|
|
366
|
+
event: "story_switch_unacknowledged";
|
|
367
|
+
previous_project_id: string;
|
|
368
|
+
previous_story_id: string;
|
|
369
|
+
project_id: string;
|
|
370
|
+
story_id: string;
|
|
371
|
+
message_id: string;
|
|
372
|
+
envelope_ref: string;
|
|
373
|
+
at: string;
|
|
374
|
+
artifact_keys: string[];
|
|
354
375
|
}
|
|
355
376
|
export interface LastEnvelopeRef {
|
|
356
377
|
ref: string;
|
|
@@ -573,6 +594,10 @@ export interface LaneRecord {
|
|
|
573
594
|
envelope_summary?: string;
|
|
574
595
|
attribution_history: AttributionHistoryEntry[];
|
|
575
596
|
last_envelope?: LastEnvelopeRef;
|
|
597
|
+
/** Pending attribution change, consumed by the next terminal envelope. */
|
|
598
|
+
story_switch_pending?: StorySwitchPending;
|
|
599
|
+
/** Last completion suppressed because the new story repeated prior evidence. */
|
|
600
|
+
story_switch_unacknowledged?: StorySwitchUnacknowledgedNote;
|
|
576
601
|
/** Effective model id recorded at create/spawn. Omitted when the provider chooses. */
|
|
577
602
|
model?: string;
|
|
578
603
|
/** Where the effective model came from; repaired models stay pinned across the account move. */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -227,7 +227,7 @@ export const LANE_FIELD_WRITERS = {
|
|
|
227
227
|
priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], registry_worker: ["creator", "command", "spawn"], registry_worker_approval_history: ["command", "watcher", "reconcile"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
|
|
228
228
|
keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile"], keep_alive_history: ["reconcile"], keep_alive_blocked_resume_streak: ["reconcile", "spawn", "watcher"], keep_alive_last_resume_at: ["command", "reconcile"], keep_alive_next_eligible_at: ["command", "reconcile"], keep_alive_exhausted_at: ["command", "reconcile"],
|
|
229
229
|
provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
|
|
230
|
-
version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command"],
|
|
230
|
+
version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command", "watcher", "reconcile"], story_switch_pending: ["command", "watcher", "reconcile"], story_switch_unacknowledged: ["watcher", "reconcile"],
|
|
231
231
|
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"],
|
|
232
232
|
last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher", "reconcile"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
|
|
233
233
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
@@ -37,6 +37,7 @@ import { LanesError } from "./errors.js";
|
|
|
37
37
|
import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
38
38
|
import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, LANE_PROVIDERS, } from "./types.js";
|
|
39
39
|
import { laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
|
|
40
|
+
import { applyStorySwitchEnvelope } from "./story-switch.js";
|
|
40
41
|
import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpdateLane, withLaneHireLocks } from "./store.js";
|
|
41
42
|
import { enforceRegistryApprovalGate } from "./approval-gate.js";
|
|
42
43
|
import { readWorkerRunResult } from "./delivery.js";
|
|
@@ -414,6 +415,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
414
415
|
}), current.registry_worker_approval_history ?? []);
|
|
415
416
|
}
|
|
416
417
|
if (envelope) {
|
|
418
|
+
const storySwitch = applyStorySwitchEnvelope(current, envelope, at, envelopeRef);
|
|
417
419
|
if (envelope.decision !== "blocked")
|
|
418
420
|
next.keep_alive_blocked_resume_streak = 0;
|
|
419
421
|
next.last_envelope = {
|
|
@@ -423,11 +425,20 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
423
425
|
artifacts: envelope.artifacts,
|
|
424
426
|
...(envelope.blocked_reason !== undefined ? { blocked_reason: envelope.blocked_reason } : {}),
|
|
425
427
|
};
|
|
426
|
-
|
|
428
|
+
if (storySwitch.clearPending) {
|
|
429
|
+
next.story_switch_pending = undefined;
|
|
430
|
+
}
|
|
431
|
+
if (storySwitch.storySwitchUnacknowledged !== undefined) {
|
|
432
|
+
next.story_switch_unacknowledged = storySwitch.storySwitchUnacknowledged;
|
|
433
|
+
next.attribution_history = storySwitch.attributionHistory ?? current.attribution_history;
|
|
434
|
+
}
|
|
435
|
+
const storyStatus = storySwitch.suppressCompletion
|
|
436
|
+
? "in_progress"
|
|
437
|
+
: terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
|
|
427
438
|
if (storyStatus !== undefined) {
|
|
428
|
-
next.story_status = envelope.story_status;
|
|
429
|
-
next.story_complete = envelope.story_complete;
|
|
430
|
-
next.story_complete_reason = envelope.story_complete_reason;
|
|
439
|
+
next.story_status = storySwitch.suppressCompletion ? "in_progress" : envelope.story_status;
|
|
440
|
+
next.story_complete = storySwitch.suppressCompletion ? false : envelope.story_complete;
|
|
441
|
+
next.story_complete_reason = storySwitch.suppressCompletion ? undefined : envelope.story_complete_reason;
|
|
431
442
|
const currentProject = current.project_id;
|
|
432
443
|
const currentStory = current.story_id;
|
|
433
444
|
const updatedStories = laneStories(current).map((story) => story.project_id === currentProject && story.story_id === currentStory
|