@indigoai-us/hq-cli 5.345.124 → 5.345.126
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 +28 -0
- package/dist/commands/lanes-accounts.js +22 -3
- package/dist/commands/lanes.js +25 -32
- package/dist/lib/lanes/accounts.d.ts +48 -2
- package/dist/lib/lanes/accounts.js +160 -9
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/message-replay.js +6 -1
- package/dist/lib/lanes/provider-rejection.d.ts +2 -6
- package/dist/lib/lanes/provider-rejection.js +19 -0
- package/dist/lib/lanes/quota.d.ts +11 -7
- package/dist/lib/lanes/quota.js +45 -5
- package/dist/lib/lanes/session-relink.d.ts +2 -0
- package/dist/lib/lanes/session-relink.js +116 -4
- package/dist/lib/lanes/spawn.d.ts +1 -1
- package/dist/lib/lanes/spawn.js +58 -12
- package/dist/lib/lanes/types.d.ts +5 -0
- package/dist/lib/lanes/types.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.126] — 2026-10-14
|
|
6
|
+
- `hq lanes` resume no longer picks a stale transcript when the same session exists in more than one account home. It uses the copy from the home the lane last ran in, and otherwise refuses with `provider_transcript_ambiguous` naming the homes.
|
|
7
|
+
|
|
8
|
+
## [5.345.125] — 2026-10-14
|
|
9
|
+
- `hq lanes` no longer sets `CLAUDE_CONFIG_DIR` for a Claude account whose
|
|
10
|
+
home is the default `~/.claude`, so lane workers, `claude auth status`
|
|
11
|
+
probes, and `hq lanes accounts login` read the same keychain credential as
|
|
12
|
+
the interactive `claude` session. `hq lanes accounts list` now reports each
|
|
13
|
+
Claude account's email and org, the interactive login, and
|
|
14
|
+
`credential_matches_interactive` with a warning when they differ.
|
|
15
|
+
- A single provider 429 now holds an account for `quota_default_hold_ms`
|
|
16
|
+
(15 minutes by default) instead of the provider's multi-day reset. The
|
|
17
|
+
provider's reset is kept as `provider_reset_at` and becomes the hold only
|
|
18
|
+
when a fresh probe is refused again before it. The structured `resetsAt`
|
|
19
|
+
on a Claude result is used when present, and "resets Oct 16 at 1am
|
|
20
|
+
(America/Denver)" style messages (the `at` form and IANA zones) parse
|
|
21
|
+
correctly. Quota refusals name the account's email and org and print the
|
|
22
|
+
`hq lanes accounts clear-quota` command.
|
|
23
|
+
- `hq lanes` resumes a lane that ran in a worktree. Spawn records the
|
|
24
|
+
directory each run started in and resumes there, and when a Claude
|
|
25
|
+
transcript is not under the expected project directory, the resume scans
|
|
26
|
+
every project directory in each account home before refusing. A truly
|
|
27
|
+
missing transcript still fails, and the error lists the directories scanned.
|
|
28
|
+
- Lead autopilot no longer starts worker lanes on codex with a fixed
|
|
29
|
+
`gpt-6-luna` model. The worker follows the same engine choice as
|
|
30
|
+
`hq lanes create` (the lead lane's provider, then the harness markers) and
|
|
31
|
+
that provider's default model and effort.
|
|
32
|
+
|
|
5
33
|
## [5.345.124] — 2026-10-14
|
|
6
34
|
- Lanes no longer stop every lane on an account for days after a single weekly-limit 429. Reset times such as "resets Oct 16 at 1am (America/Denver)" are now read with their time and timezone. A reset more than a day away first gets only a short hold, and the full hold applies only if the next lane after that also gets a 429. When a hold is set, the lane's senior gets a message with the provider's text and `hq lanes accounts clear-quota <provider> <account>`.
|
|
7
35
|
|
|
@@ -4,7 +4,7 @@ import { buildOutpostBalanceMoveCommand, dispatchErrorMessage } from "../lib/lan
|
|
|
4
4
|
import { BALANCE_MOVABLE_STATES, accountLoadsForReport, planClaudeBalanceMoves, selectClaudeAccount } from "../lib/lanes/claude-balance.js";
|
|
5
5
|
import { CompanySelectionError } from "../utils/company-selection-error.js";
|
|
6
6
|
import { effectiveAccountHome } from "../lib/lanes/account-home.js";
|
|
7
|
-
import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS,
|
|
7
|
+
import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, accountStatus, canonicalizeHome, claudeIdentityDiffers, installDrainHook, isLanesError, listAccountStatuses, loadLanesConfig, parseLaneProvider, probeInteractiveClaudeIdentity, readLane, withProviderHomeEnv, writeLanesConfig } from "../lib/lanes/index.js";
|
|
8
8
|
import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
|
|
9
9
|
import { resolveLaneEffort } from "../lib/lanes/effort.js";
|
|
10
10
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
@@ -113,9 +113,16 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
113
113
|
// model and effort are what a spawn on each account would start from
|
|
114
114
|
// (spec: --model/--effort, then the account, then the provider).
|
|
115
115
|
const quota = loadQuotaState(hqRoot);
|
|
116
|
+
// The login the interactive `claude` uses (no CLAUDE_CONFIG_DIR), so a
|
|
117
|
+
// lane account that resolves to a different keychain credential is
|
|
118
|
+
// visible here instead of only as a 429 on the wrong subscription.
|
|
119
|
+
const interactiveIdentity = providers.includes("claude") && ctx.probeProvider
|
|
120
|
+
? probeInteractiveClaudeIdentity(ctx.probeProvider, ctx.env)
|
|
121
|
+
: null;
|
|
116
122
|
const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config, {
|
|
117
123
|
existsSync: ctx.existsSync,
|
|
118
124
|
hostname: ctx.hostname(),
|
|
125
|
+
homedir: ctx.homedir,
|
|
119
126
|
probe: ctx.probeProvider,
|
|
120
127
|
env: ctx.env,
|
|
121
128
|
}).map((status) => {
|
|
@@ -129,6 +136,9 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
129
136
|
providerEffort: config.providers[provider].effort,
|
|
130
137
|
model,
|
|
131
138
|
});
|
|
139
|
+
const differs = provider === "claude"
|
|
140
|
+
? claudeIdentityDiffers(status.identity, interactiveIdentity)
|
|
141
|
+
: null;
|
|
132
142
|
return {
|
|
133
143
|
...status,
|
|
134
144
|
effective_home: status.home,
|
|
@@ -136,6 +146,13 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
136
146
|
error: "home_missing",
|
|
137
147
|
fix_command: `hq lanes accounts set-home ${provider} ${status.id} <host-home>`,
|
|
138
148
|
} : {}),
|
|
149
|
+
...(provider === "claude" ? {
|
|
150
|
+
identity: status.identity ?? null,
|
|
151
|
+
credential_matches_interactive: differs === null ? null : !differs,
|
|
152
|
+
...(differs === true ? {
|
|
153
|
+
warning: `Lane credential ${status.identity?.email ?? status.identity?.org_id ?? "(unknown)"} differs from the interactive claude login ${interactiveIdentity?.email ?? interactiveIdentity?.org_id ?? "(unknown)"}; lanes on this account bill that other subscription`,
|
|
154
|
+
} : {}),
|
|
155
|
+
} : {}),
|
|
139
156
|
model: accountCfg?.model ?? null,
|
|
140
157
|
effort: accountCfg?.effort ?? null,
|
|
141
158
|
effective_model: model ?? null,
|
|
@@ -154,6 +171,7 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
154
171
|
ok: true,
|
|
155
172
|
action: "accounts.list",
|
|
156
173
|
providers: providerDefaults,
|
|
174
|
+
...(providers.includes("claude") ? { interactive_identity: interactiveIdentity } : {}),
|
|
157
175
|
accounts,
|
|
158
176
|
});
|
|
159
177
|
}
|
|
@@ -387,15 +405,16 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
387
405
|
});
|
|
388
406
|
const copied = defaultHome !== home ? copyHookRails(defaultHome, home) : railsPresent(home);
|
|
389
407
|
installDrainHook(provider, home);
|
|
390
|
-
const envVar = PROVIDER_HOME_ENV[provider];
|
|
391
408
|
const login = VENDOR_LOGIN[provider];
|
|
392
409
|
const loginArgs = provider === "codex" && opts.deviceAuth
|
|
393
410
|
? [...login.args, "--device-auth"]
|
|
394
411
|
: login.args;
|
|
412
|
+
// Same rule as the worker spawn: the default Claude home logs in with
|
|
413
|
+
// CLAUDE_CONFIG_DIR unset so the credential matches the interactive one.
|
|
395
414
|
const result = await ctx.spawnLogin({
|
|
396
415
|
command: login.command,
|
|
397
416
|
args: loginArgs,
|
|
398
|
-
env:
|
|
417
|
+
env: withProviderHomeEnv(ctx.env, provider, home, ctx.homedir),
|
|
399
418
|
cwd: home,
|
|
400
419
|
});
|
|
401
420
|
if (result.status !== 0) {
|
package/dist/commands/lanes.js
CHANGED
|
@@ -2331,6 +2331,27 @@ function orchestratorEngine(hqRoot, senior, ctx) {
|
|
|
2331
2331
|
return "claude";
|
|
2332
2332
|
return undefined;
|
|
2333
2333
|
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Starts the worker lane a lead autopilot fill-grant wake asks for. Provider,
|
|
2336
|
+
* model and effort are left to `runCreate`, so the worker follows the
|
|
2337
|
+
* orchestrator engine (the lead lane provider, then the harness markers) and
|
|
2338
|
+
* that provider default model, and fails with `provider_required` when no
|
|
2339
|
+
* engine is known. There is no hard-coded engine.
|
|
2340
|
+
*/
|
|
2341
|
+
function autopilotWorkerStarter(ctx) {
|
|
2342
|
+
return async ({ lead, story }) => {
|
|
2343
|
+
const workerCtx = { ...ctx, env: { ...ctx.env, HQ_LANE_ID: lead.lane_id } };
|
|
2344
|
+
const started = await runCreate(workerCtx, {
|
|
2345
|
+
company: lead.company?.slug,
|
|
2346
|
+
project: lead.project_id,
|
|
2347
|
+
story: story.id,
|
|
2348
|
+
briefFile: story.briefPath,
|
|
2349
|
+
senior: "lane:" + lead.lane_id,
|
|
2350
|
+
quiet: true,
|
|
2351
|
+
});
|
|
2352
|
+
return { lane_id: started.lane_id };
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2334
2355
|
function defaultSenior(ctx) {
|
|
2335
2356
|
const parent = ctx.env.HQ_PARENT_SESSION_ID?.trim();
|
|
2336
2357
|
if (parent)
|
|
@@ -7389,7 +7410,7 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
7389
7410
|
});
|
|
7390
7411
|
const block = quotaBlockFor(loadQuotaState(hqRoot), lane.provider, target, model, now);
|
|
7391
7412
|
if (block.blocked) {
|
|
7392
|
-
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 });
|
|
7413
|
+
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})` : ""}. Clear the hold with: hq lanes accounts clear-quota ${lane.provider} ${target}`, { provider: lane.provider, account: target, quota_reset_at: block.quota_reset_at ?? null, marker: block.marker ?? null, model: model ?? null, clear_command: `hq lanes accounts clear-quota ${lane.provider} ${target}` });
|
|
7393
7414
|
}
|
|
7394
7415
|
if (model !== undefined) {
|
|
7395
7416
|
validateLaneModel(lane.provider, model, targetHome, {
|
|
@@ -7407,7 +7428,7 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
7407
7428
|
const copy = ensureSessionHome({
|
|
7408
7429
|
provider: lane.provider,
|
|
7409
7430
|
sessionId,
|
|
7410
|
-
cwd: hqRoot,
|
|
7431
|
+
cwd: lane.working_directory_at_spawn ?? hqRoot,
|
|
7411
7432
|
targetHome,
|
|
7412
7433
|
registeredHomes: Object.values(providerCfg.accounts).map((account) => effectiveAccountHome(account, ctx.hostname()).home),
|
|
7413
7434
|
providerHomeAtSpawn: lane.provider_home_at_spawn ?? sourceHome,
|
|
@@ -9399,21 +9420,7 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
|
|
|
9399
9420
|
dryRun: Boolean(opts.dryRun),
|
|
9400
9421
|
readFlag: readLanesRegistryFlag,
|
|
9401
9422
|
keepAliveResumedLeads: new Set(summary.resumed.filter((entry) => entry.reason === "keep_alive").map((entry) => entry.id)),
|
|
9402
|
-
startWorker:
|
|
9403
|
-
const workerCtx = { ...ctx, env: { ...ctx.env, HQ_LANE_ID: lead.lane_id } };
|
|
9404
|
-
const started = await runCreate(workerCtx, {
|
|
9405
|
-
company: lead.company?.slug,
|
|
9406
|
-
project: lead.project_id,
|
|
9407
|
-
story: story.id,
|
|
9408
|
-
briefFile: story.briefPath,
|
|
9409
|
-
provider: "codex",
|
|
9410
|
-
model: "gpt-6-luna",
|
|
9411
|
-
effort: "high",
|
|
9412
|
-
senior: `lane:${lead.lane_id}`,
|
|
9413
|
-
quiet: true,
|
|
9414
|
-
});
|
|
9415
|
-
return { lane_id: started.lane_id };
|
|
9416
|
-
},
|
|
9423
|
+
startWorker: autopilotWorkerStarter(ctx),
|
|
9417
9424
|
});
|
|
9418
9425
|
}
|
|
9419
9426
|
catch (error) {
|
|
@@ -10446,21 +10453,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
10446
10453
|
now: ctx.now(),
|
|
10447
10454
|
dryRun: Boolean(opts.dryRun),
|
|
10448
10455
|
readFlag: readLanesRegistryFlag,
|
|
10449
|
-
startWorker:
|
|
10450
|
-
const workerCtx = { ...ctx, env: { ...ctx.env, HQ_LANE_ID: lead.lane_id } };
|
|
10451
|
-
const started = await runCreate(workerCtx, {
|
|
10452
|
-
company: lead.company?.slug,
|
|
10453
|
-
project: lead.project_id,
|
|
10454
|
-
story: story.id,
|
|
10455
|
-
briefFile: story.briefPath,
|
|
10456
|
-
provider: "codex",
|
|
10457
|
-
model: "gpt-6-luna",
|
|
10458
|
-
effort: "high",
|
|
10459
|
-
senior: `lane:${lead.lane_id}`,
|
|
10460
|
-
quiet: true,
|
|
10461
|
-
});
|
|
10462
|
-
return { lane_id: started.lane_id };
|
|
10463
|
-
},
|
|
10456
|
+
startWorker: autopilotWorkerStarter(ctx),
|
|
10464
10457
|
});
|
|
10465
10458
|
printJson(result);
|
|
10466
10459
|
}));
|
|
@@ -10,6 +10,45 @@
|
|
|
10
10
|
import { type LaneRecord, type LaneProvider } from "./types.js";
|
|
11
11
|
import type { LanesConfig } from "./types.js";
|
|
12
12
|
export declare const PROVIDER_HOME_ENV: Record<LaneProvider, string>;
|
|
13
|
+
/**
|
|
14
|
+
* Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
|
|
15
|
+
* the variable unset it reads the plain `Claude Code-credentials` item, and
|
|
16
|
+
* with it set, even to the default `~/.claude`, it reads a hash-suffixed item
|
|
17
|
+
* for that path. Setting the variable for the default home therefore makes a
|
|
18
|
+
* lane use a different login than the interactive session (incident
|
|
19
|
+
* 2026-10-13). A Claude account whose home is the default is launched with
|
|
20
|
+
* the variable unset instead.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isDefaultProviderHome(provider: LaneProvider, home: string, homedir?: () => string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Return a copy of `base` with the provider home variable set to `home`,
|
|
25
|
+
* or, for a Claude account on the default home, with the variable removed
|
|
26
|
+
* so the worker and the interactive session share one credential.
|
|
27
|
+
*/
|
|
28
|
+
export declare function withProviderHomeEnv(base: NodeJS.ProcessEnv, provider: LaneProvider, home: string, homedir?: () => string): NodeJS.ProcessEnv;
|
|
29
|
+
/** Who `claude auth status` says a home is logged in as. Never a token. */
|
|
30
|
+
export interface ClaudeIdentity {
|
|
31
|
+
email: string | null;
|
|
32
|
+
org_id: string | null;
|
|
33
|
+
org_name: string | null;
|
|
34
|
+
subscription_type: string | null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Email and org from a `claude auth status` JSON payload. Only identity
|
|
38
|
+
* fields are kept; everything else in the payload is dropped.
|
|
39
|
+
*/
|
|
40
|
+
export declare function claudeIdentityFromProbe(result: AccountStatusProbeResult): ClaudeIdentity | null;
|
|
41
|
+
/** "me@example.com (Acme)" label for hold and failover messages. */
|
|
42
|
+
export declare function describeClaudeIdentity(identity: ClaudeIdentity | undefined | null): string | null;
|
|
43
|
+
/**
|
|
44
|
+
* The identity the interactive session uses: `claude auth status` with no
|
|
45
|
+
* `CLAUDE_CONFIG_DIR`. Null when the probe fails or names nobody.
|
|
46
|
+
*/
|
|
47
|
+
export declare function probeInteractiveClaudeIdentity(probe: NonNullable<AccountDeps["probe"]>, base?: NodeJS.ProcessEnv): ClaudeIdentity | null;
|
|
48
|
+
/** True when both identities are known and name different logins; null when unknown. */
|
|
49
|
+
export declare function claudeIdentityDiffers(a: ClaudeIdentity | undefined | null, b: ClaudeIdentity | undefined | null): boolean | null;
|
|
50
|
+
/** The operator command that lifts a recorded quota hold. */
|
|
51
|
+
export declare function clearQuotaCommand(provider: LaneProvider, accountId: string): string;
|
|
13
52
|
export declare const AUTH_JSON_NAME = "auth.json";
|
|
14
53
|
export declare const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
|
|
15
54
|
/**
|
|
@@ -28,6 +67,7 @@ export interface AccountStatusProbeResult {
|
|
|
28
67
|
export interface AccountDeps {
|
|
29
68
|
existsSync: (p: string) => boolean;
|
|
30
69
|
hostname?: string;
|
|
70
|
+
homedir?: () => string;
|
|
31
71
|
/** Provider status output is parsed for a boolean only; it is never printed. */
|
|
32
72
|
probe?: (command: string, args: string[], env: NodeJS.ProcessEnv) => AccountStatusProbeResult;
|
|
33
73
|
env?: NodeJS.ProcessEnv;
|
|
@@ -64,6 +104,8 @@ export interface AccountStatus {
|
|
|
64
104
|
id: string;
|
|
65
105
|
home: string;
|
|
66
106
|
home_source?: "host" | "default";
|
|
107
|
+
/** Email and org from the provider status command, when it was consulted. */
|
|
108
|
+
identity?: ClaudeIdentity;
|
|
67
109
|
enabled: boolean;
|
|
68
110
|
disabled_reason?: string;
|
|
69
111
|
/** Present in personal/lanes/config.json. */
|
|
@@ -112,6 +154,10 @@ export declare function resolveAccount(provider: LaneProvider, requestedId: stri
|
|
|
112
154
|
export declare function authJsonPath(home: string): string;
|
|
113
155
|
export declare function accountStatus(provider: LaneProvider, accountId: string, config: LanesConfig, deps?: AccountDeps): AccountStatus;
|
|
114
156
|
export declare function listAccountStatuses(provider: LaneProvider, config: LanesConfig, deps?: AccountDeps): AccountStatus[];
|
|
115
|
-
/**
|
|
116
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Environment assignment the worker process should receive. Empty for a
|
|
159
|
+
* Claude account on the default home, which runs with the variable unset
|
|
160
|
+
* (see isDefaultProviderHome).
|
|
161
|
+
*/
|
|
162
|
+
export declare function accountEnv(resolved: ResolvedAccount, homedir?: () => string): Record<string, string>;
|
|
117
163
|
//# sourceMappingURL=accounts.d.ts.map
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* module never reads, prints, copies, or hashes credential files.
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
11
12
|
import { effectiveAccountHome } from "./account-home.js";
|
|
12
13
|
import * as path from "node:path";
|
|
13
14
|
import { AccountResolutionError } from "./errors.js";
|
|
14
15
|
import { isTerminalLaneState } from "./types.js";
|
|
15
|
-
import { accountMaxRunning } from "./config.js";
|
|
16
|
+
import { DEFAULT_ACCOUNT_HOMES, accountMaxRunning, canonicalizeHome } from "./config.js";
|
|
16
17
|
import { listLaneIds, readLane } from "./store.js";
|
|
17
18
|
import { loadQuotaState, quotaBlockFor } from "./quota.js";
|
|
18
19
|
export const PROVIDER_HOME_ENV = {
|
|
@@ -20,6 +21,112 @@ export const PROVIDER_HOME_ENV = {
|
|
|
20
21
|
codex: "CODEX_HOME",
|
|
21
22
|
grok: "GROK_HOME",
|
|
22
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Claude Code keys its OS keychain credential by `CLAUDE_CONFIG_DIR`: with
|
|
26
|
+
* the variable unset it reads the plain `Claude Code-credentials` item, and
|
|
27
|
+
* with it set, even to the default `~/.claude`, it reads a hash-suffixed item
|
|
28
|
+
* for that path. Setting the variable for the default home therefore makes a
|
|
29
|
+
* lane use a different login than the interactive session (incident
|
|
30
|
+
* 2026-10-13). A Claude account whose home is the default is launched with
|
|
31
|
+
* the variable unset instead.
|
|
32
|
+
*/
|
|
33
|
+
export function isDefaultProviderHome(provider, home, homedir = () => os.homedir()) {
|
|
34
|
+
const deps = {
|
|
35
|
+
homedir,
|
|
36
|
+
realpathSync: (p) => fs.realpathSync(p),
|
|
37
|
+
existsSync: (p) => fs.existsSync(p),
|
|
38
|
+
};
|
|
39
|
+
try {
|
|
40
|
+
return canonicalizeHome(home, deps) === canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], deps);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Return a copy of `base` with the provider home variable set to `home`,
|
|
48
|
+
* or, for a Claude account on the default home, with the variable removed
|
|
49
|
+
* so the worker and the interactive session share one credential.
|
|
50
|
+
*/
|
|
51
|
+
export function withProviderHomeEnv(base, provider, home, homedir) {
|
|
52
|
+
const next = { ...base };
|
|
53
|
+
const envVar = PROVIDER_HOME_ENV[provider];
|
|
54
|
+
if (provider === "claude" && isDefaultProviderHome(provider, home, homedir)) {
|
|
55
|
+
delete next[envVar];
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
next[envVar] = home;
|
|
59
|
+
}
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
function optionalString(value) {
|
|
63
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Email and org from a `claude auth status` JSON payload. Only identity
|
|
67
|
+
* fields are kept; everything else in the payload is dropped.
|
|
68
|
+
*/
|
|
69
|
+
export function claudeIdentityFromProbe(result) {
|
|
70
|
+
const text = result.stdout.trim();
|
|
71
|
+
if (!text.startsWith("{"))
|
|
72
|
+
return null;
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(text);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
81
|
+
return null;
|
|
82
|
+
const rec = parsed;
|
|
83
|
+
const identity = {
|
|
84
|
+
email: optionalString(rec.email),
|
|
85
|
+
org_id: optionalString(rec.orgId),
|
|
86
|
+
org_name: optionalString(rec.orgName),
|
|
87
|
+
subscription_type: optionalString(rec.subscriptionType),
|
|
88
|
+
};
|
|
89
|
+
if (!identity.email && !identity.org_id && !identity.org_name)
|
|
90
|
+
return null;
|
|
91
|
+
return identity;
|
|
92
|
+
}
|
|
93
|
+
/** "me@example.com (Acme)" label for hold and failover messages. */
|
|
94
|
+
export function describeClaudeIdentity(identity) {
|
|
95
|
+
if (!identity)
|
|
96
|
+
return null;
|
|
97
|
+
const org = identity.org_name ?? identity.org_id;
|
|
98
|
+
if (identity.email && org)
|
|
99
|
+
return `${identity.email} (${org})`;
|
|
100
|
+
return identity.email ?? org;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The identity the interactive session uses: `claude auth status` with no
|
|
104
|
+
* `CLAUDE_CONFIG_DIR`. Null when the probe fails or names nobody.
|
|
105
|
+
*/
|
|
106
|
+
export function probeInteractiveClaudeIdentity(probe, base = process.env) {
|
|
107
|
+
const bare = { ...base };
|
|
108
|
+
delete bare[PROVIDER_HOME_ENV.claude];
|
|
109
|
+
try {
|
|
110
|
+
return claudeIdentityFromProbe(probe("claude", ["auth", "status"], bare));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** True when both identities are known and name different logins; null when unknown. */
|
|
117
|
+
export function claudeIdentityDiffers(a, b) {
|
|
118
|
+
if (!a || !b)
|
|
119
|
+
return null;
|
|
120
|
+
if (a.email && b.email)
|
|
121
|
+
return a.email.toLowerCase() !== b.email.toLowerCase();
|
|
122
|
+
if (a.org_id && b.org_id)
|
|
123
|
+
return a.org_id !== b.org_id;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/** The operator command that lifts a recorded quota hold. */
|
|
127
|
+
export function clearQuotaCommand(provider, accountId) {
|
|
128
|
+
return `hq lanes accounts clear-quota ${provider} ${accountId}`;
|
|
129
|
+
}
|
|
23
130
|
export const AUTH_JSON_NAME = "auth.json";
|
|
24
131
|
export const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
|
|
25
132
|
/**
|
|
@@ -161,23 +268,34 @@ function modelForAccount(provider, accountId, config, ctx) {
|
|
|
161
268
|
return providerModel.trim();
|
|
162
269
|
return undefined;
|
|
163
270
|
}
|
|
164
|
-
|
|
271
|
+
/** "account "a" (me@example.com (Acme))" when the identity is known. */
|
|
272
|
+
function accountLabel(accountId, identity) {
|
|
273
|
+
const who = describeClaudeIdentity(identity);
|
|
274
|
+
return `Account ${JSON.stringify(accountId)}${who ? ` (${who})` : ""}`;
|
|
275
|
+
}
|
|
276
|
+
function throwIfQuotaBlocked(block, provider, accountId, identity) {
|
|
165
277
|
if (!block.blocked || !block.reason)
|
|
166
278
|
return;
|
|
167
279
|
const model = block.model ?? "(no model)";
|
|
280
|
+
const label = accountLabel(accountId, identity);
|
|
281
|
+
const identityFields = identity ? { identity } : {};
|
|
168
282
|
if (block.reason === "account_model_unavailable") {
|
|
169
|
-
throw new AccountResolutionError("account_model_unavailable",
|
|
283
|
+
throw new AccountResolutionError("account_model_unavailable", `${label} cannot run model ${model}: ${block.marker ?? "needs credits"}`, {
|
|
170
284
|
provider,
|
|
171
285
|
account: accountId,
|
|
172
286
|
model: block.model ?? null,
|
|
173
287
|
marker: block.marker ?? null,
|
|
288
|
+
...identityFields,
|
|
174
289
|
});
|
|
175
290
|
}
|
|
176
|
-
|
|
291
|
+
const clear = clearQuotaCommand(provider, accountId);
|
|
292
|
+
throw new AccountResolutionError("account_quota_exhausted", `${label} is out of quota${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""} for model ${model}. Clear the hold with: ${clear}`, {
|
|
177
293
|
provider,
|
|
178
294
|
account: accountId,
|
|
179
295
|
model: block.model ?? null,
|
|
180
296
|
quota_reset_at: block.quota_reset_at ?? null,
|
|
297
|
+
clear_command: clear,
|
|
298
|
+
...identityFields,
|
|
181
299
|
});
|
|
182
300
|
}
|
|
183
301
|
function boundAccount(provider, id, account, hostname) {
|
|
@@ -205,15 +323,19 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
|
|
|
205
323
|
throw new AccountResolutionError("no_enabled_account", `No enabled account for provider ${provider}`, { provider, ineligible: "none_enabled" });
|
|
206
324
|
}
|
|
207
325
|
const proven = [];
|
|
326
|
+
const identities = new Map();
|
|
208
327
|
for (const id of enabled) {
|
|
209
328
|
const status = accountStatus(provider, id, config, {
|
|
210
329
|
existsSync,
|
|
211
330
|
...(ctx.accountDeps?.probe ? { probe: ctx.accountDeps.probe } : {}),
|
|
212
331
|
...(ctx.accountDeps?.env ? { env: ctx.accountDeps.env } : {}),
|
|
332
|
+
...(ctx.accountDeps?.homedir ? { homedir: ctx.accountDeps.homedir } : {}),
|
|
213
333
|
...(ctx.hostname !== undefined ? { hostname: ctx.hostname } : {}),
|
|
214
334
|
});
|
|
215
335
|
if (status.logged_in === true)
|
|
216
336
|
proven.push(id);
|
|
337
|
+
if (status.identity)
|
|
338
|
+
identities.set(id, status.identity);
|
|
217
339
|
}
|
|
218
340
|
if (proven.length === 0) {
|
|
219
341
|
throw new AccountResolutionError("no_enabled_account", `No logged-in account for provider ${provider}`, { provider, ineligible: "none_logged_in", enabled });
|
|
@@ -247,17 +369,25 @@ export function selectBalancedAccount(provider, config, ctx = {}) {
|
|
|
247
369
|
account: id,
|
|
248
370
|
reason: block.reason,
|
|
249
371
|
quota_reset_at: block.quota_reset_at ?? null,
|
|
372
|
+
identity: identities.get(id) ?? null,
|
|
373
|
+
clear_command: clearQuotaCommand(provider, id),
|
|
250
374
|
}));
|
|
251
|
-
|
|
375
|
+
const held = blocked
|
|
376
|
+
.filter(({ block }) => block.reason === "account_quota_exhausted")
|
|
377
|
+
.map(({ id, block }) => `${accountLabel(id, identities.get(id))}${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""}; clear with: ${clearQuotaCommand(provider, id)}`)
|
|
378
|
+
.join("; ");
|
|
379
|
+
throw new AccountResolutionError("account_quota_exhausted", `All logged-in accounts for ${provider} are quota-limited for the selected model. ${held}`, {
|
|
252
380
|
provider,
|
|
253
381
|
account: earliest.id,
|
|
254
382
|
model: earliest.block.model ?? null,
|
|
255
383
|
quota_reset_at: resetTimes[0] ?? null,
|
|
256
384
|
account_reasons: resetsByAccount,
|
|
385
|
+
...(identities.get(earliest.id) ? { identity: identities.get(earliest.id) } : {}),
|
|
386
|
+
clear_command: clearQuotaCommand(provider, earliest.id),
|
|
257
387
|
});
|
|
258
388
|
}
|
|
259
389
|
const sample = proven[0];
|
|
260
|
-
throwIfQuotaBlocked(blocked[0].block, provider, sample);
|
|
390
|
+
throwIfQuotaBlocked(blocked[0].block, provider, sample, identities.get(sample));
|
|
261
391
|
}
|
|
262
392
|
const underCap = runnable.filter((id) => {
|
|
263
393
|
const cap = accountMaxRunning(providerCfg, id);
|
|
@@ -298,6 +428,16 @@ export function resolveAccount(provider, requestedId, config, ctx = {}) {
|
|
|
298
428
|
if (ctx.hqRoot !== undefined) {
|
|
299
429
|
const now = ctx.now ?? new Date();
|
|
300
430
|
const block = quotaBlockFor(loadQuotaState(ctx.hqRoot), provider, requestedId, modelForAccount(provider, requestedId, config, ctx), now);
|
|
431
|
+
if (block.blocked && provider === "claude" && ctx.accountDeps?.probe) {
|
|
432
|
+
const status = accountStatus(provider, requestedId, config, {
|
|
433
|
+
existsSync: ctx.existsSync ?? ((p) => fs.existsSync(p)),
|
|
434
|
+
probe: ctx.accountDeps.probe,
|
|
435
|
+
...(ctx.accountDeps.env ? { env: ctx.accountDeps.env } : {}),
|
|
436
|
+
...(ctx.accountDeps.homedir ? { homedir: ctx.accountDeps.homedir } : {}),
|
|
437
|
+
...(ctx.hostname !== undefined ? { hostname: ctx.hostname } : {}),
|
|
438
|
+
});
|
|
439
|
+
throwIfQuotaBlocked(block, provider, requestedId, status.identity);
|
|
440
|
+
}
|
|
301
441
|
throwIfQuotaBlocked(block, provider, requestedId);
|
|
302
442
|
}
|
|
303
443
|
return boundAccount(provider, requestedId, account, ctx.hostname);
|
|
@@ -328,14 +468,16 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
|
|
|
328
468
|
const present = homeExists && hasCredentialFile(provider, effective.home, deps.existsSync);
|
|
329
469
|
let auth_state = homeExists ? authStateFor(provider, present) : "home_missing";
|
|
330
470
|
let logged_in = homeExists ? loggedInFromAuthState(auth_state) : false;
|
|
471
|
+
let identity;
|
|
331
472
|
if (homeExists && !present && provider === "claude" && deps.probe !== undefined) {
|
|
332
473
|
try {
|
|
333
|
-
const result = deps.probe("claude", ["auth", "status"],
|
|
474
|
+
const result = deps.probe("claude", ["auth", "status"], withProviderHomeEnv(deps.env ?? process.env, "claude", effective.home, deps.homedir));
|
|
334
475
|
const providerStatus = claudeLoginStatus(result);
|
|
335
476
|
if (providerStatus !== null) {
|
|
336
477
|
auth_state = "status";
|
|
337
478
|
logged_in = providerStatus;
|
|
338
479
|
}
|
|
480
|
+
identity = claudeIdentityFromProbe(result) ?? undefined;
|
|
339
481
|
}
|
|
340
482
|
catch {
|
|
341
483
|
process.stderr.write(`hq lanes: Claude auth status probe failed for account ${accountId}\n`);
|
|
@@ -352,6 +494,8 @@ export function accountStatus(provider, accountId, config, deps = defaultAccount
|
|
|
352
494
|
auth_state,
|
|
353
495
|
envVar,
|
|
354
496
|
};
|
|
497
|
+
if (identity)
|
|
498
|
+
status.identity = identity;
|
|
355
499
|
if (account.disabled_reason !== undefined) {
|
|
356
500
|
status.disabled_reason = account.disabled_reason;
|
|
357
501
|
}
|
|
@@ -381,8 +525,15 @@ function claudeLoginStatus(result) {
|
|
|
381
525
|
return false;
|
|
382
526
|
return null;
|
|
383
527
|
}
|
|
384
|
-
/**
|
|
385
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Environment assignment the worker process should receive. Empty for a
|
|
530
|
+
* Claude account on the default home, which runs with the variable unset
|
|
531
|
+
* (see isDefaultProviderHome).
|
|
532
|
+
*/
|
|
533
|
+
export function accountEnv(resolved, homedir) {
|
|
534
|
+
if (resolved.provider === "claude" && isDefaultProviderHome("claude", resolved.home, homedir)) {
|
|
535
|
+
return {};
|
|
536
|
+
}
|
|
386
537
|
return { [resolved.envVar]: resolved.home };
|
|
387
538
|
}
|
|
388
539
|
//# sourceMappingURL=accounts.js.map
|
|
@@ -40,8 +40,8 @@ export { CPU_SAMPLE_MS, LANES_CPU_BUSY_OVERRIDE, LANES_CPU_CHECK, PROC_MEMINFO,
|
|
|
40
40
|
export type { CpuTimes, ThrottleAdmitReason, ThrottleDecision, ThrottleDeferReason, ThrottleDeps, ThrottleState, } from "./throttle.js";
|
|
41
41
|
export { effectiveAccountHome, } from "./account-home.js";
|
|
42
42
|
export type { EffectiveAccountHome, } from "./account-home.js";
|
|
43
|
-
export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, credentialPaths, hasCredentialFile, isCredentialProven, isLoggedOut, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
|
|
44
|
-
export type { AccountResolveContext, AccountStatus, AuthState, ResolvedAccount, } from "./accounts.js";
|
|
43
|
+
export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, claudeIdentityDiffers, claudeIdentityFromProbe, clearQuotaCommand, credentialPaths, describeClaudeIdentity, hasCredentialFile, isCredentialProven, isDefaultProviderHome, isLoggedOut, probeInteractiveClaudeIdentity, withProviderHomeEnv, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
|
|
44
|
+
export type { AccountResolveContext, AccountStatus, AuthState, ClaudeIdentity, ResolvedAccount, } from "./accounts.js";
|
|
45
45
|
export { LIVE_WITHIN_MS, LIVENESS_EVENT_KINDS, SILENT_LOST_MS, TURN_START_LOST_MS, buildLivenessIndexFromSpool, classifySessionLiveness, defaultLivenessDeps, defaultSpoolPath, ingestSpoolLine, listSessionLiveness, livenessIndexPath, loadLivenessIndex, readDaemonPresenceConnected, readLivenessIndex, rebuildLivenessIndex, sessionLiveness, } from "./liveness.js";
|
|
46
46
|
export type { LivenessDeps, LivenessEventKind, LivenessIndex, SessionIndexEntry, } from "./liveness.js";
|
|
47
47
|
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, wakeAnsweredLaneNow, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
package/dist/lib/lanes/index.js
CHANGED
|
@@ -22,7 +22,7 @@ export { ENVELOPE_INVALID_REASON, decideEnvelopeRepair, emitMeshSoft, envelopeQu
|
|
|
22
22
|
export { LANE_MESH_EVENTS, WIRE_EVENT_KINDS, createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, laneCanEmit, parseLaneMeshEvent, progressEventDue, toWireThreadEvent, } from "./mesh-events.js";
|
|
23
23
|
export { CPU_SAMPLE_MS, LANES_CPU_BUSY_OVERRIDE, LANES_CPU_CHECK, PROC_MEMINFO, STARVATION_MAX_DEFERRALS, STARVATION_MAX_MS, VM_STAT_BIN, applyThrottleDecision, busyFractionFromDeltas, cpuTimesSnapshot, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, parseMemAvailableKb, parseVmStatAvailableMb, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, sampleCpuBusyFraction, starvationBoundReached, throttleStatePath, writeThrottleState, } from "./throttle.js";
|
|
24
24
|
export { effectiveAccountHome, } from "./account-home.js";
|
|
25
|
-
export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, credentialPaths, hasCredentialFile, isCredentialProven, isLoggedOut, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
|
|
25
|
+
export { AUTH_JSON_NAME, CLAUDE_CREDENTIALS_NAME, PROVIDER_CREDENTIAL_FILES, PROVIDER_HOME_ENV, accountEnv, accountStatus, authJsonPath, authStateFor, claudeIdentityDiffers, claudeIdentityFromProbe, clearQuotaCommand, credentialPaths, describeClaudeIdentity, hasCredentialFile, isCredentialProven, isDefaultProviderHome, isLoggedOut, probeInteractiveClaudeIdentity, withProviderHomeEnv, isOccupyingLaneState, lastStartedAt, listAccountStatuses, occupyingLaneCount, resolveAccount, runningLaneCount, selectBalancedAccount, } from "./accounts.js";
|
|
26
26
|
export { LIVE_WITHIN_MS, LIVENESS_EVENT_KINDS, SILENT_LOST_MS, TURN_START_LOST_MS, buildLivenessIndexFromSpool, classifySessionLiveness, defaultLivenessDeps, defaultSpoolPath, ingestSpoolLine, listSessionLiveness, livenessIndexPath, loadLivenessIndex, readDaemonPresenceConnected, readLivenessIndex, rebuildLivenessIndex, sessionLiveness, } from "./liveness.js";
|
|
27
27
|
export { DEAD_HOST_MS, MAX_AGED_ADVANCES, QUESTION_AGE_MS, RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME, wakeAnsweredLaneNow, agedAdvanceCount, currentHopStartedAtMs, isDeadlinePassed, laneRunsHere, readTimeoutDeadline, reconcileHeartbeatPath, resolveQuestionAudience, runReconcilePass, shouldAdvanceAudienceEnded, shouldAgeQuestion, timeoutPath, } from "./reconcile.js";
|
|
28
28
|
export { RECONCILE_TIMER_INTERVAL, defaultSystemdUserDir, installReconcileTimer, providerPathEnv, renderReconcileService, renderReconcileTimer, uninstallReconcileTimer, } from "./reconcile-install.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { probeProviderCompactionsSince, providerTranscriptSize, } from "./compaction.js";
|
|
5
6
|
import { LanesError } from "./errors.js";
|
|
@@ -55,7 +56,11 @@ export function writeMessageReplayState(filePath, state) {
|
|
|
55
56
|
function safeProviderTranscriptPath(transcriptPath, provider, env) {
|
|
56
57
|
if (provider === "grok")
|
|
57
58
|
return transcriptPath;
|
|
58
|
-
|
|
59
|
+
// A Claude worker on the default home runs with CLAUDE_CONFIG_DIR unset
|
|
60
|
+
// (accounts.ts isDefaultProviderHome); its transcripts live under ~/.claude.
|
|
61
|
+
const home = provider === "claude"
|
|
62
|
+
? env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")
|
|
63
|
+
: env.CODEX_HOME;
|
|
59
64
|
if (!home) {
|
|
60
65
|
throw new LanesError("message_replay_provider_home_missing", `Cannot validate ${provider} transcript without its provider home`);
|
|
61
66
|
}
|
|
@@ -38,6 +38,8 @@ export interface ProviderRejection {
|
|
|
38
38
|
event: "codex.turn.failed" | "claude.result.is_error" | "claude.result.login_failure" | "grok.error";
|
|
39
39
|
/** Filesystem time when the provider output was appended, when read from worker.out. */
|
|
40
40
|
output_at?: string;
|
|
41
|
+
/** The provider's structured reset (`resetsAt`), when the result carried one. */
|
|
42
|
+
resets_at?: unknown;
|
|
41
43
|
}
|
|
42
44
|
export declare function isProviderLoginFailure(failure: ProviderRejection | null): boolean;
|
|
43
45
|
/**
|
|
@@ -56,12 +58,6 @@ export declare function retryableFromStatus(status: number | null): boolean;
|
|
|
56
58
|
* read when it parses; otherwise the outer text is the message.
|
|
57
59
|
*/
|
|
58
60
|
export declare function parseCodexTurnFailed(text: string): ProviderRejection | null;
|
|
59
|
-
/**
|
|
60
|
-
* Claude `-p --output-format json`: the last `type: "result"` object with
|
|
61
|
-
* `is_error: true` and a numeric `api_error_status`, or the CLI's explicit
|
|
62
|
-
* unauthenticated message. Other status-less errors keep the existing
|
|
63
|
-
* classification because they may be quota text or a worker failure.
|
|
64
|
-
*/
|
|
65
61
|
export declare function parseClaudeResultRejection(text: string): ProviderRejection | null;
|
|
66
62
|
/**
|
|
67
63
|
* Grok `--output-format json` prints one object. Measured 2026-09-22 (lane
|
|
@@ -166,6 +166,23 @@ export function parseCodexTurnFailed(text) {
|
|
|
166
166
|
* unauthenticated message. Other status-less errors keep the existing
|
|
167
167
|
* classification because they may be quota text or a worker failure.
|
|
168
168
|
*/
|
|
169
|
+
function isRecord(value) {
|
|
170
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
171
|
+
}
|
|
172
|
+
/** `resetsAt` on the result, or under `rate_limit_info` (directly or in the API error params). */
|
|
173
|
+
function claudeStructuredReset(rec) {
|
|
174
|
+
const direct = rec.resets_at ?? rec.resetsAt;
|
|
175
|
+
if (direct !== undefined)
|
|
176
|
+
return direct;
|
|
177
|
+
for (const holder of [rec, rec.api_error_params, rec.apiErrorParams]) {
|
|
178
|
+
if (!isRecord(holder))
|
|
179
|
+
continue;
|
|
180
|
+
const info = holder.rate_limit_info ?? holder.rateLimitInfo;
|
|
181
|
+
if (isRecord(info) && info.resetsAt !== undefined)
|
|
182
|
+
return info.resetsAt;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
169
186
|
export function parseClaudeResultRejection(text) {
|
|
170
187
|
// The last `result` decides: a success or a status-less error there is
|
|
171
188
|
// final (NOT_REJECTED), so the scan does not continue to earlier results.
|
|
@@ -182,6 +199,7 @@ export function parseClaudeResultRejection(text) {
|
|
|
182
199
|
if (status === null && !loginFailure && !retryableProviderFailure(status, message)) {
|
|
183
200
|
return NOT_REJECTED;
|
|
184
201
|
}
|
|
202
|
+
const resetsAt = claudeStructuredReset(rec);
|
|
185
203
|
return {
|
|
186
204
|
reason: PROVIDER_REJECTED_REASON,
|
|
187
205
|
provider: "claude",
|
|
@@ -192,6 +210,7 @@ export function parseClaudeResultRejection(text) {
|
|
|
192
210
|
event: loginFailure && rec.is_error !== true
|
|
193
211
|
? "claude.result.login_failure"
|
|
194
212
|
: "claude.result.is_error",
|
|
213
|
+
...(resetsAt !== undefined ? { resets_at: resetsAt } : {}),
|
|
195
214
|
};
|
|
196
215
|
});
|
|
197
216
|
return found === NOT_REJECTED ? null : found;
|
|
@@ -107,16 +107,20 @@ export declare function parseProviderResetTime(text: string, now: Date): string
|
|
|
107
107
|
* `type: "result"` object. Nothing else from the payload is returned, and
|
|
108
108
|
* this function does not log.
|
|
109
109
|
*/
|
|
110
|
-
export
|
|
110
|
+
export interface QuotaResultFields {
|
|
111
111
|
api_error_status: unknown;
|
|
112
112
|
is_error: unknown;
|
|
113
113
|
result: unknown;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
114
|
+
/**
|
|
115
|
+
* The provider's structured reset (`resetsAt`, epoch seconds or ISO) when
|
|
116
|
+
* the result carries one; it beats the wording in `result`.
|
|
117
|
+
*/
|
|
118
|
+
resets_at?: unknown;
|
|
119
|
+
}
|
|
120
|
+
/** Epoch seconds, epoch milliseconds, or an ISO string, to ISO; null otherwise. */
|
|
121
|
+
export declare function normalizeStructuredReset(value: unknown): string | null;
|
|
122
|
+
export declare function quotaFieldsFromOutput(text: string): QuotaResultFields | null;
|
|
123
|
+
export declare function quotaSignalFromFields(fields: QuotaResultFields, model: string | undefined, now: Date): QuotaSignal | null;
|
|
120
124
|
export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
|
|
121
125
|
export interface RecordAccountQuotaOptions {
|
|
122
126
|
/** When the 429 was observed. Enables the far-reset confirmation gate. */
|
package/dist/lib/lanes/quota.js
CHANGED
|
@@ -258,7 +258,7 @@ export function parseProviderResetTime(text, now) {
|
|
|
258
258
|
}
|
|
259
259
|
return null;
|
|
260
260
|
}
|
|
261
|
-
const resetMatch = new RegExp(String.raw `\bresets\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:(?:,\s*(?:(\d{4})
|
|
261
|
+
const resetMatch = new RegExp(String.raw `\bresets\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:(?:,\s*(?:(\d{4})(?:,|\s+at)?\s+)?|\s+at\s+)(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*${ZONE_PATTERN})?`, "i").exec(text);
|
|
262
262
|
const codexMatch = /\btry\s+again\s+at\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?,\s*(\d{4})\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s*\((UTC)\))?/i.exec(text);
|
|
263
263
|
const match = resetMatch ?? codexMatch;
|
|
264
264
|
if (!match)
|
|
@@ -320,10 +320,47 @@ function isUsageLimitShape(text) {
|
|
|
320
320
|
return /weekly limit|usage limit|rate limit|quota|usage credits|hit your .+ limit/i.test(text);
|
|
321
321
|
}
|
|
322
322
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
323
|
+
* A structured reset from a result object: `resets_at` / `resetsAt` at the
|
|
324
|
+
* top level, or `rate_limit_info.resetsAt` directly or under
|
|
325
|
+
* `api_error_params` / `apiErrorParams`. Undefined when none is present.
|
|
326
326
|
*/
|
|
327
|
+
function structuredResetFrom(rec) {
|
|
328
|
+
const direct = rec.resets_at ?? rec.resetsAt;
|
|
329
|
+
if (direct !== undefined)
|
|
330
|
+
return direct;
|
|
331
|
+
const holders = [rec, rec.api_error_params, rec.apiErrorParams];
|
|
332
|
+
for (const holder of holders) {
|
|
333
|
+
if (!isPlainObject(holder))
|
|
334
|
+
continue;
|
|
335
|
+
const info = holder.rate_limit_info ?? holder.rateLimitInfo;
|
|
336
|
+
if (isPlainObject(info) && info.resetsAt !== undefined)
|
|
337
|
+
return info.resetsAt;
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
/** Epoch seconds, epoch milliseconds, or an ISO string, to ISO; null otherwise. */
|
|
342
|
+
export function normalizeStructuredReset(value) {
|
|
343
|
+
let ms;
|
|
344
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
345
|
+
ms = value < 1e12 ? value * 1000 : value;
|
|
346
|
+
}
|
|
347
|
+
else if (typeof value === "string" && value.trim().length > 0) {
|
|
348
|
+
const trimmed = value.trim();
|
|
349
|
+
if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
|
|
350
|
+
const n = Number(trimmed);
|
|
351
|
+
ms = n < 1e12 ? n * 1000 : n;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
ms = Date.parse(trimmed);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
361
|
+
return null;
|
|
362
|
+
return new Date(ms).toISOString();
|
|
363
|
+
}
|
|
327
364
|
export function quotaFieldsFromOutput(text) {
|
|
328
365
|
const lines = text.split("\n");
|
|
329
366
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
@@ -339,10 +376,12 @@ export function quotaFieldsFromOutput(text) {
|
|
|
339
376
|
}
|
|
340
377
|
if (!isPlainObject(parsed) || parsed.type !== "result")
|
|
341
378
|
continue;
|
|
379
|
+
const resets_at = structuredResetFrom(parsed);
|
|
342
380
|
return {
|
|
343
381
|
api_error_status: parsed.api_error_status,
|
|
344
382
|
is_error: parsed.is_error,
|
|
345
383
|
result: parsed.result,
|
|
384
|
+
...(resets_at !== undefined ? { resets_at } : {}),
|
|
346
385
|
};
|
|
347
386
|
}
|
|
348
387
|
return null;
|
|
@@ -362,7 +401,8 @@ export function quotaSignalFromFields(fields, model, now) {
|
|
|
362
401
|
marker,
|
|
363
402
|
};
|
|
364
403
|
}
|
|
365
|
-
const
|
|
404
|
+
const structured = normalizeStructuredReset(fields.resets_at) ?? undefined;
|
|
405
|
+
const quota_reset_at = structured ?? (text.length > 0 ? parseProviderResetTime(text, now) ?? undefined : undefined);
|
|
366
406
|
return {
|
|
367
407
|
reason: "account_quota_exhausted",
|
|
368
408
|
quota_reset_at,
|
|
@@ -12,6 +12,8 @@ export interface EnsureSessionHomeResult extends SessionCopyResult {
|
|
|
12
12
|
source_home: string;
|
|
13
13
|
transcript_path: string;
|
|
14
14
|
homes_searched: string[];
|
|
15
|
+
/** Set when the transcript was found outside the expected project directory. */
|
|
16
|
+
found_outside_cwd?: true;
|
|
15
17
|
}
|
|
16
18
|
/**
|
|
17
19
|
* Older Claude account homes can share a registered `account-N/.claude`
|
|
@@ -117,8 +117,120 @@ function uniqueHomes(homes) {
|
|
|
117
117
|
}
|
|
118
118
|
return [...unique];
|
|
119
119
|
}
|
|
120
|
-
function missingTranscript(input, homes) {
|
|
121
|
-
return new LanesError("provider_transcript_missing", `Provider transcript for session ${input.sessionId} was not found. Homes searched: ${homes.join(", ")}
|
|
120
|
+
function missingTranscript(input, homes, dirsScanned) {
|
|
121
|
+
return new LanesError("provider_transcript_missing", `Provider transcript for session ${input.sessionId} was not found. Homes searched: ${homes.join(", ")}. Directories scanned: ${dirsScanned.length > 0 ? dirsScanned.join(", ") : "none"}`, {
|
|
122
|
+
provider: input.provider,
|
|
123
|
+
session_id: input.sessionId,
|
|
124
|
+
homes_searched: homes,
|
|
125
|
+
directories_scanned: dirsScanned,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Every `<home>/projects/<dir>/` that could hold a claude transcript. A lane
|
|
130
|
+
* can record one cwd and have run in another (a worktree), so a resume looks
|
|
131
|
+
* in every project directory before refusing.
|
|
132
|
+
*/
|
|
133
|
+
function claudeProjectDirs(home) {
|
|
134
|
+
const root = path.join(home, "projects");
|
|
135
|
+
let entries;
|
|
136
|
+
try {
|
|
137
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const code = err.code;
|
|
141
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
142
|
+
return [];
|
|
143
|
+
throw new LanesError("provider_home_search_failed", `Could not search provider home ${home}: ${err instanceof Error ? err.message : String(err)}`, { provider: "claude", home });
|
|
144
|
+
}
|
|
145
|
+
return entries
|
|
146
|
+
.filter((entry) => entry.isDirectory())
|
|
147
|
+
.map((entry) => path.join(root, entry.name))
|
|
148
|
+
.sort();
|
|
149
|
+
}
|
|
150
|
+
function isFile(target) {
|
|
151
|
+
try {
|
|
152
|
+
return fs.statSync(target).isFile();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
const code = err.code;
|
|
156
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
157
|
+
return false;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Last resort for a claude session that is not under the expected project
|
|
163
|
+
* directory in any home: scan every project directory of every searched home,
|
|
164
|
+
* then copy the transcript (and its sibling subagent directory) into the
|
|
165
|
+
* target home under the project directory for `input.cwd`, which is where
|
|
166
|
+
* `claude --resume` started in that cwd looks for it.
|
|
167
|
+
*/
|
|
168
|
+
function relinkFromAnyProject(input, targetHome, searched) {
|
|
169
|
+
const scanned = [];
|
|
170
|
+
if (input.provider !== "claude") {
|
|
171
|
+
for (const home of searched)
|
|
172
|
+
scanned.push(path.join(home, "sessions"));
|
|
173
|
+
throw missingTranscript(input, searched, scanned);
|
|
174
|
+
}
|
|
175
|
+
const matches = [];
|
|
176
|
+
for (const home of searched) {
|
|
177
|
+
const found = [];
|
|
178
|
+
const dirs = claudeProjectDirs(home);
|
|
179
|
+
if (dirs.length === 0)
|
|
180
|
+
scanned.push(path.join(home, "projects"));
|
|
181
|
+
for (const dir of dirs) {
|
|
182
|
+
scanned.push(dir);
|
|
183
|
+
const candidate = path.join(dir, `${input.sessionId}.jsonl`);
|
|
184
|
+
if (isFile(candidate))
|
|
185
|
+
found.push(candidate);
|
|
186
|
+
}
|
|
187
|
+
if (found.length > 1) {
|
|
188
|
+
throw new LanesError("provider_transcript_ambiguous", `Multiple claude transcripts for session ${input.sessionId} exist in ${home}`, { provider: input.provider, session_id: input.sessionId, homes_searched: searched, matches: found });
|
|
189
|
+
}
|
|
190
|
+
if (found.length === 1)
|
|
191
|
+
matches.push({ home, source: found[0] });
|
|
192
|
+
}
|
|
193
|
+
if (matches.length === 0)
|
|
194
|
+
throw missingTranscript(input, searched, scanned);
|
|
195
|
+
// The last-run home holds the newest copy; any other home may hold a stale
|
|
196
|
+
// copy from an earlier account move, so more than one is ambiguous.
|
|
197
|
+
const lastRunHome = input.providerHomeAtSpawn ? path.resolve(input.providerHomeAtSpawn) : undefined;
|
|
198
|
+
const chosen = matches.find((match) => match.home === lastRunHome) ??
|
|
199
|
+
(matches.length === 1 ? matches[0] : undefined);
|
|
200
|
+
if (chosen === undefined) {
|
|
201
|
+
throw new LanesError("provider_transcript_ambiguous", `Provider transcript for session ${input.sessionId} exists in multiple homes: ${matches.map((match) => match.home).join(", ")}`, {
|
|
202
|
+
provider: input.provider,
|
|
203
|
+
session_id: input.sessionId,
|
|
204
|
+
homes_searched: searched,
|
|
205
|
+
matching_homes: matches.map((match) => match.home),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
{
|
|
209
|
+
const home = chosen.home;
|
|
210
|
+
const source = chosen.source;
|
|
211
|
+
const targetDir = path.join(targetHome, "projects", encodeClaudeProjectDir(input.cwd));
|
|
212
|
+
const target = path.join(targetDir, `${input.sessionId}.jsonl`);
|
|
213
|
+
const subagents = path.join(path.dirname(source), input.sessionId);
|
|
214
|
+
const hasSubagents = statDirectory(subagents);
|
|
215
|
+
const plan = {
|
|
216
|
+
provider: "claude",
|
|
217
|
+
sources: [source],
|
|
218
|
+
targets: [target],
|
|
219
|
+
source_dir: hasSubagents ? subagents : null,
|
|
220
|
+
target_dir: hasSubagents ? path.join(targetDir, input.sessionId) : null,
|
|
221
|
+
};
|
|
222
|
+
const copied = copySessionFiles(plan);
|
|
223
|
+
if (!isFile(target)) {
|
|
224
|
+
throw new LanesError("provider_transcript_relink_failed", `Could not verify session ${input.sessionId} in target home ${targetHome}`, { provider: input.provider, session_id: input.sessionId, target_home: targetHome });
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
...copied,
|
|
228
|
+
source_home: home,
|
|
229
|
+
transcript_path: target,
|
|
230
|
+
homes_searched: searched,
|
|
231
|
+
found_outside_cwd: true,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
122
234
|
}
|
|
123
235
|
function copyVerifiedTranscript(input, sourceHome, targetHome, searched) {
|
|
124
236
|
let copied;
|
|
@@ -132,7 +244,7 @@ function copyVerifiedTranscript(input, sourceHome, targetHome, searched) {
|
|
|
132
244
|
}
|
|
133
245
|
catch (err) {
|
|
134
246
|
if (err instanceof LanesError && err.error === "provider_session_missing") {
|
|
135
|
-
|
|
247
|
+
return relinkFromAnyProject(input, targetHome, searched);
|
|
136
248
|
}
|
|
137
249
|
throw err;
|
|
138
250
|
}
|
|
@@ -202,7 +314,7 @@ export function ensureSessionHome(input) {
|
|
|
202
314
|
matches.push({ home, transcript: found[0] });
|
|
203
315
|
}
|
|
204
316
|
if (matches.length === 0)
|
|
205
|
-
|
|
317
|
+
return relinkFromAnyProject(input, targetHome, searched);
|
|
206
318
|
if (matches.length > 1) {
|
|
207
319
|
throw new LanesError("provider_transcript_ambiguous", `Provider transcript for session ${input.sessionId} exists in multiple homes: ${matches.map((match) => match.home).join(", ")}`, {
|
|
208
320
|
provider: input.provider,
|
|
@@ -255,7 +255,7 @@ export declare function buildProviderCommand(provider: LaneProvider, input: {
|
|
|
255
255
|
export declare function workerEnv(base: NodeJS.ProcessEnv, resolved: {
|
|
256
256
|
envVar: string;
|
|
257
257
|
home: string;
|
|
258
|
-
}, laneId: string, hqRoot: string, autoCompactTokens?: number, meshCompany?: LaneCompanyRef, workerPolicyDir?: string, registryWorker?: LaneRecord["registry_worker"]): NodeJS.ProcessEnv;
|
|
258
|
+
}, laneId: string, hqRoot: string, autoCompactTokens?: number, meshCompany?: LaneCompanyRef, workerPolicyDir?: string, registryWorker?: LaneRecord["registry_worker"], homedir?: () => string): NodeJS.ProcessEnv;
|
|
259
259
|
export declare const WORKER_POLICY_HOOK_CAPABILITY = "# hq-capability: worker-policy-dir v1";
|
|
260
260
|
/** Grok does not run HQ hooks; Claude and Codex use the installed capability marker. */
|
|
261
261
|
export declare function workerPolicyDeliveryMode(hqRoot: string, provider: LaneProvider): "hook" | "prompt";
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -13,7 +13,7 @@ import * as fs from "node:fs";
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { configureRemoteControlChildEnv, prepareRemoteControlLane, remoteControlSelectionConfig } from "./remote-control-lane.js";
|
|
16
|
-
import { PROVIDER_HOME_ENV, accountStatus, isLoggedOut, resolveAccount, selectBalancedAccount, } from "./accounts.js";
|
|
16
|
+
import { PROVIDER_HOME_ENV, accountStatus, isDefaultProviderHome, isLoggedOut, resolveAccount, selectBalancedAccount, } from "./accounts.js";
|
|
17
17
|
import { effectiveAccountHome } from "./account-home.js";
|
|
18
18
|
import { buildHostCapacitySnapshot } from "./host-capacity.js";
|
|
19
19
|
import { selectClaudeAccount } from "./claude-balance.js";
|
|
@@ -1661,7 +1661,7 @@ export function buildProviderCommand(provider, input) {
|
|
|
1661
1661
|
args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, appendCodexWorkerPolicyDirFlag(parsed, appendProviderProfileFlags(parsed, args, input.profile), input.workerPolicyDir), input.model), input.effort), input.fastMode),
|
|
1662
1662
|
};
|
|
1663
1663
|
}
|
|
1664
|
-
export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir, registryWorker) {
|
|
1664
|
+
export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir, registryWorker, homedir) {
|
|
1665
1665
|
const accepted = Object.values(PROVIDER_HOME_ENV);
|
|
1666
1666
|
if (!accepted.includes(resolved.envVar)) {
|
|
1667
1667
|
throw new LanesError("unknown_provider_home_env", `Unknown provider home variable ${JSON.stringify(resolved.envVar)}. Accepted: ${accepted.join(", ")}`, { accepted, value: resolved.envVar });
|
|
@@ -1672,7 +1672,16 @@ export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, mes
|
|
|
1672
1672
|
delete env.HQ_POLICY_WORKER_DIR;
|
|
1673
1673
|
delete env.HQ_LANE_APPROVAL_REQUIRED;
|
|
1674
1674
|
delete env.HQ_LANE_HUMAN_CHECKPOINTS;
|
|
1675
|
-
|
|
1675
|
+
if (resolved.envVar === PROVIDER_HOME_ENV.claude &&
|
|
1676
|
+
isDefaultProviderHome("claude", resolved.home, homedir)) {
|
|
1677
|
+
// The default ~/.claude home runs with CLAUDE_CONFIG_DIR unset so the
|
|
1678
|
+
// worker reads the same keychain credential as the interactive session
|
|
1679
|
+
// (see isDefaultProviderHome). An inherited value is dropped too.
|
|
1680
|
+
delete env[resolved.envVar];
|
|
1681
|
+
}
|
|
1682
|
+
else {
|
|
1683
|
+
env[resolved.envVar] = resolved.home;
|
|
1684
|
+
}
|
|
1676
1685
|
delete env[CLAUDE_AUTO_COMPACT_ENV];
|
|
1677
1686
|
if (resolved.envVar === PROVIDER_HOME_ENV.claude && autoCompactTokens !== undefined) {
|
|
1678
1687
|
env[CLAUDE_AUTO_COMPACT_ENV] = String(autoCompactTokens);
|
|
@@ -2075,6 +2084,14 @@ export function readSpawnManifest(runDir) {
|
|
|
2075
2084
|
}
|
|
2076
2085
|
return out;
|
|
2077
2086
|
}
|
|
2087
|
+
function isExistingDirectory(target) {
|
|
2088
|
+
try {
|
|
2089
|
+
return fs.statSync(target).isDirectory();
|
|
2090
|
+
}
|
|
2091
|
+
catch {
|
|
2092
|
+
return false;
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2078
2095
|
function laneLaunchResult(lane) {
|
|
2079
2096
|
const { provider_home_at_spawn: _providerHomeAtSpawn, ...safe } = lane;
|
|
2080
2097
|
return safe;
|
|
@@ -2327,6 +2344,17 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2327
2344
|
: existing;
|
|
2328
2345
|
throw new LanesError("account_not_logged_in", `Account ${resolved.id} for ${lane.provider} is not logged in (no credential file)`, { provider: lane.provider, account: resolved.id, lane_id: lane.lane_id, lane: failed });
|
|
2329
2346
|
}
|
|
2347
|
+
// The worker starts here, and claude keys its transcript by this directory,
|
|
2348
|
+
// so the transcript checks below use the same value. A resume reuses the
|
|
2349
|
+
// directory the last run started in (a worktree for pipeline lanes) when it
|
|
2350
|
+
// still exists.
|
|
2351
|
+
const recordedWorkingDirectory = plan.mode === "resume" &&
|
|
2352
|
+
typeof existing.working_directory_at_spawn === "string" &&
|
|
2353
|
+
existing.working_directory_at_spawn.trim() !== "" &&
|
|
2354
|
+
isExistingDirectory(existing.working_directory_at_spawn)
|
|
2355
|
+
? existing.working_directory_at_spawn
|
|
2356
|
+
: undefined;
|
|
2357
|
+
const workerCwd = deps.workingDirectory ?? pendingResume?.working_directory ?? recordedWorkingDirectory ?? hqRoot;
|
|
2330
2358
|
if (existing.account !== resolved.id) {
|
|
2331
2359
|
const source = existing.account
|
|
2332
2360
|
? config.providers[existing.provider].accounts[existing.account]
|
|
@@ -2335,12 +2363,22 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2335
2363
|
source &&
|
|
2336
2364
|
existing.provider_session_id &&
|
|
2337
2365
|
providerSupportsSessionResume(existing.provider)) {
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2366
|
+
try {
|
|
2367
|
+
copySessionFiles(planSessionCopy(existing.provider, {
|
|
2368
|
+
sourceHome: existing.provider_home_at_spawn ?? effectiveAccountHome(source, hostname).home,
|
|
2369
|
+
targetHome: resolved.home,
|
|
2370
|
+
cwd: workerCwd,
|
|
2371
|
+
sessionId: existing.provider_session_id,
|
|
2372
|
+
}));
|
|
2373
|
+
}
|
|
2374
|
+
catch (err) {
|
|
2375
|
+
// A resume below searches every home and project directory for the
|
|
2376
|
+
// session, so a transcript under another cwd is handled there.
|
|
2377
|
+
const resumeFollows = plan.mode === "resume" && existing.provider !== "grok";
|
|
2378
|
+
if (!(resumeFollows && err instanceof LanesError && err.error === "provider_session_missing")) {
|
|
2379
|
+
throw err;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2344
2382
|
}
|
|
2345
2383
|
}
|
|
2346
2384
|
if (plan.mode === "resume" && existing.provider !== "grok") {
|
|
@@ -2350,7 +2388,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2350
2388
|
ensureSessionHome({
|
|
2351
2389
|
provider: existing.provider,
|
|
2352
2390
|
sessionId: plan.session_id,
|
|
2353
|
-
cwd:
|
|
2391
|
+
cwd: workerCwd,
|
|
2354
2392
|
targetHome,
|
|
2355
2393
|
registeredHomes: Object.values(config.providers[existing.provider].accounts)
|
|
2356
2394
|
.map((account) => effectiveAccountHome(account, hostname).home),
|
|
@@ -2644,6 +2682,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2644
2682
|
pending_settings: undefined,
|
|
2645
2683
|
account: resolved.id,
|
|
2646
2684
|
provider_home_at_spawn: remoteControlHome ?? resolved.home,
|
|
2685
|
+
working_directory_at_spawn: workerCwd,
|
|
2647
2686
|
...(model !== undefined ? { model } : {}),
|
|
2648
2687
|
...(model !== undefined
|
|
2649
2688
|
? {
|
|
@@ -2789,7 +2828,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2789
2828
|
let watcherPid;
|
|
2790
2829
|
try {
|
|
2791
2830
|
noteCapacityAdvisory(lane, env);
|
|
2792
|
-
workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv,
|
|
2831
|
+
workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, workerCwd, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
|
|
2793
2832
|
writePidRecord(path.join(runDir, "worker.pid"), {
|
|
2794
2833
|
pid: workerPid,
|
|
2795
2834
|
starttime: readProcStarttime(workerPid) ?? undefined,
|
|
@@ -3095,12 +3134,19 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
|
|
|
3095
3134
|
? { resume: { session_id: sessionId, prompt: repairPrompt } }
|
|
3096
3135
|
: { freshTurnPrompt: repairPrompt }),
|
|
3097
3136
|
});
|
|
3137
|
+
// The repair turn resumes the run's session, which claude keys by the
|
|
3138
|
+
// directory that run started in.
|
|
3139
|
+
const repairCwd = mode === "resume" &&
|
|
3140
|
+
typeof lane.working_directory_at_spawn === "string" &&
|
|
3141
|
+
isExistingDirectory(lane.working_directory_at_spawn)
|
|
3142
|
+
? lane.working_directory_at_spawn
|
|
3143
|
+
: hqRoot;
|
|
3098
3144
|
const workerOutOffset = fileSizeOrZero(path.join(logsDir, "worker.out"));
|
|
3099
3145
|
const previous = readSpawnManifest(runDir);
|
|
3100
3146
|
let workerPid;
|
|
3101
3147
|
try {
|
|
3102
3148
|
noteCapacityAdvisory(lane, env);
|
|
3103
|
-
workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv,
|
|
3149
|
+
workerPid = spawnTrackedWorker(spawnProcess, command.command, command.args, childEnv, repairCwd, path.join(logsDir, "worker.out"), path.join(logsDir, "worker.err"), path.join(logsDir, "worker-exit.json"));
|
|
3104
3150
|
writePidRecord(path.join(runDir, "worker.pid"), {
|
|
3105
3151
|
pid: workerPid,
|
|
3106
3152
|
starttime: readProcStarttime(workerPid) ?? undefined,
|
|
@@ -772,6 +772,11 @@ export interface LaneRecord {
|
|
|
772
772
|
provider_session_id_absent?: string;
|
|
773
773
|
/** Provider home used by the most recently spawned run, for transcript recovery. */
|
|
774
774
|
provider_home_at_spawn?: string;
|
|
775
|
+
/**
|
|
776
|
+
* Working directory the most recently spawned run started in. Claude keys
|
|
777
|
+
* its transcript by this directory, so a later resume starts there too.
|
|
778
|
+
*/
|
|
779
|
+
working_directory_at_spawn?: string;
|
|
775
780
|
/** Set at spawn when this run continued a recorded session instead of starting fresh. */
|
|
776
781
|
last_resume?: LaneResumeRecord;
|
|
777
782
|
/** Last 50 lane worker starts after the initial start, including actor provenance. */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -242,7 +242,7 @@ export const LANE_FIELD_WRITERS = {
|
|
|
242
242
|
overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
|
|
243
243
|
admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command", "reconcile"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile", "spawn"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
|
|
244
244
|
quota_reset_at: ["watcher", "reconcile", "spawn", "command"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
|
|
245
|
-
provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], resume_history: ["spawn", "watcher"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
|
|
245
|
+
provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], working_directory_at_spawn: ["spawn"], last_resume: ["spawn"], resume_history: ["spawn", "watcher"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
|
|
246
246
|
};
|
|
247
247
|
function isPlainObject(value) {
|
|
248
248
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|