@indigoai-us/hq-cli 5.247.1 → 5.248.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 +9 -0
- package/assets/scaffold/core/scripts/hq-session.sh +2 -2
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +1 -1
- package/dist/commands/lanes-message.d.ts +9 -0
- package/dist/commands/lanes-message.js +558 -0
- package/dist/commands/lanes-resume.d.ts +6 -0
- package/dist/commands/lanes-resume.js +455 -0
- package/dist/commands/lanes-shared.d.ts +153 -1
- package/dist/commands/lanes-shared.js +94 -1
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +59 -1086
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.248.0] — 2026-09-27
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Internal: moved the message and resume subcommands out of src/commands/lanes.ts; no behaviour change
|
|
10
|
+
|
|
5
11
|
## [5.247.1] — 2026-09-27
|
|
6
12
|
|
|
7
13
|
### Fixed
|
|
8
14
|
|
|
15
|
+
- `hq core hq-session set company_slug` accepts case-sensitive ASCII company
|
|
16
|
+
IDs that match company directory names and writes the same value to the
|
|
17
|
+
session scope capability.
|
|
9
18
|
- `hq bot create setup` stops before creating a bot when the reserved setup slug exists but its record cannot be verified.
|
|
10
19
|
|
|
11
20
|
## [5.247.0] — 2026-09-27
|
|
@@ -99,8 +99,8 @@ cmd_set() {
|
|
|
99
99
|
# session_scope_mint's own character-set rule; `personal` is a valid slug.
|
|
100
100
|
if [ "$key" = "company_slug" ] && [ -n "$value" ]; then
|
|
101
101
|
case "$value" in
|
|
102
|
-
*[!a-
|
|
103
|
-
echo "hq-session: invalid company_slug: '$value' (allowed characters: a-z 0-9 - _)" >&2
|
|
102
|
+
*[!a-zA-Z0-9_-]*)
|
|
103
|
+
echo "hq-session: invalid company_slug: '$value' (allowed characters: A-Z a-z 0-9 - _)" >&2
|
|
104
104
|
exit 1
|
|
105
105
|
;;
|
|
106
106
|
esac
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Ctx, type LaneMessageHandlerDeps, type LaneMessageOptions } from "./lanes-shared.js";
|
|
2
|
+
export declare function createLaneMessageHandlers(deps: LaneMessageHandlerDeps): {
|
|
3
|
+
runMessage: (ctx: Ctx, laneId: string, opts: LaneMessageOptions) => Promise<void>;
|
|
4
|
+
recoverQueuedMessages: (ctx: Ctx, laneId: string) => Promise<{
|
|
5
|
+
action: "none" | "resumed" | "undeliverable";
|
|
6
|
+
message_count: number;
|
|
7
|
+
}>;
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=lanes-message.d.ts.map
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { appendCappedHistory, appendDropboxMessage, deliveryRefusalReason, dropboxInboxPath, laneAcceptsOperatorMessage, laneDeliveryMechanism, operatorMessageRefusalReason, providerSessionPortable, providerSupportsSessionResume, readLane, sessionNotPortableMessage, validateProjectStories, withLaneAdmissionLock } from "../lib/lanes/index.js";
|
|
4
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
5
|
+
import { parseCliEffort } from "../lib/lanes/effort.js";
|
|
6
|
+
import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
|
|
7
|
+
import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
|
|
8
|
+
import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages } from "../lib/lanes/dropbox.js";
|
|
9
|
+
import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision } from "../lib/lanes/watcher.js";
|
|
10
|
+
import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
|
|
11
|
+
import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
|
|
12
|
+
import { keepAliveExclusion, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
|
|
13
|
+
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
14
|
+
import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
|
|
15
|
+
export function createLaneMessageHandlers(deps) {
|
|
16
|
+
const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
|
|
17
|
+
async function messageLaneExclusion(ctx, lane) {
|
|
18
|
+
if (lane.state === "killed" || lane.state === "cancelled") {
|
|
19
|
+
return { lane, exclusion: lane.state };
|
|
20
|
+
}
|
|
21
|
+
const lineageAware = await readLanesRegistryFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, {
|
|
22
|
+
company: lane.company,
|
|
23
|
+
env: ctx.env,
|
|
24
|
+
});
|
|
25
|
+
const currentLane = readLane(ctx.hqRoot(), lane.lane_id);
|
|
26
|
+
if (currentLane.state === "killed" || currentLane.state === "cancelled") {
|
|
27
|
+
return { lane: currentLane, exclusion: currentLane.state };
|
|
28
|
+
}
|
|
29
|
+
const lanes = loadAllLanes(ctx.hqRoot());
|
|
30
|
+
const retiredLaneIds = loadRetiredLaneIds(ctx.hqRoot());
|
|
31
|
+
return {
|
|
32
|
+
lane: currentLane,
|
|
33
|
+
exclusion: keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function recordMessageUndeliverable(ctx, lane, reason, messageCount) {
|
|
37
|
+
writeWatcherDecision(watcherOutLogPath(ctx.hqRoot(), lane.lane_id), {
|
|
38
|
+
event: "message_undeliverable",
|
|
39
|
+
at: ctx.now().toISOString(),
|
|
40
|
+
state: lane.state,
|
|
41
|
+
reason,
|
|
42
|
+
...(messageCount === undefined ? {} : { message_count: messageCount }),
|
|
43
|
+
}, WATCHER_OUT_MAX_BYTES);
|
|
44
|
+
}
|
|
45
|
+
async function runMessage(ctx, laneId, opts) {
|
|
46
|
+
const hqRoot = ctx.hqRoot();
|
|
47
|
+
const hireGrantContext = opts.grantContext === undefined
|
|
48
|
+
? undefined
|
|
49
|
+
: (() => {
|
|
50
|
+
if (!ctx.isSsmOwnerDispatch()) {
|
|
51
|
+
throw new LanesError("hire_grant_dispatch_unauthorized", "Message grant context is only valid on an Outpost owner dispatch", { lane_id: laneId });
|
|
52
|
+
}
|
|
53
|
+
return parseOutpostHireGrantContext(opts.grantContext, laneId);
|
|
54
|
+
})();
|
|
55
|
+
let accepted = false;
|
|
56
|
+
let claimedMessages = [];
|
|
57
|
+
const markAccepted = () => {
|
|
58
|
+
accepted = true;
|
|
59
|
+
opts.onAccepted?.();
|
|
60
|
+
};
|
|
61
|
+
let text = opts.text;
|
|
62
|
+
if (!text) {
|
|
63
|
+
throw new LanesError("missing_text", "--text is required", {});
|
|
64
|
+
}
|
|
65
|
+
let lane = readLane(hqRoot, laneId);
|
|
66
|
+
if (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane)) {
|
|
67
|
+
await dispatchOutpostLaneCommand(ctx, lane, "message", opts);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
|
|
71
|
+
if (lane.state === "running") {
|
|
72
|
+
const workerPidPath = path.join(laneRunDir(hqRoot, laneId), "worker.pid");
|
|
73
|
+
const workerRecordExists = fs.existsSync(workerPidPath);
|
|
74
|
+
const worker = readPidRecord(workerPidPath);
|
|
75
|
+
if (workerRecordExists && worker === null) {
|
|
76
|
+
throw new LanesError("worker_pid_invalid", `Lane ${laneId} has a malformed worker PID record; message delivery cannot be classified safely`, { lane_id: laneId, worker_pid_path: workerPidPath });
|
|
77
|
+
}
|
|
78
|
+
// A fresh spawn may have committed running before publishing worker.pid;
|
|
79
|
+
// absence is not proof of exit. Reclassify only a recorded process that
|
|
80
|
+
// fails the PID plus start-time identity probe.
|
|
81
|
+
const processAlive = worker === null
|
|
82
|
+
? true
|
|
83
|
+
: probePidRecord(worker, ctx.isAlive ?? isPidAlive, ctx.readStarttime ?? readProcStarttime).alive;
|
|
84
|
+
if (worker !== null && !processAlive) {
|
|
85
|
+
// The watcher may not have reached its next poll yet. Run the same
|
|
86
|
+
// classifier synchronously, then make the queue/resume choice again.
|
|
87
|
+
await runWatcherLoop(hqRoot, laneId, {
|
|
88
|
+
...ctx.watcherLoopDeps,
|
|
89
|
+
now: () => ctx.now().getTime(),
|
|
90
|
+
isAlive: ctx.isAlive ?? isPidAlive,
|
|
91
|
+
readStarttime: ctx.readStarttime ?? readProcStarttime,
|
|
92
|
+
mesh: null,
|
|
93
|
+
resolveQuestionActiveCompany: () => resolveActiveQuestionCompany(ctx),
|
|
94
|
+
});
|
|
95
|
+
lane = readLane(hqRoot, laneId);
|
|
96
|
+
// This invocation won the race to classify the dead worker, so the
|
|
97
|
+
// watcher callback did not own recovery. Fold any earlier queued
|
|
98
|
+
// messages into this resume to keep one provider turn and one delivery.
|
|
99
|
+
if (laneDeliveryMechanism(lane) === "resume" && dropboxInboxHasMessages(hqRoot, laneId)) {
|
|
100
|
+
const queuedMessages = drainDropbox(hqRoot, laneId);
|
|
101
|
+
if (queuedMessages.length > 0) {
|
|
102
|
+
claimedMessages = queuedMessages;
|
|
103
|
+
text = `${formatDropboxMessages(queuedMessages)}\n\n${text}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const eligibility = await messageLaneExclusion(ctx, lane);
|
|
110
|
+
lane = eligibility.lane;
|
|
111
|
+
const exclusion = eligibility.exclusion;
|
|
112
|
+
if (exclusion !== undefined) {
|
|
113
|
+
let messageCount;
|
|
114
|
+
if (dropboxInboxHasMessages(hqRoot, laneId)) {
|
|
115
|
+
messageCount = drainDropbox(hqRoot, laneId, { archiveDirectory: "undeliverable" }).length;
|
|
116
|
+
}
|
|
117
|
+
recordMessageUndeliverable(ctx, lane, exclusion, messageCount);
|
|
118
|
+
const refusalMessage = deliveryRefusalReason(lane);
|
|
119
|
+
throw new LanesError("lane_not_accepting_messages", refusalMessage || `Lane ${laneId} cannot receive messages: ${exclusion}`, { lane_id: laneId, state: lane.state, reason: exclusion });
|
|
120
|
+
}
|
|
121
|
+
if (lane.state === "done" && dropboxInboxHasMessages(hqRoot, laneId)) {
|
|
122
|
+
const staleMessages = drainDropbox(hqRoot, laneId, { archiveDirectory: "undeliverable" });
|
|
123
|
+
if (staleMessages.length > 0) {
|
|
124
|
+
recordMessageUndeliverable(ctx, lane, "done_before_queued_message", staleMessages.length);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const cliEffort = parseCliEffort(lane.provider, opts.effort);
|
|
128
|
+
if (cliEffort !== undefined && !providerSupportsSessionResume(lane.provider)) {
|
|
129
|
+
throw new LanesError("effort_resume_unsupported", `Provider ${lane.provider} cannot change effort on a resumed session`, { lane_id: laneId, provider: lane.provider });
|
|
130
|
+
}
|
|
131
|
+
// Check before transcript recovery, account probing, or message writes.
|
|
132
|
+
// The final state transition repeats this check under the senior lock.
|
|
133
|
+
await withResumeHireAdmission(ctx, lane, () => undefined, hireGrantContext);
|
|
134
|
+
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
135
|
+
const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
|
|
136
|
+
if (keepCurrentAccount)
|
|
137
|
+
assertCurrentResumeAccountAvailable(ctx, lane);
|
|
138
|
+
// A real account move is refused on its own terms first: a running lane and
|
|
139
|
+
// a provider without session resume get the move's named errors, not the
|
|
140
|
+
// generic delivery refusal below. Naming the current account is a keep.
|
|
141
|
+
if (opts.account !== undefined && !keepCurrentAccount && lane.state === "running") {
|
|
142
|
+
throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
|
|
143
|
+
lane_id: laneId,
|
|
144
|
+
state: lane.state,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (opts.account !== undefined && !keepCurrentAccount && !providerSessionPortable(lane.provider)) {
|
|
148
|
+
throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
|
|
149
|
+
}
|
|
150
|
+
// One decision point for how a follow-up reaches this lane (delivery.ts).
|
|
151
|
+
// `none` refuses before anything is written: no inbox append, no spawn.
|
|
152
|
+
const mechanism = laneDeliveryMechanism(lane);
|
|
153
|
+
if (mechanism === "none") {
|
|
154
|
+
const message = deliveryRefusalReason(lane);
|
|
155
|
+
throw new LanesError("lane_not_accepting_messages", message, {
|
|
156
|
+
lane_id: laneId,
|
|
157
|
+
state: lane.state,
|
|
158
|
+
provider: lane.provider,
|
|
159
|
+
message,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (keepCurrentAccount && opts.model !== undefined && mechanism !== "resume") {
|
|
163
|
+
throw new LanesError("model_requires_resume", "--model with --account requires a message that resumes the lane", { lane_id: laneId, state: lane.state });
|
|
164
|
+
}
|
|
165
|
+
if (opts.worker !== undefined && mechanism === "dropbox") {
|
|
166
|
+
throw new LanesError("worker_profile_requires_resume", `Lane ${laneId} is still running; --worker can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
|
|
167
|
+
}
|
|
168
|
+
if (cliEffort !== undefined && mechanism === "dropbox") {
|
|
169
|
+
throw new LanesError("lane_effort_requires_resume", `Lane ${laneId} is still running; --effort can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
|
|
170
|
+
}
|
|
171
|
+
const resumeProfile = opts.worker !== undefined
|
|
172
|
+
? resolveResumeProfile(ctx, lane, opts.worker)
|
|
173
|
+
: undefined;
|
|
174
|
+
if (opts.model !== undefined && opts.account === undefined) {
|
|
175
|
+
throw new LanesError("model_requires_account", "--model on hq lanes message is only accepted together with --account", {});
|
|
176
|
+
}
|
|
177
|
+
const automaticAccountSelection = opts.account === undefined && mechanism === "resume"
|
|
178
|
+
? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile)
|
|
179
|
+
: undefined;
|
|
180
|
+
const resumeEffort = resolveResumeEffort(ctx, lane, {
|
|
181
|
+
cliEffort,
|
|
182
|
+
profile: resumeProfile,
|
|
183
|
+
account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.selection.account,
|
|
184
|
+
model: opts.model,
|
|
185
|
+
});
|
|
186
|
+
const messageId = mintMessageId();
|
|
187
|
+
const at = ctx.now().toISOString();
|
|
188
|
+
const projectId = opts.project?.trim() || lane.project_id;
|
|
189
|
+
const storyId = opts.story?.trim() || lane.story_id;
|
|
190
|
+
if (opts.story !== undefined && !opts.story.trim()) {
|
|
191
|
+
throw new LanesError("missing_story", "--story must be non-empty", {});
|
|
192
|
+
}
|
|
193
|
+
if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
|
|
194
|
+
validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
|
|
195
|
+
}
|
|
196
|
+
const framedMonitorEvent = opts.monitorEvent === undefined
|
|
197
|
+
? undefined
|
|
198
|
+
: formatMonitorEvent({
|
|
199
|
+
monitorId: opts.monitorEvent.monitor_id,
|
|
200
|
+
description: opts.monitorEvent.description,
|
|
201
|
+
sequence: opts.monitorEvent.sequence,
|
|
202
|
+
text,
|
|
203
|
+
});
|
|
204
|
+
const deliveryText = framedMonitorEvent ?? text;
|
|
205
|
+
const dropboxMessage = {
|
|
206
|
+
at,
|
|
207
|
+
message_id: messageId,
|
|
208
|
+
text,
|
|
209
|
+
project_id: projectId,
|
|
210
|
+
story_id: storyId,
|
|
211
|
+
...(opts.monitorEvent === undefined ? {} : {
|
|
212
|
+
kind: "monitor_event",
|
|
213
|
+
monitor_id: opts.monitorEvent.monitor_id,
|
|
214
|
+
description: opts.monitorEvent.description,
|
|
215
|
+
sequence: opts.monitorEvent.sequence,
|
|
216
|
+
}),
|
|
217
|
+
};
|
|
218
|
+
let resumed = null;
|
|
219
|
+
let move = null;
|
|
220
|
+
let queuedOnAdmission = false;
|
|
221
|
+
if (opts.account !== undefined && !keepCurrentAccount) {
|
|
222
|
+
// A moved message is always a resume on the target account: a running
|
|
223
|
+
// lane (the dropbox mechanism) is refused inside prepareAccountMove.
|
|
224
|
+
move = await prepareAccountMove(ctx, lane, {
|
|
225
|
+
account: opts.account,
|
|
226
|
+
cliModel: opts.model?.trim() || undefined,
|
|
227
|
+
...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
|
|
228
|
+
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
229
|
+
...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
|
|
230
|
+
pending: {
|
|
231
|
+
trigger: "message",
|
|
232
|
+
text: deliveryText,
|
|
233
|
+
...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
|
|
234
|
+
message_id: messageId,
|
|
235
|
+
compact: opts.compact !== false,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
lane = move.lane;
|
|
239
|
+
if (move.queued) {
|
|
240
|
+
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else if (mechanism === "resume") {
|
|
244
|
+
const automatic = automaticAccountSelection;
|
|
245
|
+
if (automatic !== undefined) {
|
|
246
|
+
const selection = automatic.selection;
|
|
247
|
+
if (selection.account !== lane.account) {
|
|
248
|
+
move = await prepareAccountMove(ctx, lane, {
|
|
249
|
+
account: selection.account,
|
|
250
|
+
cliModel: undefined,
|
|
251
|
+
...(automatic.historyReason !== undefined ? { accountHistoryReason: automatic.historyReason } : {}),
|
|
252
|
+
...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
|
|
253
|
+
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
254
|
+
...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
|
|
255
|
+
pending: {
|
|
256
|
+
trigger: "message",
|
|
257
|
+
text: deliveryText,
|
|
258
|
+
...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
|
|
259
|
+
message_id: messageId,
|
|
260
|
+
compact: opts.compact !== false,
|
|
261
|
+
},
|
|
262
|
+
accountSelection: selection,
|
|
263
|
+
});
|
|
264
|
+
lane = move.lane;
|
|
265
|
+
if (move.queued)
|
|
266
|
+
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
lane = persistClaudeAccountSelection(hqRoot, lane, selection);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
|
|
274
|
+
validateResumeProfileModel(ctx, lane, resumeProfile.profile);
|
|
275
|
+
}
|
|
276
|
+
if (move === null && (resumeProfile !== undefined || resumeEffort !== undefined || (keepCurrentAccount && opts.model !== undefined))) {
|
|
277
|
+
lane = recordResumeSetup(ctx, lane, {
|
|
278
|
+
...(resumeProfile !== undefined ? { profile: resumeProfile } : {}),
|
|
279
|
+
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
280
|
+
...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (move?.queued) {
|
|
284
|
+
// Parked at the target's cap: reconcile resumes the session there.
|
|
285
|
+
markAccepted();
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const dispatched = await dispatchLaneDelivery(lane, {
|
|
289
|
+
dropbox: () => {
|
|
290
|
+
// Live (or about-to-be-live) process: the PreToolUse drain delivers it.
|
|
291
|
+
if (!laneAcceptsOperatorMessage(lane)) {
|
|
292
|
+
throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
|
|
293
|
+
}
|
|
294
|
+
appendDropboxMessage(hqRoot, laneId, dropboxMessage);
|
|
295
|
+
return null;
|
|
296
|
+
},
|
|
297
|
+
resume: async () => {
|
|
298
|
+
// Exited worker with a recorded session: queue the message when capacity
|
|
299
|
+
// is occupied, otherwise start its next turn under the shared admission lock.
|
|
300
|
+
assertLaneRunsHereForAdmission(ctx, lane);
|
|
301
|
+
return withLaneAdmissionLock(hqRoot, lane.provider, async () => {
|
|
302
|
+
const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
|
|
303
|
+
if (admission.action === "refuse" && admission.error)
|
|
304
|
+
throw admission.error;
|
|
305
|
+
return withResumeHireAdmission(ctx, lane, () => {
|
|
306
|
+
if (admission.action === "queue") {
|
|
307
|
+
const sessionId = lane.provider_session_id;
|
|
308
|
+
if (!sessionId) {
|
|
309
|
+
throw new LanesError("no_provider_session", `Lane ${laneId} has no provider session id to queue this message resume`, {
|
|
310
|
+
lane_id: laneId,
|
|
311
|
+
provider: lane.provider,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
315
|
+
queuedOnAdmission = true;
|
|
316
|
+
return casUpdateLane(hqRoot, laneId, (current) => {
|
|
317
|
+
if (current.version !== lane.version || current.state !== lane.state) {
|
|
318
|
+
throw new LanesError("lane_admission_conflict", `Lane ${laneId} changed while message admission was checked`, {
|
|
319
|
+
lane_id: laneId,
|
|
320
|
+
prepared_version: lane.version,
|
|
321
|
+
version: current.version,
|
|
322
|
+
state: current.state,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
...current,
|
|
327
|
+
state: "queued",
|
|
328
|
+
queued_reason: admission.reason ?? "throttle",
|
|
329
|
+
queued_at: at,
|
|
330
|
+
pending_resume: {
|
|
331
|
+
session_id: sessionId,
|
|
332
|
+
trigger: "message",
|
|
333
|
+
text: deliveryText,
|
|
334
|
+
...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
|
|
335
|
+
message_id: messageId,
|
|
336
|
+
compact: opts.compact !== false,
|
|
337
|
+
at,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const started = spawnLaneWorker(hqRoot, lane, {
|
|
343
|
+
env: ctx.env,
|
|
344
|
+
modelDeps: {
|
|
345
|
+
readFileSync: ctx.readFileSync,
|
|
346
|
+
probe: ctx.probeProvider,
|
|
347
|
+
env: ctx.env,
|
|
348
|
+
},
|
|
349
|
+
existsSync: ctx.existsSync,
|
|
350
|
+
spawnProcess: ctx.spawnProcess,
|
|
351
|
+
proveDetached: ctx.proveDetached,
|
|
352
|
+
killProcess: ctx.killProcess,
|
|
353
|
+
now: ctx.now,
|
|
354
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
355
|
+
runCompaction: ctx.runCompaction,
|
|
356
|
+
locateTranscript: ctx.locateTranscript,
|
|
357
|
+
}, {
|
|
358
|
+
trigger: {
|
|
359
|
+
kind: "message",
|
|
360
|
+
text: deliveryText,
|
|
361
|
+
message_id: messageId,
|
|
362
|
+
...(opts.monitorEvent === undefined ? {} : { monitor_event: true }),
|
|
363
|
+
},
|
|
364
|
+
compact: opts.compact !== false,
|
|
365
|
+
effort: resumeEffort ?? recordedResumeEffort(lane),
|
|
366
|
+
});
|
|
367
|
+
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
368
|
+
return started;
|
|
369
|
+
}, hireGrantContext);
|
|
370
|
+
}, ctx.sleep);
|
|
371
|
+
},
|
|
372
|
+
}, mechanism);
|
|
373
|
+
if (dispatched.mechanism === "resume")
|
|
374
|
+
resumed = dispatched.value;
|
|
375
|
+
markAccepted();
|
|
376
|
+
}
|
|
377
|
+
// Every accepted message moves the compaction reference forward.
|
|
378
|
+
let next = casUpdateLane(hqRoot, laneId, (current) => ({
|
|
379
|
+
...resetKeepAliveProgress(current),
|
|
380
|
+
last_message_at: at,
|
|
381
|
+
}));
|
|
382
|
+
if (projectId !== lane.project_id || storyId !== lane.story_id) {
|
|
383
|
+
next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
384
|
+
const already = current.attribution_history.some((entry) => entry.message_id === messageId);
|
|
385
|
+
if (already)
|
|
386
|
+
return current;
|
|
387
|
+
return {
|
|
388
|
+
...current,
|
|
389
|
+
project_id: projectId,
|
|
390
|
+
story_id: storyId,
|
|
391
|
+
no_story_reason: storyId ? undefined : current.no_story_reason,
|
|
392
|
+
stories: laneStory(current, projectId, storyId)
|
|
393
|
+
? laneStories(current)
|
|
394
|
+
: [
|
|
395
|
+
...laneStories(current),
|
|
396
|
+
{
|
|
397
|
+
project_id: projectId,
|
|
398
|
+
story_id: storyId,
|
|
399
|
+
status: "in_progress",
|
|
400
|
+
added_at: at,
|
|
401
|
+
updated_at: at,
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
attribution_history: appendCappedHistory(current.attribution_history, {
|
|
405
|
+
project_id: projectId,
|
|
406
|
+
story_id: storyId,
|
|
407
|
+
message_id: messageId,
|
|
408
|
+
at,
|
|
409
|
+
}),
|
|
410
|
+
};
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
// What this command can claim when it returns: the message is durably
|
|
414
|
+
// written (accepted). Delivery to the model is asynchronous in both
|
|
415
|
+
// mechanisms. `dropbox` delivers on the worker's next tool call, and only
|
|
416
|
+
// if the drain hook is registered in the account home the worker was
|
|
417
|
+
// started from; `resume` has started a new worker with the message as its
|
|
418
|
+
// prompt. Neither is "delivered", so the response never says so.
|
|
419
|
+
const drainHook = drainHookStatusFor(ctx, next);
|
|
420
|
+
const deliveryStatus = move?.queued
|
|
421
|
+
? "queued_on_target_account"
|
|
422
|
+
: queuedOnAdmission
|
|
423
|
+
? "queued_on_admission"
|
|
424
|
+
: mechanism === "dropbox"
|
|
425
|
+
? "awaiting_next_tool_call"
|
|
426
|
+
: "worker_spawned";
|
|
427
|
+
const warning = mechanism === "dropbox" && drainHook !== null && !drainHook.registered
|
|
428
|
+
? `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.`
|
|
429
|
+
: mechanism === "dropbox" && next.stalled === true
|
|
430
|
+
? `The worker has made no tool call since ${next.stalled_at ?? "an unknown time"} (${next.stalled_reason ?? "stalled"}); delivery waits on its next tool call.`
|
|
431
|
+
: null;
|
|
432
|
+
if (!opts.silent)
|
|
433
|
+
printJson({
|
|
434
|
+
ok: true,
|
|
435
|
+
action: "message",
|
|
436
|
+
lane_id: laneId,
|
|
437
|
+
message_id: messageId,
|
|
438
|
+
accepted: true,
|
|
439
|
+
delivered: false,
|
|
440
|
+
delivery: mechanism,
|
|
441
|
+
delivery_status: deliveryStatus,
|
|
442
|
+
inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
|
|
443
|
+
drain_hook: drainHook,
|
|
444
|
+
warning,
|
|
445
|
+
resumed_session_id: resumed?.last_resume?.session_id ?? null,
|
|
446
|
+
compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
|
|
447
|
+
? resumed.last_compaction
|
|
448
|
+
: null,
|
|
449
|
+
account_move: move ? accountMoveSummary(move) : null,
|
|
450
|
+
project_id: next.project_id,
|
|
451
|
+
story_id: next.story_id,
|
|
452
|
+
stories: laneStories(next),
|
|
453
|
+
story_warning: storyWarning(next) ?? null,
|
|
454
|
+
lane: next,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
if (claimedMessages.length > 0 && !accepted) {
|
|
459
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
460
|
+
writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
|
|
461
|
+
event: "message_recovery_failed",
|
|
462
|
+
at: ctx.now().toISOString(),
|
|
463
|
+
state: lane.state,
|
|
464
|
+
error,
|
|
465
|
+
message_count: claimedMessages.length,
|
|
466
|
+
}, WATCHER_OUT_MAX_BYTES);
|
|
467
|
+
try {
|
|
468
|
+
requeueDropboxMessages(hqRoot, laneId, claimedMessages);
|
|
469
|
+
}
|
|
470
|
+
catch (requeueErr) {
|
|
471
|
+
const requeueMessage = requeueErr instanceof Error ? requeueErr.message : String(requeueErr);
|
|
472
|
+
writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
|
|
473
|
+
event: "message_requeue_failed",
|
|
474
|
+
at: ctx.now().toISOString(),
|
|
475
|
+
state: lane.state,
|
|
476
|
+
recovery_error: error,
|
|
477
|
+
requeue_error: requeueMessage,
|
|
478
|
+
message_count: claimedMessages.length,
|
|
479
|
+
}, WATCHER_OUT_MAX_BYTES);
|
|
480
|
+
throw new LanesError("message_requeue_failed", `Could not return ${claimedMessages.length} queued lane message(s) to the inbox after recovery failed: ${requeueMessage}`, { lane_id: laneId, message_count: claimedMessages.length, recovery_error: error });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Watcher and reconcile call the hidden command below after they win the
|
|
488
|
+
* terminal transition. The inbox is claimed once with the normal atomic
|
|
489
|
+
* dropbox drain, and an accepted message is routed through runMessage so the
|
|
490
|
+
* existing provider, admission, account, worker-profile and senior checks
|
|
491
|
+
* remain authoritative.
|
|
492
|
+
*/
|
|
493
|
+
async function recoverQueuedMessages(ctx, laneId) {
|
|
494
|
+
const hqRoot = ctx.hqRoot();
|
|
495
|
+
let lane = readLane(hqRoot, laneId);
|
|
496
|
+
if (!dropboxInboxHasMessages(hqRoot, laneId))
|
|
497
|
+
return { action: "none", message_count: 0 };
|
|
498
|
+
const eligibility = await messageLaneExclusion(ctx, lane);
|
|
499
|
+
lane = eligibility.lane;
|
|
500
|
+
const exclusion = eligibility.exclusion;
|
|
501
|
+
const refusal = exclusion ?? (lane.state === "done"
|
|
502
|
+
? "done_before_envelope"
|
|
503
|
+
: lane.state === "awaiting_input"
|
|
504
|
+
? "awaiting_input_requires_question_answer"
|
|
505
|
+
: laneDeliveryMechanism(lane) !== "resume"
|
|
506
|
+
? `no_resume_path_for_${lane.state}`
|
|
507
|
+
: undefined);
|
|
508
|
+
if (refusal !== undefined) {
|
|
509
|
+
const messages = drainDropbox(hqRoot, laneId, { archiveDirectory: "undeliverable" });
|
|
510
|
+
if (messages.length > 0)
|
|
511
|
+
recordMessageUndeliverable(ctx, lane, refusal, messages.length);
|
|
512
|
+
return { action: messages.length > 0 ? "undeliverable" : "none", message_count: messages.length };
|
|
513
|
+
}
|
|
514
|
+
const messages = drainDropbox(hqRoot, laneId);
|
|
515
|
+
if (messages.length === 0)
|
|
516
|
+
return { action: "none", message_count: 0 };
|
|
517
|
+
let accepted = false;
|
|
518
|
+
try {
|
|
519
|
+
await runMessage(ctx, laneId, {
|
|
520
|
+
text: formatDropboxMessages(messages),
|
|
521
|
+
compact: false,
|
|
522
|
+
silent: true,
|
|
523
|
+
onAccepted: () => { accepted = true; },
|
|
524
|
+
});
|
|
525
|
+
return { action: "resumed", message_count: messages.length };
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
529
|
+
writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
|
|
530
|
+
event: "message_recovery_failed",
|
|
531
|
+
at: ctx.now().toISOString(),
|
|
532
|
+
state: lane.state,
|
|
533
|
+
error,
|
|
534
|
+
message_count: messages.length,
|
|
535
|
+
}, WATCHER_OUT_MAX_BYTES);
|
|
536
|
+
if (!accepted) {
|
|
537
|
+
try {
|
|
538
|
+
requeueDropboxMessages(hqRoot, laneId, messages);
|
|
539
|
+
}
|
|
540
|
+
catch (requeueErr) {
|
|
541
|
+
const requeueMessage = requeueErr instanceof Error ? requeueErr.message : String(requeueErr);
|
|
542
|
+
writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {
|
|
543
|
+
event: "message_requeue_failed",
|
|
544
|
+
at: ctx.now().toISOString(),
|
|
545
|
+
state: lane.state,
|
|
546
|
+
recovery_error: error,
|
|
547
|
+
requeue_error: requeueMessage,
|
|
548
|
+
message_count: messages.length,
|
|
549
|
+
}, WATCHER_OUT_MAX_BYTES);
|
|
550
|
+
throw new LanesError("message_requeue_failed", `Could not return ${messages.length} queued lane message(s) to the inbox after recovery failed: ${requeueMessage}`, { lane_id: laneId, message_count: messages.length, recovery_error: error });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
throw err;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { runMessage, recoverQueuedMessages };
|
|
557
|
+
}
|
|
558
|
+
//# sourceMappingURL=lanes-message.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Ctx, type LaneBalanceMoveTargetOptions, type LaneResumeHandlerDeps, type LaneResumeOptions } from "./lanes-shared.js";
|
|
2
|
+
export declare function createLaneResumeHandlers(deps: LaneResumeHandlerDeps): {
|
|
3
|
+
runClaudeBalanceMoveTarget: (ctx: Ctx, laneId: string, opts: LaneBalanceMoveTargetOptions) => Promise<void>;
|
|
4
|
+
runResume: (ctx: Ctx, laneId: string, opts: LaneResumeOptions) => Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
//# sourceMappingURL=lanes-resume.d.ts.map
|