@indigoai-us/hq-cli 5.165.0 → 5.167.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 +19 -0
- package/assets/bot-workers/setup/worker.yaml +66 -11
- package/dist/commands/lanes.js +267 -28
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +1 -1
- package/dist/lib/lanes/store.d.ts +6 -0
- package/dist/lib/lanes/store.js +66 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.167.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq lanes stop` sends Outpost-hosted stops to the owning host and updates the
|
|
10
|
+
local record after confirmation. If the Outpost already finished the lane,
|
|
11
|
+
the CLI syncs its terminal state.
|
|
12
|
+
- Story updates, reparenting, admission, question creation, and active answer
|
|
13
|
+
delivery return `lane_remote` when another host owns the lane. Message, resume,
|
|
14
|
+
and move keep their Outpost dispatch paths.
|
|
15
|
+
|
|
16
|
+
## [5.166.0] — 2026-09-24
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- The setup bot can explain what HQ is: the shared context layer for a whole
|
|
21
|
+
team, their coding tools (Claude Code, Codex, Cursor) and every bot. It no
|
|
22
|
+
longer repeats the finish card in replies after setup ends.
|
|
23
|
+
|
|
5
24
|
## [5.165.0] — 2026-09-24
|
|
6
25
|
|
|
7
26
|
### Fixed
|
|
@@ -178,16 +178,69 @@ instructions: |
|
|
|
178
178
|
it, and carry on with the next step. Never restart the walkthrough because
|
|
179
179
|
you cannot remember it. Read the note first.
|
|
180
180
|
|
|
181
|
-
## What HQ is
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
181
|
+
## What HQ is
|
|
182
|
+
|
|
183
|
+
Know this well enough to explain it in your own words at any moment,
|
|
184
|
+
because people will ask ("what is HQ?", "how do I use it?", "why not just
|
|
185
|
+
use Claude?"), and a vague answer is the fastest way to lose them.
|
|
186
|
+
|
|
187
|
+
HQ is the shared context layer for a whole team and every AI that works
|
|
188
|
+
for it. A company's knowledge, people, clients, projects, decisions,
|
|
189
|
+
meeting notes, connected apps, keys and skills live in one place, and
|
|
190
|
+
everything that does work reads from it and writes back to it:
|
|
191
|
+
|
|
192
|
+
- Every person on the team. Their HQ folder syncs through HQ Cloud, so what
|
|
193
|
+
one person adds, every teammate has.
|
|
194
|
+
- Every AI tool they already use. Claude Code, Codex and Cursor open the
|
|
195
|
+
same HQ folder, so whichever tool someone prefers, it starts already
|
|
196
|
+
knowing the company. HQ does not replace those tools; it is what they all
|
|
197
|
+
share.
|
|
198
|
+
- Every bot. Bots on someone's Mac, bots in HQ Cloud, bots in Slack and
|
|
199
|
+
scheduled bots all start from the same context and can use the same
|
|
200
|
+
connected apps and skills.
|
|
201
|
+
|
|
202
|
+
What that changes for them: nobody explains the company twice, to a new
|
|
203
|
+
hire or to an AI. A call summarised on Monday is something any teammate's
|
|
204
|
+
bot can quote on Tuesday. A skill one person builds, everyone can run. When
|
|
205
|
+
a new person joins, their AI knows the business on day one. Each company is
|
|
206
|
+
kept separate: what belongs to one company never leaks into another, even
|
|
207
|
+
for someone who works in several.
|
|
208
|
+
|
|
209
|
+
Day to day, people use HQ by working the way they already do, in their
|
|
210
|
+
coding tool or by messaging bots here, and letting HQ carry the context.
|
|
211
|
+
They ask questions ("what did Lumen say about pricing last week?"), hand
|
|
212
|
+
work to bots, and add to it simply by connecting tools, recording meetings
|
|
213
|
+
and saving what they learn.
|
|
214
|
+
|
|
215
|
+
HQ is not an alternative to Claude, Codex or Cursor. Those are the tools
|
|
216
|
+
that do the thinking, and HQ is what they think with: Claude Code opened in
|
|
217
|
+
the HQ folder is Claude that already knows the company. So when someone
|
|
218
|
+
asks "how is this different from Claude?", the answer is that they keep
|
|
219
|
+
using Claude, and HQ is what makes Claude (and every teammate's AI, and
|
|
220
|
+
every bot) start from their business instead of from zero.
|
|
221
|
+
|
|
222
|
+
When someone asks what HQ is, answer in two or three plain sentences of
|
|
223
|
+
prose built from the above, then make it concrete with one thing from their
|
|
224
|
+
own HQ (a connected tool, a teammate, a client, a meeting). No bullet list,
|
|
225
|
+
no feature tour, no "short version" line. End on that concrete example, not
|
|
226
|
+
on an offer or a question about what to set up next. This holds even when
|
|
227
|
+
setup is unfinished: answering the question is the whole message, and the
|
|
228
|
+
walkthrough resumes on their next message, not in this one.
|
|
229
|
+
|
|
230
|
+
An answer that follows these rules reads like this:
|
|
231
|
+
|
|
232
|
+
> HQ is the shared memory that Claude Code, Codex and Cursor work from.
|
|
233
|
+
> You keep using Claude Code; opened in your HQ folder, it already knows
|
|
234
|
+
> your clients, your calls and what your team decided, and so does every
|
|
235
|
+
> teammate's AI and every bot. Your Fireflies calls are in there now, so
|
|
236
|
+
> anyone you add to Enabled AI can ask what a client said last week.
|
|
237
|
+
|
|
238
|
+
## How you make them feel it
|
|
239
|
+
|
|
240
|
+
Everything that lands in HQ (a connected tool, a call transcript, a
|
|
241
|
+
profile, a client home, a decision) is there for every teammate AND for
|
|
242
|
+
every teammate's AI, whichever coding tool they use. That is the point of
|
|
243
|
+
setup, and the person should feel it, not be told it once in a paragraph.
|
|
191
244
|
|
|
192
245
|
So make it visible as you go, in one clause, whenever something joins HQ:
|
|
193
246
|
|
|
@@ -540,7 +593,9 @@ instructions: |
|
|
|
540
593
|
text at the end of the nicest message you will ever send them.
|
|
541
594
|
|
|
542
595
|
Send it once, in this closing message only (and in the one you send when
|
|
543
|
-
they say "skip").
|
|
596
|
+
they say "skip"). Never put it in any later message, including a short
|
|
597
|
+
reply to "ok" or "thanks" after the closing message: the card is already
|
|
598
|
+
there, and the person replying is what puts it away.
|
|
544
599
|
|
|
545
600
|
Then write `Status: finished` in your progress note.
|
|
546
601
|
|
package/dist/commands/lanes.js
CHANGED
|
@@ -23,7 +23,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
|
|
|
23
23
|
import { openMeshTransport } from "../lib/mesh/client.js";
|
|
24
24
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
|
|
25
25
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
26
|
-
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
26
|
+
import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
|
|
27
27
|
import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
|
|
28
28
|
import { questionBodyAbsPath } from "../lib/lanes/questions.js";
|
|
29
29
|
import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
|
|
@@ -1119,7 +1119,7 @@ async function assessLaneAdmission(ctx, lane, options) {
|
|
|
1119
1119
|
return admission;
|
|
1120
1120
|
}
|
|
1121
1121
|
function assertLaneRunsHereForAdmission(ctx, lane) {
|
|
1122
|
-
if (
|
|
1122
|
+
if (laneRunsHereForOperatorMutation(ctx, lane))
|
|
1123
1123
|
return;
|
|
1124
1124
|
throw new LanesError("lane_remote", `Lane ${lane.lane_id} is hosted remotely; run the admission command on its Outpost`, { lane_id: lane.lane_id, host: lane.host });
|
|
1125
1125
|
}
|
|
@@ -2382,6 +2382,16 @@ function laneIsHostedHere(ctx, lane) {
|
|
|
2382
2382
|
}
|
|
2383
2383
|
return identity.kind === "id" && identity.id === lane.host.id;
|
|
2384
2384
|
}
|
|
2385
|
+
function laneRunsHereForOperatorMutation(ctx, lane) {
|
|
2386
|
+
if (lane.host.kind === "outpost")
|
|
2387
|
+
return laneIsHostedHere(ctx, lane);
|
|
2388
|
+
return laneRunsHere(lane, reconcileHostFromCtx(ctx));
|
|
2389
|
+
}
|
|
2390
|
+
function assertLaneRunsHereForOperatorMutation(ctx, lane, action) {
|
|
2391
|
+
if (laneRunsHereForOperatorMutation(ctx, lane))
|
|
2392
|
+
return;
|
|
2393
|
+
throw new LanesError("lane_remote", `Cannot ${action} lane ${lane.lane_id} from this host; its state is owned by ${lane.host.kind}:${lane.host.id}`, { lane_id: lane.lane_id, host: lane.host, action });
|
|
2394
|
+
}
|
|
2385
2395
|
async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
2386
2396
|
if (lane.host.kind !== "outpost") {
|
|
2387
2397
|
throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch to`, { lane_id: lane.lane_id });
|
|
@@ -2896,8 +2906,7 @@ async function runMove(ctx, laneId, opts) {
|
|
|
2896
2906
|
moved = await dispatchRemoteLaneMove(ctx, source, choice.host, choice.account);
|
|
2897
2907
|
}
|
|
2898
2908
|
else {
|
|
2899
|
-
|
|
2900
|
-
throw new LanesError("lane_move_wrong_host", `Move lane ${laneId} from its current host (${source.host.kind}:${source.host.id})`, { lane_id: laneId, lane_host: source.host, current_host: currentHost });
|
|
2909
|
+
throw new LanesError("lane_remote", `Cannot move lane ${laneId} from this host; its state is owned by ${source.host.kind}:${source.host.id}`, { lane_id: laneId, host: source.host, action: "move" });
|
|
2901
2910
|
}
|
|
2902
2911
|
printJson({
|
|
2903
2912
|
ok: true,
|
|
@@ -2912,12 +2921,8 @@ async function runMove(ctx, laneId, opts) {
|
|
|
2912
2921
|
async function commitLaneMove(ctx, laneId, target, account) {
|
|
2913
2922
|
assertPlacementEnabled(ctx);
|
|
2914
2923
|
const hqRoot = ctx.hqRoot();
|
|
2915
|
-
const sourceHost = laneHostFromCtx(ctx);
|
|
2916
|
-
const admissionHost = reconcileHostFromCtx(ctx);
|
|
2917
2924
|
const before = readLane(hqRoot, laneId);
|
|
2918
|
-
|
|
2919
|
-
throw new LanesError("lane_move_wrong_host", `Move lane ${laneId} from its current host (${before.host.kind}:${before.host.id})`, { lane_id: laneId, lane_host: before.host, current_host: sourceHost });
|
|
2920
|
-
}
|
|
2925
|
+
assertLaneRunsHereForOperatorMutation(ctx, before, "move-target");
|
|
2921
2926
|
if (before.host.kind === target.kind && before.host.id === target.id) {
|
|
2922
2927
|
throw new LanesError("lane_move_same_host", `Lane ${laneId} is already on ${target.kind}:${target.id}`, {
|
|
2923
2928
|
lane_id: laneId,
|
|
@@ -2971,12 +2976,7 @@ async function commitLaneMove(ctx, laneId, target, account) {
|
|
|
2971
2976
|
const timeout = target.kind === "outpost" ? readLaneTimeout(hqRoot, laneId) : undefined;
|
|
2972
2977
|
const moved = await withLaneAdmissionLock(hqRoot, before.provider, async () => {
|
|
2973
2978
|
const current = readLane(hqRoot, laneId);
|
|
2974
|
-
|
|
2975
|
-
throw new LanesError("lane_move_conflict", `Lane ${laneId} changed hosts before the move`, {
|
|
2976
|
-
lane_id: laneId,
|
|
2977
|
-
host: current.host,
|
|
2978
|
-
});
|
|
2979
|
-
}
|
|
2979
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "move-target");
|
|
2980
2980
|
if (!laneCanMoveBeforeStart(current)) {
|
|
2981
2981
|
throw new LanesError(current.state === "queued" ? "lane_move_started" : "lane_not_queued", `Lane ${laneId} started before the move could commit`, { lane_id: laneId, state: current.state });
|
|
2982
2982
|
}
|
|
@@ -3216,17 +3216,15 @@ async function runAdmit(ctx, laneId, opts) {
|
|
|
3216
3216
|
state: lane.state,
|
|
3217
3217
|
});
|
|
3218
3218
|
}
|
|
3219
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "admit");
|
|
3219
3220
|
const host = reconcileHostFromCtx(ctx);
|
|
3220
|
-
if (!laneRunsHere(lane, host)) {
|
|
3221
|
-
printJson({ ok: true, action: "admit", lane_id: laneId, admitted: false, blocked_by: "remote", lane });
|
|
3222
|
-
return;
|
|
3223
|
-
}
|
|
3224
3221
|
if (lane.pickup_validation_pending === true) {
|
|
3225
3222
|
printJson({ ok: true, action: "admit", lane_id: laneId, admitted: false, blocked_by: "pickup_validation", lane });
|
|
3226
3223
|
return;
|
|
3227
3224
|
}
|
|
3228
3225
|
await withLaneAdmissionLock(hqRoot, lane.provider, async () => {
|
|
3229
3226
|
lane = readLane(hqRoot, laneId);
|
|
3227
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "admit");
|
|
3230
3228
|
if (lane.state !== "queued") {
|
|
3231
3229
|
throw new LanesError("lane_not_queued", `Lane ${laneId} changed to ${lane.state} before admission`, {
|
|
3232
3230
|
lane_id: laneId,
|
|
@@ -3572,6 +3570,7 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
3572
3570
|
await dispatchOutpostLaneCommand(ctx, lane, "message", opts);
|
|
3573
3571
|
return;
|
|
3574
3572
|
}
|
|
3573
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
|
|
3575
3574
|
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
3576
3575
|
// An account move is refused on its own terms first: a running lane and a
|
|
3577
3576
|
// provider without session resume get the move's named errors, not the
|
|
@@ -3847,6 +3846,7 @@ async function runResume(ctx, laneId, opts) {
|
|
|
3847
3846
|
await dispatchOutpostLaneCommand(ctx, lane, "resume", opts);
|
|
3848
3847
|
return;
|
|
3849
3848
|
}
|
|
3849
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "resume");
|
|
3850
3850
|
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
3851
3851
|
if (opts.model !== undefined && opts.account === undefined) {
|
|
3852
3852
|
throw new LanesError("model_requires_account", "--model on hq lanes resume is only accepted together with --account", {});
|
|
@@ -4223,6 +4223,7 @@ async function runStoryList(ctx, laneId) {
|
|
|
4223
4223
|
async function runStoryAdd(ctx, laneId, storyId, opts) {
|
|
4224
4224
|
const hqRoot = ctx.hqRoot();
|
|
4225
4225
|
let lane = readLane(hqRoot, laneId);
|
|
4226
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "update story status for");
|
|
4226
4227
|
const projectId = opts.project?.trim() || lane.project_id;
|
|
4227
4228
|
if (!projectId)
|
|
4228
4229
|
throw new LanesError("missing_project", "--project is required", {});
|
|
@@ -4232,7 +4233,10 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
|
|
|
4232
4233
|
const at = ctx.now().toISOString();
|
|
4233
4234
|
lane = await ensureStoryThread(ctx, lane, projectId);
|
|
4234
4235
|
const before = lane;
|
|
4235
|
-
const next = casUpdateLane(hqRoot, laneId, (current) =>
|
|
4236
|
+
const next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4237
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
|
|
4238
|
+
return addCarriedStory(current, projectId, normalizedStoryId, at);
|
|
4239
|
+
});
|
|
4236
4240
|
await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
|
|
4237
4241
|
const finalLane = readLane(hqRoot, laneId);
|
|
4238
4242
|
printJson({
|
|
@@ -4249,6 +4253,7 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
|
|
|
4249
4253
|
async function runStoryStart(ctx, laneId, storyId, opts) {
|
|
4250
4254
|
const hqRoot = ctx.hqRoot();
|
|
4251
4255
|
let lane = readLane(hqRoot, laneId);
|
|
4256
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "update story status for");
|
|
4252
4257
|
const target = storyCommandTarget(lane, storyId, opts.project);
|
|
4253
4258
|
if (target.story.status === "done") {
|
|
4254
4259
|
throw new LanesError("story_already_done", `Story ${target.projectId}/${target.storyId} is done and cannot be started again`, {
|
|
@@ -4260,7 +4265,10 @@ async function runStoryStart(ctx, laneId, storyId, opts) {
|
|
|
4260
4265
|
const at = ctx.now().toISOString();
|
|
4261
4266
|
lane = await ensureStoryThread(ctx, lane, target.projectId);
|
|
4262
4267
|
const before = lane;
|
|
4263
|
-
const next = casUpdateLane(hqRoot, laneId, (current) =>
|
|
4268
|
+
const next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4269
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
|
|
4270
|
+
return updateCarriedStory(current, target.projectId, target.storyId, "in_progress", at);
|
|
4271
|
+
});
|
|
4264
4272
|
await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_started", "in_progress");
|
|
4265
4273
|
const finalLane = readLane(hqRoot, laneId);
|
|
4266
4274
|
printJson({ ok: true, action: "story.start", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "in_progress", story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
|
|
@@ -4268,6 +4276,7 @@ async function runStoryStart(ctx, laneId, storyId, opts) {
|
|
|
4268
4276
|
async function runStoryBlock(ctx, laneId, storyId, opts) {
|
|
4269
4277
|
const hqRoot = ctx.hqRoot();
|
|
4270
4278
|
let lane = readLane(hqRoot, laneId);
|
|
4279
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "update story status for");
|
|
4271
4280
|
const target = storyCommandTarget(lane, storyId, opts.project);
|
|
4272
4281
|
if (target.story.status === "done") {
|
|
4273
4282
|
throw new LanesError("story_already_done", `Story ${target.projectId}/${target.storyId} is done and cannot be blocked again`, {
|
|
@@ -4282,7 +4291,10 @@ async function runStoryBlock(ctx, laneId, storyId, opts) {
|
|
|
4282
4291
|
const at = ctx.now().toISOString();
|
|
4283
4292
|
lane = await ensureStoryThread(ctx, lane, target.projectId);
|
|
4284
4293
|
const before = lane;
|
|
4285
|
-
const next = casUpdateLane(hqRoot, laneId, (current) =>
|
|
4294
|
+
const next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4295
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
|
|
4296
|
+
return updateCarriedStory(current, target.projectId, target.storyId, "blocked", at, { blocked_reason: reason });
|
|
4297
|
+
});
|
|
4286
4298
|
await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_blocked", "blocked", { blocked_reason: reason });
|
|
4287
4299
|
const finalLane = readLane(hqRoot, laneId);
|
|
4288
4300
|
printJson({ ok: true, action: "story.block", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "blocked", blocked_reason: reason, story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
|
|
@@ -4290,6 +4302,7 @@ async function runStoryBlock(ctx, laneId, storyId, opts) {
|
|
|
4290
4302
|
async function runStoryDone(ctx, laneId, storyId, opts) {
|
|
4291
4303
|
const hqRoot = ctx.hqRoot();
|
|
4292
4304
|
let lane = readLane(hqRoot, laneId);
|
|
4305
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "update story status for");
|
|
4293
4306
|
if (!opts.verified) {
|
|
4294
4307
|
throw new LanesError("senior_verification_required", "Only a senior may mark a story done; pass --verified after reviewing or merging it", { lane_id: laneId });
|
|
4295
4308
|
}
|
|
@@ -4311,10 +4324,13 @@ async function runStoryDone(ctx, laneId, storyId, opts) {
|
|
|
4311
4324
|
const at = ctx.now().toISOString();
|
|
4312
4325
|
lane = await ensureStoryThread(ctx, lane, target.projectId);
|
|
4313
4326
|
const before = lane;
|
|
4314
|
-
const next = casUpdateLane(hqRoot, laneId, (current) =>
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4327
|
+
const next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4328
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
|
|
4329
|
+
return updateCarriedStory(current, target.projectId, target.storyId, "done", at, {
|
|
4330
|
+
verified_at: at,
|
|
4331
|
+
verified_by: current.senior,
|
|
4332
|
+
});
|
|
4333
|
+
});
|
|
4318
4334
|
await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_done", "done", { summary: "Senior verification complete" });
|
|
4319
4335
|
const finalLane = readLane(hqRoot, laneId);
|
|
4320
4336
|
printJson({ ok: true, action: "story.done", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "done", verified: true, story_warning: storyWarning(finalLane) ?? null, lane: finalLane });
|
|
@@ -4354,6 +4370,7 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
4354
4370
|
const nextSenior = parseSeniorRef(opts.senior);
|
|
4355
4371
|
assertSeniorExists(hqRoot, nextSenior, ctx);
|
|
4356
4372
|
const lane = readLane(hqRoot, laneId);
|
|
4373
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "reparent");
|
|
4357
4374
|
if (isTerminalLaneState(lane.state)) {
|
|
4358
4375
|
throw new LanesError("lane_terminal", `Cannot re-parent a lane in terminal state ${lane.state}`, { lane_id: laneId, state: lane.state });
|
|
4359
4376
|
}
|
|
@@ -4378,6 +4395,7 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
4378
4395
|
continue;
|
|
4379
4396
|
if (!walkHitsLane(hqRoot, other.senior, laneId, ctx))
|
|
4380
4397
|
continue;
|
|
4398
|
+
assertLaneRunsHereForOperatorMutation(ctx, other, "reparent descendant of");
|
|
4381
4399
|
const oldDescendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx);
|
|
4382
4400
|
const descendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx, override);
|
|
4383
4401
|
if (descendantDepth > MAX_LANE_DEPTH) {
|
|
@@ -4395,6 +4413,7 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
4395
4413
|
}
|
|
4396
4414
|
const at = ctx.now().toISOString();
|
|
4397
4415
|
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4416
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "reparent");
|
|
4398
4417
|
const already = current.senior_history.some((entry) => entry.at === at && entry.reason === (opts.reason ?? "reparent"));
|
|
4399
4418
|
if (already && current.depth === newDepth)
|
|
4400
4419
|
return current;
|
|
@@ -4413,6 +4432,7 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
4413
4432
|
});
|
|
4414
4433
|
for (const descendant of descendants) {
|
|
4415
4434
|
casUpdateLane(hqRoot, descendant.laneId, (current) => {
|
|
4435
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "reparent descendant of");
|
|
4416
4436
|
if (current.depth === descendant.depth)
|
|
4417
4437
|
return current;
|
|
4418
4438
|
return { ...current, depth: descendant.depth };
|
|
@@ -4487,14 +4507,226 @@ async function runLogs(ctx, laneId, opts) {
|
|
|
4487
4507
|
export function stopCommittedState(state) {
|
|
4488
4508
|
return state === "queued" ? "cancelled" : "killed";
|
|
4489
4509
|
}
|
|
4510
|
+
function parseConfirmedOutpostStopLane(raw, laneId, outpostId, commandId) {
|
|
4511
|
+
let remoteLane;
|
|
4512
|
+
try {
|
|
4513
|
+
remoteLane = parseLaneRecord(raw);
|
|
4514
|
+
}
|
|
4515
|
+
catch (err) {
|
|
4516
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} returned an invalid lane snapshot for stop ${laneId}`, {
|
|
4517
|
+
lane_id: laneId,
|
|
4518
|
+
outpost_id: outpostId,
|
|
4519
|
+
command_id: commandId,
|
|
4520
|
+
cause: err instanceof Error ? err.message : String(err),
|
|
4521
|
+
});
|
|
4522
|
+
}
|
|
4523
|
+
if (remoteLane.lane_id !== laneId ||
|
|
4524
|
+
remoteLane.host.kind !== "outpost" ||
|
|
4525
|
+
remoteLane.host.id !== outpostId ||
|
|
4526
|
+
!isTerminalLaneState(remoteLane.state)) {
|
|
4527
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return its terminal lane ${laneId}`, {
|
|
4528
|
+
lane_id: laneId,
|
|
4529
|
+
outpost_id: outpostId,
|
|
4530
|
+
command_id: commandId,
|
|
4531
|
+
owner_lane_id: remoteLane.lane_id,
|
|
4532
|
+
owner_host: remoteLane.host,
|
|
4533
|
+
owner_state: remoteLane.state,
|
|
4534
|
+
});
|
|
4535
|
+
}
|
|
4536
|
+
return remoteLane;
|
|
4537
|
+
}
|
|
4538
|
+
function parseRemoteCancelledQuestionIds(value, laneId, outpostId, commandId) {
|
|
4539
|
+
if (!Array.isArray(value) ||
|
|
4540
|
+
value.some((id) => typeof id !== "string" || id.trim() === "") ||
|
|
4541
|
+
new Set(value).size !== value.length) {
|
|
4542
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} returned invalid cancelled question ids for lane ${laneId}`, { lane_id: laneId, outpost_id: outpostId, command_id: commandId });
|
|
4543
|
+
}
|
|
4544
|
+
return value;
|
|
4545
|
+
}
|
|
4546
|
+
async function readOutpostTerminalLane(ctx, laneId, outpostId, stopCommandId) {
|
|
4547
|
+
let dispatched;
|
|
4548
|
+
try {
|
|
4549
|
+
dispatched = await ctx.dispatchOutpostExec({
|
|
4550
|
+
outpostId,
|
|
4551
|
+
command: buildOutpostLaneCommand({ action: "show", laneId }),
|
|
4552
|
+
timeoutSeconds: 120,
|
|
4553
|
+
waitMs: 120_000,
|
|
4554
|
+
});
|
|
4555
|
+
}
|
|
4556
|
+
catch (err) {
|
|
4557
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Could not read terminal lane ${laneId} from Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, {
|
|
4558
|
+
lane_id: laneId,
|
|
4559
|
+
outpost_id: outpostId,
|
|
4560
|
+
command_id: stopCommandId,
|
|
4561
|
+
sync_command_id: null,
|
|
4562
|
+
});
|
|
4563
|
+
}
|
|
4564
|
+
const commandId = dispatched.commandId?.trim();
|
|
4565
|
+
if (dispatched.done !== true ||
|
|
4566
|
+
!commandId ||
|
|
4567
|
+
dispatched.status !== "Success" ||
|
|
4568
|
+
dispatched.exitCode !== 0) {
|
|
4569
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not confirm a lane read after stop ${laneId}`, {
|
|
4570
|
+
lane_id: laneId,
|
|
4571
|
+
outpost_id: outpostId,
|
|
4572
|
+
command_id: stopCommandId,
|
|
4573
|
+
sync_command_id: commandId || null,
|
|
4574
|
+
status: dispatched.status,
|
|
4575
|
+
done: dispatched.done ?? false,
|
|
4576
|
+
exit_code: dispatched.exitCode,
|
|
4577
|
+
});
|
|
4578
|
+
}
|
|
4579
|
+
let remote;
|
|
4580
|
+
try {
|
|
4581
|
+
remote = parseRemoteJson(dispatched.stdout, "Outpost lane show");
|
|
4582
|
+
}
|
|
4583
|
+
catch (err) {
|
|
4584
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} returned an invalid lane read after stop ${laneId}`, {
|
|
4585
|
+
lane_id: laneId,
|
|
4586
|
+
outpost_id: outpostId,
|
|
4587
|
+
command_id: stopCommandId,
|
|
4588
|
+
sync_command_id: commandId,
|
|
4589
|
+
cause: err instanceof Error ? err.message : String(err),
|
|
4590
|
+
});
|
|
4591
|
+
}
|
|
4592
|
+
if (remote?.ok !== true || remote.action !== "show") {
|
|
4593
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return lane ${laneId} after stop`, {
|
|
4594
|
+
lane_id: laneId,
|
|
4595
|
+
outpost_id: outpostId,
|
|
4596
|
+
command_id: stopCommandId,
|
|
4597
|
+
sync_command_id: commandId,
|
|
4598
|
+
});
|
|
4599
|
+
}
|
|
4600
|
+
return {
|
|
4601
|
+
lane: parseConfirmedOutpostStopLane(remote.lane, laneId, outpostId, commandId),
|
|
4602
|
+
commandId,
|
|
4603
|
+
};
|
|
4604
|
+
}
|
|
4605
|
+
async function dispatchOutpostLaneStop(ctx, lane) {
|
|
4606
|
+
if (lane.host.kind !== "outpost") {
|
|
4607
|
+
throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch stop to`, { lane_id: lane.lane_id });
|
|
4608
|
+
}
|
|
4609
|
+
const outpostId = lane.host.id;
|
|
4610
|
+
let dispatched;
|
|
4611
|
+
try {
|
|
4612
|
+
dispatched = await ctx.dispatchOutpostExec({
|
|
4613
|
+
outpostId,
|
|
4614
|
+
command: buildOutpostLaneCommand({ action: "stop", laneId: lane.lane_id }),
|
|
4615
|
+
timeoutSeconds: 120,
|
|
4616
|
+
waitMs: 120_000,
|
|
4617
|
+
});
|
|
4618
|
+
}
|
|
4619
|
+
catch (err) {
|
|
4620
|
+
throw new LanesError("outpost_dispatch_failed", `Could not dispatch stop for lane ${lane.lane_id} to Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, outpost_id: outpostId });
|
|
4621
|
+
}
|
|
4622
|
+
const commandId = dispatched.commandId?.trim();
|
|
4623
|
+
if (dispatched.done !== true || !commandId) {
|
|
4624
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return a completed stop command for lane ${lane.lane_id}`, {
|
|
4625
|
+
lane_id: lane.lane_id,
|
|
4626
|
+
outpost_id: outpostId,
|
|
4627
|
+
command_id: commandId || null,
|
|
4628
|
+
status: dispatched.status,
|
|
4629
|
+
done: dispatched.done ?? false,
|
|
4630
|
+
exit_code: dispatched.exitCode,
|
|
4631
|
+
});
|
|
4632
|
+
}
|
|
4633
|
+
const remote = parseRemoteJson(dispatched.stdout, "Outpost lane stop");
|
|
4634
|
+
let state;
|
|
4635
|
+
let alreadyTerminal = false;
|
|
4636
|
+
let ownerLane;
|
|
4637
|
+
let cancelledQuestions;
|
|
4638
|
+
let syncCommandId;
|
|
4639
|
+
if (remote?.error === "already_terminal") {
|
|
4640
|
+
if (remote.lane_id !== lane.lane_id ||
|
|
4641
|
+
!isTerminalLaneState(remote.state) ||
|
|
4642
|
+
(dispatched.status !== "Success" && dispatched.status !== "Failed") ||
|
|
4643
|
+
dispatched.exitCode === null ||
|
|
4644
|
+
dispatched.exitCode === 0) {
|
|
4645
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} returned an invalid already-terminal result for lane ${lane.lane_id}`, { lane_id: lane.lane_id, outpost_id: outpostId, command_id: commandId });
|
|
4646
|
+
}
|
|
4647
|
+
state = remote.state;
|
|
4648
|
+
alreadyTerminal = true;
|
|
4649
|
+
const owner = await readOutpostTerminalLane(ctx, lane.lane_id, outpostId, commandId);
|
|
4650
|
+
if (owner.lane.state !== state) {
|
|
4651
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} returned a different terminal state for lane ${lane.lane_id}`, {
|
|
4652
|
+
lane_id: lane.lane_id,
|
|
4653
|
+
outpost_id: outpostId,
|
|
4654
|
+
command_id: commandId,
|
|
4655
|
+
sync_command_id: owner.commandId,
|
|
4656
|
+
stop_state: state,
|
|
4657
|
+
owner_state: owner.lane.state,
|
|
4658
|
+
});
|
|
4659
|
+
}
|
|
4660
|
+
ownerLane = owner.lane;
|
|
4661
|
+
cancelledQuestions = [];
|
|
4662
|
+
syncCommandId = owner.commandId;
|
|
4663
|
+
}
|
|
4664
|
+
else if (remote?.error !== undefined) {
|
|
4665
|
+
throw new LanesError("outpost_remote_command_failed", typeof remote.message === "string"
|
|
4666
|
+
? remote.message
|
|
4667
|
+
: `Outpost ${outpostId} rejected the stop command for lane ${lane.lane_id}`, {
|
|
4668
|
+
lane_id: lane.lane_id,
|
|
4669
|
+
outpost_id: outpostId,
|
|
4670
|
+
command_id: commandId,
|
|
4671
|
+
remote_error: remote.error,
|
|
4672
|
+
});
|
|
4673
|
+
}
|
|
4674
|
+
else {
|
|
4675
|
+
const remoteLane = remote?.lane;
|
|
4676
|
+
const remoteLaneRecord = remoteLane !== null &&
|
|
4677
|
+
typeof remoteLane === "object" &&
|
|
4678
|
+
!Array.isArray(remoteLane)
|
|
4679
|
+
? remoteLane
|
|
4680
|
+
: null;
|
|
4681
|
+
if (dispatched.status !== "Success" ||
|
|
4682
|
+
dispatched.exitCode !== 0 ||
|
|
4683
|
+
remote?.ok !== true ||
|
|
4684
|
+
remote.action !== "stop" ||
|
|
4685
|
+
remoteLaneRecord?.lane_id !== lane.lane_id ||
|
|
4686
|
+
(remoteLaneRecord.state !== "cancelled" && remoteLaneRecord.state !== "killed")) {
|
|
4687
|
+
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without confirming the stop for lane ${lane.lane_id}`, {
|
|
4688
|
+
lane_id: lane.lane_id,
|
|
4689
|
+
outpost_id: outpostId,
|
|
4690
|
+
command_id: commandId,
|
|
4691
|
+
status: dispatched.status,
|
|
4692
|
+
exit_code: dispatched.exitCode,
|
|
4693
|
+
});
|
|
4694
|
+
}
|
|
4695
|
+
ownerLane = parseConfirmedOutpostStopLane(remoteLaneRecord, lane.lane_id, outpostId, commandId);
|
|
4696
|
+
state = ownerLane.state;
|
|
4697
|
+
cancelledQuestions = parseRemoteCancelledQuestionIds(remote.cancelled_questions, lane.lane_id, outpostId, commandId);
|
|
4698
|
+
}
|
|
4699
|
+
const updated = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, ownerLane);
|
|
4700
|
+
printJson({
|
|
4701
|
+
ok: true,
|
|
4702
|
+
action: "stop",
|
|
4703
|
+
lane: updated,
|
|
4704
|
+
cancelled_questions: cancelledQuestions,
|
|
4705
|
+
outpost_id: outpostId,
|
|
4706
|
+
outpost_command_id: commandId,
|
|
4707
|
+
...(syncCommandId ? { outpost_sync_command_id: syncCommandId } : {}),
|
|
4708
|
+
outpost_result: {
|
|
4709
|
+
state,
|
|
4710
|
+
already_terminal: alreadyTerminal,
|
|
4711
|
+
status: dispatched.status,
|
|
4712
|
+
exit_code: dispatched.exitCode,
|
|
4713
|
+
},
|
|
4714
|
+
});
|
|
4715
|
+
}
|
|
4490
4716
|
async function runStop(ctx, laneId) {
|
|
4491
4717
|
const hqRoot = ctx.hqRoot();
|
|
4492
4718
|
const lane = readLane(hqRoot, laneId);
|
|
4719
|
+
if (lane.host.kind === "outpost" && !laneRunsHereForOperatorMutation(ctx, lane)) {
|
|
4720
|
+
await dispatchOutpostLaneStop(ctx, lane);
|
|
4721
|
+
return;
|
|
4722
|
+
}
|
|
4723
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "stop");
|
|
4493
4724
|
if (isTerminalLaneState(lane.state)) {
|
|
4494
4725
|
throw new LanesError("already_terminal", `Lane ${laneId} is already ${lane.state}`, { lane_id: laneId, state: lane.state });
|
|
4495
4726
|
}
|
|
4496
4727
|
const at = ctx.now().toISOString();
|
|
4497
4728
|
const updated = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4729
|
+
assertLaneRunsHereForOperatorMutation(ctx, current, "stop");
|
|
4498
4730
|
if (isTerminalLaneState(current.state)) {
|
|
4499
4731
|
throw new LanesError("already_terminal", `Lane ${laneId} is already ${current.state}`, { lane_id: laneId, state: current.state });
|
|
4500
4732
|
}
|
|
@@ -4531,6 +4763,7 @@ async function runAsk(ctx, opts) {
|
|
|
4531
4763
|
throw new LanesError("missing_question", "--question is required", {});
|
|
4532
4764
|
}
|
|
4533
4765
|
const lane = readLane(hqRoot, laneId);
|
|
4766
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "ask");
|
|
4534
4767
|
const optionList = Array.isArray(opts.option)
|
|
4535
4768
|
? opts.option
|
|
4536
4769
|
: opts.option
|
|
@@ -4659,6 +4892,10 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
|
4659
4892
|
const question = readQuestion(hqRoot, questionId);
|
|
4660
4893
|
throwIfQuestionNotPending(question);
|
|
4661
4894
|
await yieldEventLoop();
|
|
4895
|
+
const askingLane = readLane(hqRoot, laneIdForAnswerDelivery(question));
|
|
4896
|
+
if (laneAcceptsDropboxDelivery(askingLane)) {
|
|
4897
|
+
assertLaneRunsHereForOperatorMutation(ctx, askingLane, "deliver an answer to");
|
|
4898
|
+
}
|
|
4662
4899
|
const body = requireQuestionBody(hqRoot, question);
|
|
4663
4900
|
// A trimmed-empty --freeform is absent: it must not win over --choice and
|
|
4664
4901
|
// must not become a freeform answer of "".
|
|
@@ -4700,7 +4937,7 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
|
|
|
4700
4937
|
},
|
|
4701
4938
|
};
|
|
4702
4939
|
});
|
|
4703
|
-
const delivery = deliverAnswerToAskingLane(
|
|
4940
|
+
const delivery = deliverAnswerToAskingLane(ctx, updated, choice, at);
|
|
4704
4941
|
printJson({
|
|
4705
4942
|
ok: true,
|
|
4706
4943
|
action: "questions.answer",
|
|
@@ -4729,13 +4966,15 @@ function writeAnswerDeliveryFailed(hqRoot, laneId, questionId, err, at) {
|
|
|
4729
4966
|
* Record is already durable. Delivery is best-effort onto the asking lane's
|
|
4730
4967
|
* drop box (spec §6.5). Target is question.lane_id, never a project/story lookup.
|
|
4731
4968
|
*/
|
|
4732
|
-
function deliverAnswerToAskingLane(
|
|
4969
|
+
function deliverAnswerToAskingLane(ctx, question, choice, at) {
|
|
4970
|
+
const hqRoot = ctx.hqRoot();
|
|
4733
4971
|
const laneId = laneIdForAnswerDelivery(question);
|
|
4734
4972
|
try {
|
|
4735
4973
|
const lane = readLane(hqRoot, laneId);
|
|
4736
4974
|
if (!laneAcceptsDropboxDelivery(lane)) {
|
|
4737
4975
|
return { status: "recorded_only" };
|
|
4738
4976
|
}
|
|
4977
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "deliver an answer to");
|
|
4739
4978
|
appendDropboxMessage(hqRoot, laneId, answerDropboxMessage(question, choice, at, mintMessageId()));
|
|
4740
4979
|
return { status: "dropped" };
|
|
4741
4980
|
}
|
|
@@ -16,7 +16,7 @@ export { resolveLanesHqRoot } from "./hq-root.js";
|
|
|
16
16
|
export type { LanesHqRootOpts } from "./hq-root.js";
|
|
17
17
|
export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
|
|
18
18
|
export type { OutpostDispatchResult, OutpostPickupPayload, } from "./outpost-dispatch.js";
|
|
19
|
-
export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
19
|
+
export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, } from "./store.js";
|
|
20
20
|
export type { StoreDeps } from "./store.js";
|
|
21
21
|
export { DEFAULT_LANE_WATCH_INTERVAL_MS, MAX_LANE_WATCH_INTERVAL_MS, MAX_WATCHED_LANES, MAX_WATCH_OUTPUT_LINES, runLaneWatch, } from "./watch.js";
|
|
22
22
|
export type { LaneWatchDeps, LaneWatchResult } from "./watch.js";
|
package/dist/lib/lanes/index.js
CHANGED
|
@@ -10,7 +10,7 @@ export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory,
|
|
|
10
10
|
export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
|
|
11
11
|
export { resolveLanesHqRoot } from "./hq-root.js";
|
|
12
12
|
export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
|
|
13
|
-
export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, updateLane, updateQuestion, } from "./store.js";
|
|
13
|
+
export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, } from "./store.js";
|
|
14
14
|
export { DEFAULT_LANE_WATCH_INTERVAL_MS, MAX_LANE_WATCH_INTERVAL_MS, MAX_WATCHED_LANES, MAX_WATCH_OUTPUT_LINES, runLaneWatch, } from "./watch.js";
|
|
15
15
|
export { MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_TASK_OUTPUT_MAX_BYTES, MONITOR_TRANSCRIPT_MAX_BYTES, MONITOR_UNCOVERED_EXIT_CODE, coveredLaneIds, isMonitorTaskLive, monitorReminderText, parseCanonicalWatchCommand, parseMonitorTranscript, readLiveMonitorEvidence, } from "./monitor.js";
|
|
16
16
|
export { IMDS_HOST, IMDS_INSTANCE_ID_URL, IMDS_TIMEOUT_MS, IMDS_TOKEN_URL, defaultReadImdsInstanceId, resolveMachineIdentity, } from "./machine-identity.js";
|
|
@@ -54,6 +54,12 @@ export declare function tryReadQuestion(hqRoot: string, questionId: string, deps
|
|
|
54
54
|
export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
|
|
55
55
|
export declare function createLane(hqRoot: string, init: Omit<LaneRecord, "schema_version" | "lane_id" | "version" | "created_at" | "updated_at"> & Partial<Pick<LaneRecord, "schema_version" | "lane_id" | "version" | "created_at" | "updated_at">>, deps?: StoreDeps): LaneRecord;
|
|
56
56
|
export declare function updateLane(hqRoot: string, laneId: string, expectedVersion: number, writer: LaneWriter, updater: (current: LaneRecord) => LaneRecord, deps?: StoreDeps): LaneRecord;
|
|
57
|
+
/**
|
|
58
|
+
* Replace the primary copy with a terminal snapshot returned by the Outpost
|
|
59
|
+
* that owns the lane. The owner is authoritative on equal versions; a newer
|
|
60
|
+
* primary version is a conflict and must not be overwritten.
|
|
61
|
+
*/
|
|
62
|
+
export declare function syncLaneRecordFromOwner(hqRoot: string, laneId: string, outpostId: string, rawOwnerLane: unknown, deps?: StoreDeps): LaneRecord;
|
|
57
63
|
/** Compare-and-set with conflict retry. Callers that race a concurrent writer re-read. */
|
|
58
64
|
export declare function retryUpdateLane(hqRoot: string, laneId: string, writer: LaneWriter, updater: (current: LaneRecord) => LaneRecord, deps?: StoreDeps, attempts?: number): LaneRecord;
|
|
59
65
|
/** Compare-and-set with conflict retry for question records (spec §6.2). */
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -13,7 +13,7 @@ import * as path from "node:path";
|
|
|
13
13
|
import { generateUlid } from "../mesh/live/ulid.js";
|
|
14
14
|
import { resolveLanesHqRoot } from "./hq-root.js";
|
|
15
15
|
import { LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesError, isLaneVersionConflict, } from "./errors.js";
|
|
16
|
-
import { HISTORY_CAP, HOST_KINDS, LANE_STORY_STATUSES, LANE_SCHEMA_VERSION, QUESTION_SCHEMA_VERSION, SENIOR_KINDS, isHostKind, isLaneProvider, isQueuedReason, isLaneState, isQuestionStatus, isSeniorKind, parseMajorSchema, REQUIRED_LANE_RECORD_FIELDS, LANE_FIELD_WRITERS, LANE_WRITERS, } from "./types.js";
|
|
16
|
+
import { HISTORY_CAP, HOST_KINDS, LANE_STORY_STATUSES, LANE_SCHEMA_VERSION, QUESTION_SCHEMA_VERSION, SENIOR_KINDS, isHostKind, isLaneProvider, isQueuedReason, isLaneState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseMajorSchema, REQUIRED_LANE_RECORD_FIELDS, LANE_FIELD_WRITERS, LANE_WRITERS, } from "./types.js";
|
|
17
17
|
import { laneStories } from "./stories.js";
|
|
18
18
|
export const LANES_STORE_REL = path.join("workspace", "lanes");
|
|
19
19
|
export const LANE_RECORDS_DIR = "lanes";
|
|
@@ -810,6 +810,71 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
|
|
|
810
810
|
return updated;
|
|
811
811
|
}, deps);
|
|
812
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* Replace the primary copy with a terminal snapshot returned by the Outpost
|
|
815
|
+
* that owns the lane. The owner is authoritative on equal versions; a newer
|
|
816
|
+
* primary version is a conflict and must not be overwritten.
|
|
817
|
+
*/
|
|
818
|
+
export function syncLaneRecordFromOwner(hqRoot, laneId, outpostId, rawOwnerLane, deps = defaultStoreDeps()) {
|
|
819
|
+
let ownerLane;
|
|
820
|
+
try {
|
|
821
|
+
ownerLane = parseLaneRecord(rawOwnerLane);
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
throw new LanesError("outpost_lane_sync_invalid", `Outpost ${outpostId} returned an invalid lane snapshot for ${laneId}`, {
|
|
825
|
+
lane_id: laneId,
|
|
826
|
+
outpost_id: outpostId,
|
|
827
|
+
cause: err instanceof Error ? err.message : String(err),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (ownerLane.lane_id !== laneId ||
|
|
831
|
+
ownerLane.host.kind !== "outpost" ||
|
|
832
|
+
ownerLane.host.id !== outpostId ||
|
|
833
|
+
!isTerminalLaneState(ownerLane.state)) {
|
|
834
|
+
throw new LanesError("outpost_lane_sync_invalid", `Outpost ${outpostId} did not return its terminal record for lane ${laneId}`, {
|
|
835
|
+
lane_id: laneId,
|
|
836
|
+
outpost_id: outpostId,
|
|
837
|
+
owner_lane_id: ownerLane.lane_id,
|
|
838
|
+
owner_host: ownerLane.host,
|
|
839
|
+
owner_state: ownerLane.state,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
const filePath = laneRecordPath(hqRoot, laneId);
|
|
843
|
+
return withRecordLock(filePath, deps, () => {
|
|
844
|
+
const current = readMergedRecord(filePath, parseLaneRecord, mergeLaneRecords, deps);
|
|
845
|
+
if (current === null) {
|
|
846
|
+
throw new LaneSchemaError(`Record disappeared under lock: ${filePath}`, {
|
|
847
|
+
path: filePath,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
if (current.lane_id !== laneId ||
|
|
851
|
+
current.host.kind !== "outpost" ||
|
|
852
|
+
current.host.id !== outpostId) {
|
|
853
|
+
throw new LanesError("outpost_lane_sync_conflict", `Primary lane ${laneId} is no longer owned by Outpost ${outpostId}`, {
|
|
854
|
+
lane_id: laneId,
|
|
855
|
+
outpost_id: outpostId,
|
|
856
|
+
current_lane_id: current.lane_id,
|
|
857
|
+
current_host: current.host,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (ownerLane.version < current.version) {
|
|
861
|
+
throw new LanesError("outpost_lane_sync_conflict", `Outpost ${outpostId} returned lane ${laneId} at version ${ownerLane.version}, behind primary version ${current.version}`, {
|
|
862
|
+
lane_id: laneId,
|
|
863
|
+
outpost_id: outpostId,
|
|
864
|
+
owner_version: ownerLane.version,
|
|
865
|
+
primary_version: current.version,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
const ownerSnapshot = ownerLane.outpost_command_id === undefined &&
|
|
869
|
+
current.outpost_command_id !== undefined
|
|
870
|
+
? { ...ownerLane, outpost_command_id: current.outpost_command_id }
|
|
871
|
+
: ownerLane;
|
|
872
|
+
const synced = mergeLaneRecords(ownerSnapshot, [current]);
|
|
873
|
+
parseLaneRecord(synced, filePath);
|
|
874
|
+
atomicWriteJson(filePath, synced, deps);
|
|
875
|
+
return synced;
|
|
876
|
+
});
|
|
877
|
+
}
|
|
813
878
|
/** Compare-and-set with conflict retry. Callers that race a concurrent writer re-read. */
|
|
814
879
|
export function retryUpdateLane(hqRoot, laneId, writer, updater, deps = defaultStoreDeps(), attempts = 5) {
|
|
815
880
|
let last;
|