@indigoai-us/hq-cli 5.131.0 → 5.133.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/dist/command-catalog.generated.d.ts +12 -0
- package/dist/command-catalog.generated.js +16 -0
- package/dist/commands/lanes.d.ts +15 -4
- package/dist/commands/lanes.js +478 -48
- package/dist/commands/mesh.js +24 -2
- package/dist/commands/update.js +1 -1
- package/dist/lib/bot/paths.d.ts +7 -5
- package/dist/lib/bot/paths.js +8 -1
- package/dist/lib/bot/prompt.d.ts +0 -2
- package/dist/lib/bot/prompt.js +5 -11
- package/dist/lib/bot/room-policy.d.ts +1 -24
- package/dist/lib/bot/room-policy.js +2 -35
- package/dist/lib/bot/run.d.ts +7 -8
- package/dist/lib/bot/run.js +21 -33
- package/dist/lib/bot/session.d.ts +1 -1
- package/dist/lib/bot/session.js +1 -1
- package/dist/lib/lanes/accounts.d.ts +6 -0
- package/dist/lib/lanes/accounts.js +2 -1
- package/dist/lib/lanes/config.d.ts +4 -0
- package/dist/lib/lanes/config.js +9 -0
- package/dist/lib/lanes/constants.d.ts +3 -0
- package/dist/lib/lanes/constants.js +3 -0
- package/dist/lib/lanes/index.d.ts +11 -3
- package/dist/lib/lanes/index.js +6 -1
- package/dist/lib/lanes/mesh-events.js +4 -0
- package/dist/lib/lanes/models.d.ts +78 -0
- package/dist/lib/lanes/models.js +205 -0
- package/dist/lib/lanes/move-account.d.ts +56 -0
- package/dist/lib/lanes/move-account.js +193 -0
- package/dist/lib/lanes/provider-rejection.d.ts +86 -0
- package/dist/lib/lanes/provider-rejection.js +258 -0
- package/dist/lib/lanes/quota.d.ts +23 -3
- package/dist/lib/lanes/quota.js +88 -14
- package/dist/lib/lanes/reconcile-install.d.ts +24 -3
- package/dist/lib/lanes/reconcile-install.js +48 -12
- package/dist/lib/lanes/reconcile.d.ts +5 -2
- package/dist/lib/lanes/reconcile.js +135 -13
- package/dist/lib/lanes/spawn.js +26 -5
- package/dist/lib/lanes/store.js +10 -0
- package/dist/lib/lanes/types.d.ts +51 -2
- package/dist/lib/lanes/types.js +5 -3
- package/dist/lib/lanes/watcher.d.ts +27 -0
- package/dist/lib/lanes/watcher.js +68 -5
- package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
- package/dist/lib/mesh/live/backfill-held.js +37 -14
- package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/doctor.js +16 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
- package/dist/lib/mesh/live/daemon/install.js +100 -7
- package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
- package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
- package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
- package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
- package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
- package/dist/lib/mesh/live/daemon/presence.js +10 -9
- package/dist/lib/mesh/live/daemon/run.d.ts +18 -0
- package/dist/lib/mesh/live/daemon/run.js +65 -6
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
- package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
- package/dist/lib/mesh/live/emit-evidence.js +20 -2
- package/dist/lib/work-context/company.d.ts +16 -0
- package/dist/lib/work-context/company.js +46 -14
- package/dist/utils/self-update.js +4 -1
- package/package.json +2 -2
package/dist/commands/lanes.js
CHANGED
|
@@ -17,17 +17,19 @@ import { vaultApiFetch } from "../utils/vault-api.js";
|
|
|
17
17
|
import { openMeshTransport } from "../lib/mesh/client.js";
|
|
18
18
|
import { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.js";
|
|
19
19
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
20
|
-
import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, isLoggedOut, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
20
|
+
import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, MAX_LANE_DEPTH, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, isLoggedOut, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasRecordedSessionId, providerSupportsSessionResume, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
21
21
|
import { questionBodyAbsPath } from "../lib/lanes/questions.js";
|
|
22
22
|
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
23
23
|
import { providerCompactAfterMs } from "../lib/lanes/config.js";
|
|
24
24
|
import { parseCliEffort, resolveLaneEffort } from "../lib/lanes/effort.js";
|
|
25
25
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
26
|
-
import { accountQuotaView, loadQuotaState } from "../lib/lanes/quota.js";
|
|
26
|
+
import { accountQuotaView, loadQuotaState, quotaBlockFor } from "../lib/lanes/quota.js";
|
|
27
|
+
import { SESSION_MOVE_REASON, copySessionFiles, planSessionCopy } from "../lib/lanes/move-account.js";
|
|
28
|
+
import { defaultProbe, validateLaneModel, } from "../lib/lanes/models.js";
|
|
27
29
|
import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, stopLaneProcesses, } from "../lib/lanes/spawn.js";
|
|
28
30
|
export { spawnLaneWorker };
|
|
29
31
|
export { questionBodyAbsPath };
|
|
30
|
-
export
|
|
32
|
+
export { MAX_LANE_DEPTH };
|
|
31
33
|
export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
|
|
32
34
|
export const OUTPOST_DIR = "/etc/outpost";
|
|
33
35
|
export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
@@ -201,6 +203,7 @@ function resolveDeps(deps = {}) {
|
|
|
201
203
|
};
|
|
202
204
|
}),
|
|
203
205
|
resolveMachineIdentity: deps.resolveMachineIdentity ?? lookupMachineIdentity,
|
|
206
|
+
probeProvider: deps.probeProvider ?? defaultProbe,
|
|
204
207
|
runCompaction: deps.runCompaction,
|
|
205
208
|
locateTranscript: deps.locateTranscript,
|
|
206
209
|
stageOutpostPayload: deps.stageOutpostPayload ?? defaultStageOutpostPayload,
|
|
@@ -1047,6 +1050,7 @@ export const PICKUP_TERMINAL_REASONS = [
|
|
|
1047
1050
|
"account_not_logged_in",
|
|
1048
1051
|
"unknown_account",
|
|
1049
1052
|
"account_disabled",
|
|
1053
|
+
"model_unsupported",
|
|
1050
1054
|
];
|
|
1051
1055
|
export const DISPATCH_TERMINAL_REASON = "outpost_dispatch_failed";
|
|
1052
1056
|
function parsePickupTerminalReason(value) {
|
|
@@ -1069,28 +1073,39 @@ function markDispatchFailure(hqRoot, laneId, details) {
|
|
|
1069
1073
|
};
|
|
1070
1074
|
});
|
|
1071
1075
|
}
|
|
1072
|
-
|
|
1076
|
+
/**
|
|
1077
|
+
* The CAS updater a pickup failure applies. A lane that is already terminal
|
|
1078
|
+
* for any reason (a cancel that landed after the caller's last read, an
|
|
1079
|
+
* earlier pickup failure) is returned unchanged: the guard lives inside the
|
|
1080
|
+
* same compare-and-swap that writes the failure, so no read-then-write gap
|
|
1081
|
+
* can overwrite another writer's terminal decision.
|
|
1082
|
+
*/
|
|
1083
|
+
export function pickupFailureUpdater(reasonRaw, details) {
|
|
1073
1084
|
const reason = parsePickupTerminalReason(reasonRaw);
|
|
1074
|
-
return
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
if (isTerminalLaneState(current.state)
|
|
1085
|
+
return (current) => {
|
|
1086
|
+
// A running record is never overwritten either: a worker that was
|
|
1087
|
+
// admitted meanwhile keeps its lane, and the watcher classifies it.
|
|
1088
|
+
if (isTerminalLaneState(current.state) || current.state === "running")
|
|
1078
1089
|
return current;
|
|
1079
|
-
}
|
|
1080
1090
|
return {
|
|
1081
1091
|
...current,
|
|
1082
1092
|
state: "failed",
|
|
1083
1093
|
queued_reason: undefined,
|
|
1094
|
+
pickup_validation_pending: undefined,
|
|
1095
|
+
pickup_validation_pending_since: undefined,
|
|
1084
1096
|
terminal_reason: reason,
|
|
1085
1097
|
terminal_details: details,
|
|
1086
1098
|
};
|
|
1087
|
-
}
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
function markPickupFailure(hqRoot, laneId, reasonRaw, details) {
|
|
1102
|
+
return casUpdateLane(hqRoot, laneId, pickupFailureUpdater(reasonRaw, details));
|
|
1088
1103
|
}
|
|
1089
1104
|
/**
|
|
1090
1105
|
* Outpost-side account bind (spec §5.2). Writes a named terminal reason,
|
|
1091
1106
|
* never a plain `failed`.
|
|
1092
1107
|
*/
|
|
1093
|
-
export function applyOutpostAccountPickup(hqRoot, laneId) {
|
|
1108
|
+
export function applyOutpostAccountPickup(hqRoot, laneId, modelDeps = {}) {
|
|
1094
1109
|
const lane = readLane(hqRoot, laneId);
|
|
1095
1110
|
const config = loadLanesConfig(hqRoot);
|
|
1096
1111
|
try {
|
|
@@ -1104,16 +1119,60 @@ export function applyOutpostAccountPickup(hqRoot, laneId) {
|
|
|
1104
1119
|
account: resolved.id,
|
|
1105
1120
|
});
|
|
1106
1121
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1122
|
+
// The executing host is the first place the model can be checked
|
|
1123
|
+
// against this account's provider list: an outpost create without
|
|
1124
|
+
// --account has no local account home to validate against.
|
|
1125
|
+
// The probe can block for up to MODEL_PROBE_TIMEOUT_MS. A cancel or any
|
|
1126
|
+
// other terminal decision taken meanwhile is final: after every probe
|
|
1127
|
+
// result the lane is re-read and a terminal lane is returned as it is
|
|
1128
|
+
// (so runPickup never proceeds into admission for it). The failure
|
|
1129
|
+
// write itself refuses a terminal record inside its CAS
|
|
1130
|
+
// (pickupFailureUpdater), so a cancel landing between this re-read and
|
|
1131
|
+
// that write is kept too.
|
|
1132
|
+
let current = lane;
|
|
1133
|
+
if (lane.model !== undefined) {
|
|
1134
|
+
let unsupported = null;
|
|
1135
|
+
try {
|
|
1136
|
+
validateLaneModel(lane.provider, lane.model, resolved.home, modelDeps);
|
|
1137
|
+
}
|
|
1138
|
+
catch (err) {
|
|
1139
|
+
if (isLanesError(err) && err.error === "model_unsupported") {
|
|
1140
|
+
unsupported = err;
|
|
1141
|
+
}
|
|
1142
|
+
else {
|
|
1143
|
+
throw err;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
current = readLane(hqRoot, laneId);
|
|
1147
|
+
if (isTerminalLaneState(current.state) || current.state === "running")
|
|
1148
|
+
return current;
|
|
1149
|
+
if (unsupported) {
|
|
1150
|
+
return markPickupFailure(hqRoot, laneId, "model_unsupported", {
|
|
1151
|
+
...unsupported.details,
|
|
1152
|
+
account: resolved.id,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (current.account === resolved.id && current.pickup_validation_pending === undefined) {
|
|
1157
|
+
return current;
|
|
1158
|
+
}
|
|
1109
1159
|
// The bind is the executing host's pre-spawn account resolution, the same
|
|
1110
1160
|
// write spawnLaneWorker makes for a local lane, so it carries the spawn
|
|
1111
1161
|
// writer. `account` stays closed to `command`: an operator command never
|
|
1112
|
-
// re-pins the account of a lane it does not execute.
|
|
1162
|
+
// re-pins the account of a lane it does not execute. The bind also
|
|
1163
|
+
// clears the pickup marker: from here the reconcile pass may admit it.
|
|
1113
1164
|
return retryUpdateLane(hqRoot, laneId, "spawn", (current) => {
|
|
1114
|
-
if (current.
|
|
1165
|
+
if (isTerminalLaneState(current.state) || current.state === "running")
|
|
1166
|
+
return current;
|
|
1167
|
+
if (current.account === resolved.id && current.pickup_validation_pending === undefined) {
|
|
1115
1168
|
return current;
|
|
1116
|
-
|
|
1169
|
+
}
|
|
1170
|
+
return {
|
|
1171
|
+
...current,
|
|
1172
|
+
account: resolved.id,
|
|
1173
|
+
pickup_validation_pending: undefined,
|
|
1174
|
+
pickup_validation_pending_since: undefined,
|
|
1175
|
+
};
|
|
1117
1176
|
});
|
|
1118
1177
|
}
|
|
1119
1178
|
catch (err) {
|
|
@@ -1255,6 +1314,106 @@ async function runCreate(ctx, opts) {
|
|
|
1255
1314
|
}
|
|
1256
1315
|
const now = ctx.now();
|
|
1257
1316
|
const createdAt = now.toISOString();
|
|
1317
|
+
// `--model` is validated against the provider's own model list (models.ts)
|
|
1318
|
+
// before anything is written or posted: ahead of the mesh thread, so a
|
|
1319
|
+
// refusal leaves no orphan thread, and ahead of the admission lock, so a
|
|
1320
|
+
// slow `grok models` / `claude --help` probe never holds the lock other
|
|
1321
|
+
// creates wait on. A pinned create checks the pinned account's home. An
|
|
1322
|
+
// unpinned create is not bound to an account here (persist re-resolves the
|
|
1323
|
+
// balanced account under the lock, so overlapping creates still spread),
|
|
1324
|
+
// so it checks the balanced candidate first and then every other enabled,
|
|
1325
|
+
// logged-in account the locked selection could land on (codex catalogs are per home
|
|
1326
|
+
// and can differ). `validatedAccounts` records which accepted the model
|
|
1327
|
+
// or had no readable list, and the balanced pick under the lock is
|
|
1328
|
+
// restricted to that set (resolveAccount's allowedAccounts): an account
|
|
1329
|
+
// whose list refused the model is never selected, so nothing has to be
|
|
1330
|
+
// refused after the mesh thread exists, and no probe or catalog read runs
|
|
1331
|
+
// while the lock is held. Every outpost create,
|
|
1332
|
+
// pinned or not, is validated at pickup on the executing host instead:
|
|
1333
|
+
// its provider list and CLI are the ones that run the lane.
|
|
1334
|
+
const modelDeps = {
|
|
1335
|
+
readFileSync: ctx.readFileSync,
|
|
1336
|
+
probe: ctx.probeProvider,
|
|
1337
|
+
env: ctx.env,
|
|
1338
|
+
};
|
|
1339
|
+
const validatedAccounts = new Set();
|
|
1340
|
+
if (cliModel !== undefined && !outpostId) {
|
|
1341
|
+
const accounts = config.providers[provider].accounts;
|
|
1342
|
+
const candidate = pinnedAccount ??
|
|
1343
|
+
resolveAccount(provider, undefined, config, {
|
|
1344
|
+
hqRoot,
|
|
1345
|
+
existsSync: ctx.existsSync,
|
|
1346
|
+
cliModel,
|
|
1347
|
+
}).id;
|
|
1348
|
+
const toValidate = pinnedAccount !== undefined
|
|
1349
|
+
? [candidate]
|
|
1350
|
+
: [
|
|
1351
|
+
candidate,
|
|
1352
|
+
...Object.keys(accounts)
|
|
1353
|
+
.filter((id) => id !== candidate &&
|
|
1354
|
+
accounts[id]?.enabled &&
|
|
1355
|
+
accountStatus(provider, id, config, { existsSync: ctx.existsSync }).logged_in === true)
|
|
1356
|
+
.sort(),
|
|
1357
|
+
];
|
|
1358
|
+
const checkedAccounts = [];
|
|
1359
|
+
const unsupported = [];
|
|
1360
|
+
const quotaBlocked = [];
|
|
1361
|
+
const quota = loadQuotaState(hqRoot);
|
|
1362
|
+
for (const id of toValidate) {
|
|
1363
|
+
const home = accounts[id]?.home;
|
|
1364
|
+
if (home === undefined)
|
|
1365
|
+
continue;
|
|
1366
|
+
checkedAccounts.push(id);
|
|
1367
|
+
try {
|
|
1368
|
+
validateLaneModel(provider, cliModel, home, modelDeps);
|
|
1369
|
+
}
|
|
1370
|
+
catch (err) {
|
|
1371
|
+
if (!(isLanesError(err) && err.error === "model_unsupported"))
|
|
1372
|
+
throw err;
|
|
1373
|
+
unsupported.push({ account: id, error: err });
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
const block = quotaBlockFor(quota, provider, id, cliModel, now);
|
|
1377
|
+
if (block.blocked) {
|
|
1378
|
+
quotaBlocked.push({ account: id, block });
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
validatedAccounts.add(id);
|
|
1382
|
+
}
|
|
1383
|
+
if (validatedAccounts.size === 0 && quotaBlocked.length > 0) {
|
|
1384
|
+
const blocked = quotaBlocked[0];
|
|
1385
|
+
const model = blocked.block.model ?? cliModel;
|
|
1386
|
+
const checkedSuffix = ` Checked accounts: ${checkedAccounts.join(", ")}`;
|
|
1387
|
+
if (blocked.block.reason === "account_model_unavailable") {
|
|
1388
|
+
throw new AccountResolutionError("account_model_unavailable", `Account ${JSON.stringify(blocked.account)} cannot run model ${model ?? "(no model)"}: ${blocked.block.marker ?? "needs credits"}${checkedSuffix}`, {
|
|
1389
|
+
provider,
|
|
1390
|
+
account: blocked.account,
|
|
1391
|
+
model: model ?? null,
|
|
1392
|
+
marker: blocked.block.marker ?? null,
|
|
1393
|
+
checked_accounts: checkedAccounts,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
throw new AccountResolutionError("account_quota_exhausted", `Account ${JSON.stringify(blocked.account)} is out of quota${blocked.block.quota_reset_at ? ` until ${blocked.block.quota_reset_at}` : ""} for model ${model ?? "(no model)"}${checkedSuffix}`, {
|
|
1397
|
+
provider,
|
|
1398
|
+
account: blocked.account,
|
|
1399
|
+
model: model ?? null,
|
|
1400
|
+
quota_reset_at: blocked.block.quota_reset_at ?? null,
|
|
1401
|
+
checked_accounts: checkedAccounts,
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
if (validatedAccounts.size === 0 && unsupported.length > 0) {
|
|
1405
|
+
const first = unsupported[0].error;
|
|
1406
|
+
throw new LanesError(first.error, `${first.message} Checked accounts: ${checkedAccounts.join(", ")}`, {
|
|
1407
|
+
...first.details,
|
|
1408
|
+
checked_accounts: checkedAccounts,
|
|
1409
|
+
account_errors: unsupported.map(({ account, error }) => ({
|
|
1410
|
+
account,
|
|
1411
|
+
accepted: error.details.accepted,
|
|
1412
|
+
source: error.details.source,
|
|
1413
|
+
})),
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1258
1417
|
const threadId = company
|
|
1259
1418
|
? await ctx.mesh.ensureProjectThread(company, project)
|
|
1260
1419
|
: undefined;
|
|
@@ -1266,6 +1425,7 @@ async function runCreate(ctx, opts) {
|
|
|
1266
1425
|
hqRoot,
|
|
1267
1426
|
existsSync: ctx.existsSync,
|
|
1268
1427
|
cliModel,
|
|
1428
|
+
...(validatedAccounts.size > 0 ? { allowedAccounts: [...validatedAccounts] } : {}),
|
|
1269
1429
|
}).id;
|
|
1270
1430
|
}
|
|
1271
1431
|
const accountCfg = pinnedAccount !== undefined
|
|
@@ -1525,13 +1685,30 @@ function parseRemotePickupJson(stdout) {
|
|
|
1525
1685
|
}
|
|
1526
1686
|
return null;
|
|
1527
1687
|
}
|
|
1528
|
-
function ingestPickupPayload(hqRoot, payload) {
|
|
1688
|
+
function ingestPickupPayload(hqRoot, payload, now) {
|
|
1529
1689
|
const laneId = payload.lane.lane_id;
|
|
1530
1690
|
if (!laneId) {
|
|
1531
1691
|
throw new LanesError("outpost_payload_invalid", "Pickup payload lane.lane_id is required", {});
|
|
1532
1692
|
}
|
|
1533
1693
|
const existing = tryReadLane(hqRoot, laneId);
|
|
1534
|
-
|
|
1694
|
+
// The lane is stored ineligible for admission until the account bind
|
|
1695
|
+
// clears the marker (see LaneRecord.pickup_validation_pending).
|
|
1696
|
+
const lane = existing === null
|
|
1697
|
+
? createLane(hqRoot, {
|
|
1698
|
+
...payload.lane,
|
|
1699
|
+
updated_at: now.toISOString(),
|
|
1700
|
+
pickup_validation_pending: true,
|
|
1701
|
+
pickup_validation_pending_since: now.toISOString(),
|
|
1702
|
+
})
|
|
1703
|
+
: existing.state === "queued"
|
|
1704
|
+
? retryUpdateLane(hqRoot, laneId, "command", (current) => current.state === "queued"
|
|
1705
|
+
? {
|
|
1706
|
+
...current,
|
|
1707
|
+
pickup_validation_pending: true,
|
|
1708
|
+
pickup_validation_pending_since: now.toISOString(),
|
|
1709
|
+
}
|
|
1710
|
+
: current)
|
|
1711
|
+
: existing;
|
|
1535
1712
|
const dest = runDir(hqRoot, lane.lane_id);
|
|
1536
1713
|
ensureDir(path.join(dest, "logs"));
|
|
1537
1714
|
ensureDir(path.join(dest, "envelopes"));
|
|
@@ -1574,7 +1751,7 @@ async function runPickup(ctx, opts) {
|
|
|
1574
1751
|
throw new LanesError("outpost_payload_invalid", `Pickup payload is not JSON: ${err instanceof Error ? err.message : String(err)}`, {});
|
|
1575
1752
|
}
|
|
1576
1753
|
const payload = parseOutpostPickupPayload(parsedJson);
|
|
1577
|
-
const ingested = ingestPickupPayload(hqRoot, payload);
|
|
1754
|
+
const ingested = ingestPickupPayload(hqRoot, payload, ctx.now());
|
|
1578
1755
|
if (ingested.host.kind === "outpost") {
|
|
1579
1756
|
try {
|
|
1580
1757
|
refuseOutpostSelfTarget(hqRoot, ingested, {
|
|
@@ -1599,7 +1776,11 @@ async function runPickup(ctx, opts) {
|
|
|
1599
1776
|
throw err;
|
|
1600
1777
|
}
|
|
1601
1778
|
}
|
|
1602
|
-
const afterAccount = applyOutpostAccountPickup(hqRoot, ingested.lane_id
|
|
1779
|
+
const afterAccount = applyOutpostAccountPickup(hqRoot, ingested.lane_id, {
|
|
1780
|
+
readFileSync: ctx.readFileSync,
|
|
1781
|
+
probe: ctx.probeProvider,
|
|
1782
|
+
env: ctx.env,
|
|
1783
|
+
});
|
|
1603
1784
|
if (isTerminalLaneState(afterAccount.state)) {
|
|
1604
1785
|
printJson({
|
|
1605
1786
|
ok: true,
|
|
@@ -1741,23 +1922,182 @@ async function runShow(ctx, laneId) {
|
|
|
1741
1922
|
provider_session_id_absent: lane.provider_session_id_absent ?? null,
|
|
1742
1923
|
envelope_repair_mode: lane.envelope_repair?.mode ?? null,
|
|
1743
1924
|
last_resume: lane.last_resume ?? null,
|
|
1925
|
+
account: lane.account ?? null,
|
|
1926
|
+
account_history: lane.account_history ?? [],
|
|
1927
|
+
pending_resume: lane.pending_resume ?? null,
|
|
1744
1928
|
last_run_usage: lane.last_run_usage ?? null,
|
|
1745
1929
|
model: lane.model ?? null,
|
|
1746
1930
|
effort: lane.effort ?? null,
|
|
1747
1931
|
effort_source: lane.effort_source ?? null,
|
|
1932
|
+
terminal_reason: lane.terminal_reason ?? null,
|
|
1933
|
+
terminal_details: lane.terminal_details ?? null,
|
|
1748
1934
|
last_message_at: lane.last_message_at ?? null,
|
|
1749
1935
|
last_compaction: lane.last_compaction
|
|
1750
1936
|
? { ...lane.last_compaction, after: compactionAfterUsage(lane) }
|
|
1751
1937
|
: null,
|
|
1752
1938
|
});
|
|
1753
1939
|
}
|
|
1940
|
+
/**
|
|
1941
|
+
* US-023: move the lane's provider session to `--account` (same provider)
|
|
1942
|
+
* before a resume or a message resume. Every check runs before anything is
|
|
1943
|
+
* copied or written; the copy runs before the record changes, so a failed
|
|
1944
|
+
* copy leaves the lane on its source account. The turn is started by the
|
|
1945
|
+
* caller, or parked as `pending_resume` when the target is at its cap so the
|
|
1946
|
+
* reconcile pass resumes it there.
|
|
1947
|
+
*/
|
|
1948
|
+
async function prepareAccountMove(ctx, lane, input) {
|
|
1949
|
+
const hqRoot = ctx.hqRoot();
|
|
1950
|
+
const now = ctx.now();
|
|
1951
|
+
const laneId = lane.lane_id;
|
|
1952
|
+
if (lane.state === "running") {
|
|
1953
|
+
throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
|
|
1954
|
+
lane_id: laneId,
|
|
1955
|
+
state: lane.state,
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
// `--account <id>` or `--account <provider>:<id>`: a provider prefix that
|
|
1959
|
+
// is not the lane's is a cross-provider move, which no session survives.
|
|
1960
|
+
const raw = input.account.trim();
|
|
1961
|
+
const colon = raw.indexOf(":");
|
|
1962
|
+
const requestedProvider = colon > 0 ? raw.slice(0, colon) : undefined;
|
|
1963
|
+
const target = colon > 0 ? raw.slice(colon + 1) : raw;
|
|
1964
|
+
if (requestedProvider !== undefined && requestedProvider !== lane.provider) {
|
|
1965
|
+
throw new LanesError("account_provider_mismatch", `Lane ${laneId} runs on ${lane.provider}; account ${JSON.stringify(raw)} names provider ${requestedProvider}. Sessions do not move between providers`, { lane_id: laneId, provider: lane.provider, requested_provider: requestedProvider, account: target });
|
|
1966
|
+
}
|
|
1967
|
+
if (!target) {
|
|
1968
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(input.account)} for ${lane.provider}`, {
|
|
1969
|
+
provider: lane.provider,
|
|
1970
|
+
account: input.account,
|
|
1971
|
+
enabled: [],
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
if (target === lane.account) {
|
|
1975
|
+
throw new LanesError("account_unchanged", `Lane ${laneId} already runs on account ${target}`, {
|
|
1976
|
+
lane_id: laneId,
|
|
1977
|
+
account: target,
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
if (!providerSupportsSessionResume(lane.provider)) {
|
|
1981
|
+
throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
|
|
1982
|
+
}
|
|
1983
|
+
if (!hasRecordedSessionId(lane)) {
|
|
1984
|
+
throw new LanesError("no_provider_session", `Lane ${laneId} has no recorded provider session id, so there is no session to move`, { lane_id: laneId, provider: lane.provider });
|
|
1985
|
+
}
|
|
1986
|
+
const sessionId = lane.provider_session_id;
|
|
1987
|
+
const config = loadLanesConfig(hqRoot);
|
|
1988
|
+
const providerCfg = config.providers[lane.provider];
|
|
1989
|
+
const targetCfg = providerCfg.accounts[target];
|
|
1990
|
+
if (targetCfg === undefined) {
|
|
1991
|
+
throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(target)} for ${lane.provider}. Configured: ${Object.keys(providerCfg.accounts).join(", ") || "(none)"}`, { provider: lane.provider, account: target, enabled: Object.keys(providerCfg.accounts).filter((id) => providerCfg.accounts[id]?.enabled) });
|
|
1992
|
+
}
|
|
1993
|
+
if (!targetCfg.enabled) {
|
|
1994
|
+
throw new AccountResolutionError("account_disabled", `Account ${JSON.stringify(target)} is disabled${targetCfg.disabled_reason ? `: ${targetCfg.disabled_reason}` : ""}`, { provider: lane.provider, account: target, reason: targetCfg.disabled_reason ?? "" });
|
|
1995
|
+
}
|
|
1996
|
+
if (isLoggedOut(accountStatus(lane.provider, target, config, { existsSync: ctx.existsSync }))) {
|
|
1997
|
+
throw new LanesError("account_not_logged_in", `Account ${target} for ${lane.provider} is not logged in (no credential file)`, { provider: lane.provider, account: target, lane_id: laneId });
|
|
1998
|
+
}
|
|
1999
|
+
// The model the moved run would use, resolved the way spawn resolves it:
|
|
2000
|
+
// the operator's --model, else the lane's own, else the target account's
|
|
2001
|
+
// default, else the provider's. The quota and validation checks use this
|
|
2002
|
+
// effective model so a per-model hold on the target's default is seen
|
|
2003
|
+
// here, before anything is copied. A target that pins a different model
|
|
2004
|
+
// refuses the lane's model unless the operator names one for the move.
|
|
2005
|
+
const model = resolveLaneModel({
|
|
2006
|
+
cliModel: input.cliModel ?? lane.model,
|
|
2007
|
+
accountModel: targetCfg.model,
|
|
2008
|
+
providerModel: providerCfg.model,
|
|
2009
|
+
});
|
|
2010
|
+
if (input.cliModel === undefined && lane.model !== undefined && targetCfg.model !== undefined && targetCfg.model !== lane.model) {
|
|
2011
|
+
throw new LanesError("model_unsupported", `Account ${target} pins model ${targetCfg.model}; lane ${laneId} runs ${lane.model}. Pass --model ${targetCfg.model} to move it onto that model`, { provider: lane.provider, account: target, model: lane.model, account_model: targetCfg.model, lane_id: laneId });
|
|
2012
|
+
}
|
|
2013
|
+
const block = quotaBlockFor(loadQuotaState(hqRoot), lane.provider, target, model, now);
|
|
2014
|
+
if (block.blocked) {
|
|
2015
|
+
throw new LanesError("account_quota_held", `Account ${target} for ${lane.provider} is held${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""}${block.marker ? ` (${block.marker})` : ""}`, { provider: lane.provider, account: target, quota_reset_at: block.quota_reset_at ?? null, marker: block.marker ?? null, model: model ?? null });
|
|
2016
|
+
}
|
|
2017
|
+
if (model !== undefined) {
|
|
2018
|
+
validateLaneModel(lane.provider, model, targetCfg.home, {
|
|
2019
|
+
readFileSync: ctx.readFileSync,
|
|
2020
|
+
probe: ctx.probeProvider,
|
|
2021
|
+
env: ctx.env,
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
const sourceCfg = lane.account !== undefined ? providerCfg.accounts[lane.account] : undefined;
|
|
2025
|
+
if (sourceCfg === undefined) {
|
|
2026
|
+
throw new AccountResolutionError("unknown_account", `Lane ${laneId} records account ${JSON.stringify(lane.account)} which is not configured for ${lane.provider}`, { provider: lane.provider, account: lane.account ?? "", enabled: [] });
|
|
2027
|
+
}
|
|
2028
|
+
// Checks done. Copy the session files, then write the record.
|
|
2029
|
+
const copy = copySessionFiles(planSessionCopy(lane.provider, {
|
|
2030
|
+
sourceHome: sourceCfg.home,
|
|
2031
|
+
targetHome: targetCfg.home,
|
|
2032
|
+
cwd: hqRoot,
|
|
2033
|
+
sessionId,
|
|
2034
|
+
}));
|
|
2035
|
+
const at = now.toISOString();
|
|
2036
|
+
const from = lane.account ?? null;
|
|
2037
|
+
// The capacity decision and the record write run under the target
|
|
2038
|
+
// account's admission lock, the same serialization lane creation uses. A
|
|
2039
|
+
// slot is taken by a running lane or by a lane another move has already
|
|
2040
|
+
// pointed at the account whose spawn has not claimed it yet
|
|
2041
|
+
// (`pending_resume` set by this write, cleared by the claim), so two moves
|
|
2042
|
+
// into one free slot cannot both start.
|
|
2043
|
+
return withAdmissionLock(admissionLockPath(hqRoot, lane.provider, target), async () => {
|
|
2044
|
+
const lanes = loadAllLanes(hqRoot);
|
|
2045
|
+
// A queued lane with a pending resume waits for admission and holds
|
|
2046
|
+
// no slot; one in any other state with a pending resume is a move
|
|
2047
|
+
// whose spawn is about to claim, and holds the slot it was granted.
|
|
2048
|
+
const occupies = (row) => row.lane_id !== laneId &&
|
|
2049
|
+
(row.state === "running" || (row.pending_resume !== undefined && row.state !== "queued"));
|
|
2050
|
+
const onProvider = lanes.filter((row) => row.provider === lane.provider && occupies(row));
|
|
2051
|
+
const atCap = onProvider.length >= providerCfg.max_running ||
|
|
2052
|
+
onProvider.filter((row) => row.account === target).length >= accountMaxRunning(providerCfg, target);
|
|
2053
|
+
const moved = casUpdateLane(hqRoot, laneId, (current) => {
|
|
2054
|
+
// The copy and every check above were made from `lane`; a record
|
|
2055
|
+
// that moved on since (another move, a spawn, a cancel) invalidates
|
|
2056
|
+
// them, so the write is refused rather than applied to a state it
|
|
2057
|
+
// was not prepared for.
|
|
2058
|
+
if (current.version !== lane.version) {
|
|
2059
|
+
throw new LanesError("account_move_conflict", `Lane ${laneId} changed while the move was prepared (version ${lane.version} → ${current.version}, state ${current.state}, account ${current.account ?? "(none)"}); run the move again`, { lane_id: laneId, prepared_version: lane.version, version: current.version, state: current.state, account: current.account ?? null });
|
|
2060
|
+
}
|
|
2061
|
+
const next = {
|
|
2062
|
+
...current,
|
|
2063
|
+
account: target,
|
|
2064
|
+
...(input.cliModel !== undefined ? { model: input.cliModel } : {}),
|
|
2065
|
+
account_history: appendCappedHistory(current.account_history ?? [], {
|
|
2066
|
+
from,
|
|
2067
|
+
to: target,
|
|
2068
|
+
at,
|
|
2069
|
+
reason: SESSION_MOVE_REASON,
|
|
2070
|
+
}),
|
|
2071
|
+
pending_resume: { session_id: sessionId, at, ...input.pending },
|
|
2072
|
+
};
|
|
2073
|
+
if (atCap) {
|
|
2074
|
+
next.state = "queued";
|
|
2075
|
+
next.queued_reason = "cap";
|
|
2076
|
+
}
|
|
2077
|
+
return next;
|
|
2078
|
+
});
|
|
2079
|
+
return { lane: moved, from, to: target, copy, queued: atCap };
|
|
2080
|
+
}, ctx.sleep);
|
|
2081
|
+
}
|
|
1754
2082
|
async function runMessage(ctx, laneId, opts) {
|
|
1755
2083
|
const hqRoot = ctx.hqRoot();
|
|
1756
2084
|
const text = opts.text;
|
|
1757
2085
|
if (!text) {
|
|
1758
2086
|
throw new LanesError("missing_text", "--text is required", {});
|
|
1759
2087
|
}
|
|
1760
|
-
|
|
2088
|
+
let lane = readLane(hqRoot, laneId);
|
|
2089
|
+
// An account move is refused on its own terms first: a running lane and a
|
|
2090
|
+
// provider without session resume get the move's named errors, not the
|
|
2091
|
+
// generic delivery refusal below.
|
|
2092
|
+
if (opts.account !== undefined && lane.state === "running") {
|
|
2093
|
+
throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
|
|
2094
|
+
lane_id: laneId,
|
|
2095
|
+
state: lane.state,
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
if (opts.account !== undefined && !providerSupportsSessionResume(lane.provider)) {
|
|
2099
|
+
throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
|
|
2100
|
+
}
|
|
1761
2101
|
// One decision point for how a follow-up reaches this lane (delivery.ts).
|
|
1762
2102
|
// `none` refuses before anything is written: no inbox append, no spawn.
|
|
1763
2103
|
const mechanism = laneDeliveryMechanism(lane);
|
|
@@ -1770,6 +2110,9 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
1770
2110
|
message,
|
|
1771
2111
|
});
|
|
1772
2112
|
}
|
|
2113
|
+
if (opts.model !== undefined && opts.account === undefined) {
|
|
2114
|
+
throw new LanesError("model_requires_account", "--model on hq lanes message is only accepted together with --account", {});
|
|
2115
|
+
}
|
|
1773
2116
|
const messageId = mintMessageId();
|
|
1774
2117
|
const at = ctx.now().toISOString();
|
|
1775
2118
|
const projectId = opts.project?.trim() || lane.project_id;
|
|
@@ -1782,7 +2125,24 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
1782
2125
|
story_id: storyId,
|
|
1783
2126
|
};
|
|
1784
2127
|
let resumed = null;
|
|
1785
|
-
|
|
2128
|
+
let move = null;
|
|
2129
|
+
if (opts.account !== undefined) {
|
|
2130
|
+
// A moved message is always a resume on the target account: a running
|
|
2131
|
+
// lane (the dropbox mechanism) is refused inside prepareAccountMove.
|
|
2132
|
+
move = await prepareAccountMove(ctx, lane, {
|
|
2133
|
+
account: opts.account,
|
|
2134
|
+
cliModel: opts.model?.trim() || undefined,
|
|
2135
|
+
pending: { trigger: "message", text, message_id: messageId, compact: opts.compact !== false },
|
|
2136
|
+
});
|
|
2137
|
+
lane = move.lane;
|
|
2138
|
+
if (move.queued) {
|
|
2139
|
+
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
if (move?.queued) {
|
|
2143
|
+
// Parked at the target's cap: reconcile resumes the session there.
|
|
2144
|
+
}
|
|
2145
|
+
else if (mechanism === "dropbox") {
|
|
1786
2146
|
// Live (or about-to-be-live) process: the PreToolUse drain delivers it.
|
|
1787
2147
|
if (!laneAcceptsOperatorMessage(lane)) {
|
|
1788
2148
|
throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
|
|
@@ -1841,7 +2201,11 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
1841
2201
|
// started from; `resume` has started a new worker with the message as its
|
|
1842
2202
|
// prompt. Neither is "delivered", so the response never says so.
|
|
1843
2203
|
const drainHook = drainHookStatusFor(ctx, next);
|
|
1844
|
-
const deliveryStatus =
|
|
2204
|
+
const deliveryStatus = move?.queued
|
|
2205
|
+
? "queued_on_target_account"
|
|
2206
|
+
: mechanism === "dropbox"
|
|
2207
|
+
? "awaiting_next_tool_call"
|
|
2208
|
+
: "worker_spawned";
|
|
1845
2209
|
const warning = mechanism === "dropbox" && drainHook !== null && !drainHook.registered
|
|
1846
2210
|
? `The drain hook is not registered in ${drainHook.path}; the running worker will not receive this message. It stays in the inbox and is delivered on the lane's next start.`
|
|
1847
2211
|
: mechanism === "dropbox" && next.stalled === true
|
|
@@ -1863,11 +2227,24 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
1863
2227
|
compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
1864
2228
|
? resumed.last_compaction
|
|
1865
2229
|
: null,
|
|
2230
|
+
account_move: move ? accountMoveSummary(move) : null,
|
|
1866
2231
|
project_id: next.project_id,
|
|
1867
2232
|
story_id: next.story_id,
|
|
1868
2233
|
lane: next,
|
|
1869
2234
|
});
|
|
1870
2235
|
}
|
|
2236
|
+
function accountMoveSummary(move) {
|
|
2237
|
+
return {
|
|
2238
|
+
from: move.from,
|
|
2239
|
+
to: move.to,
|
|
2240
|
+
queued: move.queued,
|
|
2241
|
+
copied: move.copy.copied,
|
|
2242
|
+
sources: move.copy.sources,
|
|
2243
|
+
targets: move.copy.targets,
|
|
2244
|
+
source_dir: move.copy.source_dir,
|
|
2245
|
+
target_dir: move.copy.target_dir,
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
1871
2248
|
/**
|
|
1872
2249
|
* `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
|
|
1873
2250
|
* continues a lane in `interrupted`, `done` or `blocked` on its recorded
|
|
@@ -1879,7 +2256,19 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
1879
2256
|
*/
|
|
1880
2257
|
async function runResume(ctx, laneId, opts) {
|
|
1881
2258
|
const hqRoot = ctx.hqRoot();
|
|
1882
|
-
|
|
2259
|
+
let lane = readLane(hqRoot, laneId);
|
|
2260
|
+
if (opts.model !== undefined && opts.account === undefined) {
|
|
2261
|
+
throw new LanesError("model_requires_account", "--model on hq lanes resume is only accepted together with --account", {});
|
|
2262
|
+
}
|
|
2263
|
+
if (opts.account !== undefined && lane.state === "running") {
|
|
2264
|
+
throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
|
|
2265
|
+
lane_id: laneId,
|
|
2266
|
+
state: lane.state,
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
if (opts.account !== undefined && !providerSupportsSessionResume(lane.provider)) {
|
|
2270
|
+
throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
|
|
2271
|
+
}
|
|
1883
2272
|
const refusal = resumeCommandRefusal(lane);
|
|
1884
2273
|
if (refusal !== null) {
|
|
1885
2274
|
throw new LanesError(refusal.error, refusal.message, {
|
|
@@ -1896,25 +2285,42 @@ async function runResume(ctx, laneId, opts) {
|
|
|
1896
2285
|
const storyId = opts.story?.trim() || lane.story_id;
|
|
1897
2286
|
const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
|
|
1898
2287
|
const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2288
|
+
let move = null;
|
|
2289
|
+
if (opts.account !== undefined) {
|
|
2290
|
+
move = await prepareAccountMove(ctx, lane, {
|
|
2291
|
+
account: opts.account,
|
|
2292
|
+
cliModel: opts.model?.trim() || undefined,
|
|
2293
|
+
pending: {
|
|
2294
|
+
trigger: "resume",
|
|
2295
|
+
resumed_from: resumedFrom,
|
|
2296
|
+
...(text !== undefined ? { text } : {}),
|
|
2297
|
+
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
2298
|
+
compact: opts.compact !== false,
|
|
2299
|
+
},
|
|
2300
|
+
});
|
|
2301
|
+
lane = move.lane;
|
|
2302
|
+
}
|
|
2303
|
+
const resumed = move?.queued
|
|
2304
|
+
? lane
|
|
2305
|
+
: spawnLaneWorker(hqRoot, lane, {
|
|
2306
|
+
env: ctx.env,
|
|
2307
|
+
existsSync: ctx.existsSync,
|
|
2308
|
+
spawnProcess: ctx.spawnProcess,
|
|
2309
|
+
proveDetached: ctx.proveDetached,
|
|
2310
|
+
killProcess: ctx.killProcess,
|
|
2311
|
+
now: ctx.now,
|
|
2312
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
2313
|
+
runCompaction: ctx.runCompaction,
|
|
2314
|
+
locateTranscript: ctx.locateTranscript,
|
|
2315
|
+
}, {
|
|
2316
|
+
trigger: {
|
|
2317
|
+
kind: "resume",
|
|
2318
|
+
from: resumedFrom,
|
|
2319
|
+
...(text !== undefined ? { text } : {}),
|
|
2320
|
+
...(messageId !== undefined ? { message_id: messageId } : {}),
|
|
2321
|
+
},
|
|
2322
|
+
compact: opts.compact !== false,
|
|
2323
|
+
});
|
|
1918
2324
|
if (text !== undefined && messageId !== undefined) {
|
|
1919
2325
|
// Same audit copy a message resume leaves; nothing drains it. Archive only
|
|
1920
2326
|
// after a successful spawn so a concurrent resume cannot leave a phantom.
|
|
@@ -1961,7 +2367,8 @@ async function runResume(ctx, laneId, opts) {
|
|
|
1961
2367
|
message_id: messageId ?? null,
|
|
1962
2368
|
text: text ?? null,
|
|
1963
2369
|
delivery: "resume",
|
|
1964
|
-
delivery_status: "worker_spawned",
|
|
2370
|
+
delivery_status: move?.queued ? "queued_on_target_account" : "worker_spawned",
|
|
2371
|
+
account_move: move ? accountMoveSummary(move) : null,
|
|
1965
2372
|
resumed_session_id: resumed.last_resume?.session_id ?? null,
|
|
1966
2373
|
last_resume: next.last_resume ?? null,
|
|
1967
2374
|
compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
@@ -2025,15 +2432,26 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
2025
2432
|
throw new LanesError("demotion_with_pending_questions", "Cannot increase a lane's depth while it has pending questions", { lane_id: laneId, from_depth: oldDepth, to_depth: newDepth });
|
|
2026
2433
|
}
|
|
2027
2434
|
const override = { laneId, senior: nextSenior, depth: newDepth };
|
|
2435
|
+
const descendants = [];
|
|
2028
2436
|
for (const other of loadAllLanes(hqRoot)) {
|
|
2029
2437
|
if (other.lane_id === laneId)
|
|
2030
2438
|
continue;
|
|
2031
2439
|
if (!walkHitsLane(hqRoot, other.senior, laneId, ctx))
|
|
2032
2440
|
continue;
|
|
2033
|
-
const
|
|
2034
|
-
|
|
2035
|
-
|
|
2441
|
+
const oldDescendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx);
|
|
2442
|
+
const descendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx, override);
|
|
2443
|
+
if (descendantDepth > MAX_LANE_DEPTH) {
|
|
2444
|
+
throw new LanesError("max_lane_depth", `Re-parent would put descendant ${other.lane_id} at depth ${descendantDepth}`, { lane_id: other.lane_id, depth: descendantDepth, max: MAX_LANE_DEPTH });
|
|
2445
|
+
}
|
|
2446
|
+
if (descendantDepth > oldDescendantDepth &&
|
|
2447
|
+
pendingQuestionsForLane(hqRoot, other.lane_id).length > 0) {
|
|
2448
|
+
throw new LanesError("demotion_with_pending_questions", "Cannot increase a lane's depth while it has pending questions", {
|
|
2449
|
+
lane_id: other.lane_id,
|
|
2450
|
+
from_depth: oldDescendantDepth,
|
|
2451
|
+
to_depth: descendantDepth,
|
|
2452
|
+
});
|
|
2036
2453
|
}
|
|
2454
|
+
descendants.push({ laneId: other.lane_id, depth: descendantDepth });
|
|
2037
2455
|
}
|
|
2038
2456
|
const at = ctx.now().toISOString();
|
|
2039
2457
|
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
@@ -2053,6 +2471,13 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
2053
2471
|
}),
|
|
2054
2472
|
};
|
|
2055
2473
|
});
|
|
2474
|
+
for (const descendant of descendants) {
|
|
2475
|
+
casUpdateLane(hqRoot, descendant.laneId, (current) => {
|
|
2476
|
+
if (current.depth === descendant.depth)
|
|
2477
|
+
return current;
|
|
2478
|
+
return { ...current, depth: descendant.depth };
|
|
2479
|
+
});
|
|
2480
|
+
}
|
|
2056
2481
|
for (const question of pending) {
|
|
2057
2482
|
casUpdateQuestion(hqRoot, question.question_id, (current) => {
|
|
2058
2483
|
const already = current.routing_history.some((entry) => entry.reason === "reparented" && entry.at === at);
|
|
@@ -2671,6 +3096,7 @@ async function runReconcile(ctx, opts) {
|
|
|
2671
3096
|
throttleDeps: ctx.throttleDeps,
|
|
2672
3097
|
livenessDeps: livenessDepsFromCtx(ctx),
|
|
2673
3098
|
presenceConnected: ctx.presenceConnected,
|
|
3099
|
+
mesh: ctx.mesh,
|
|
2674
3100
|
env: ctx.env,
|
|
2675
3101
|
});
|
|
2676
3102
|
printJson(summary);
|
|
@@ -2692,7 +3118,7 @@ async function runReconcileInstall(ctx, opts) {
|
|
|
2692
3118
|
hqRoot: ctx.hqRoot(),
|
|
2693
3119
|
enable: Boolean(opts.enable),
|
|
2694
3120
|
runCommand: opts.enable ? ctx.runSystemctl : undefined,
|
|
2695
|
-
|
|
3121
|
+
providerDirs: providerPath.resolved_dirs,
|
|
2696
3122
|
});
|
|
2697
3123
|
printJson({
|
|
2698
3124
|
...result,
|
|
@@ -2781,6 +3207,8 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
2781
3207
|
.requiredOption("--text <msg>", "Message text")
|
|
2782
3208
|
.option("--project <slug>", "Re-point project attribution from this message")
|
|
2783
3209
|
.option("--story <id>", "Re-point story attribution from this message")
|
|
3210
|
+
.option("--account <id>", "Move the lane's session to this account of its provider before the message resumes it")
|
|
3211
|
+
.option("--model <id>", "Model for the moved lane (with --account only)")
|
|
2784
3212
|
.option("--no-compact", "Skip the resume-time compaction check for this message")
|
|
2785
3213
|
.action(wrap(async (laneId, opts) => {
|
|
2786
3214
|
await runMessage(ctx, laneId, opts);
|
|
@@ -2792,6 +3220,8 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
2792
3220
|
.option("--text <msg>", "Operator message for the resumed turn")
|
|
2793
3221
|
.option("--project <slug>", "Re-point project attribution from this resume")
|
|
2794
3222
|
.option("--story <id>", "Re-point story attribution from this resume")
|
|
3223
|
+
.option("--account <id>", "Move the lane's session to this account of its provider before resuming")
|
|
3224
|
+
.option("--model <id>", "Model for the moved lane (with --account only)")
|
|
2795
3225
|
.option("--no-compact", "Skip the resume-time compaction check for this resume")
|
|
2796
3226
|
.action(wrap(async (laneId, opts) => {
|
|
2797
3227
|
await runResume(ctx, laneId, opts);
|