@indigoai-us/hq-cli 5.345.123 → 5.345.124
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 +14 -0
- package/assets/scaffold/core/scripts/work-mesh-project-registration-offer.sh +77 -0
- package/assets/scaffold/core/scripts/workflow-gate.sh +247 -0
- package/dist/command-catalog.generated.d.ts +239 -0
- package/dist/command-catalog.generated.js +291 -0
- package/dist/commands/core-bucket-c-work-mesh-offer-native.d.ts +7 -0
- package/dist/commands/core-bucket-c-work-mesh-offer-native.js +147 -0
- package/dist/commands/core-bucket-c-workflow-gate-fast.d.ts +3 -0
- package/dist/commands/core-bucket-c-workflow-gate-fast.js +17 -0
- package/dist/commands/core-bucket-c-workflow-gate-native.d.ts +7 -0
- package/dist/commands/core-bucket-c-workflow-gate-native.js +250 -0
- package/dist/commands/core.js +12 -0
- package/dist/commands/mesh.js +457 -16
- package/dist/fast-scaffold.js +26 -0
- package/dist/lib/lanes/quota-notice.d.ts +24 -0
- package/dist/lib/lanes/quota-notice.js +61 -0
- package/dist/lib/lanes/quota.d.ts +44 -8
- package/dist/lib/lanes/quota.js +187 -45
- package/dist/lib/lanes/reconcile.js +17 -1
- package/dist/lib/lanes/watcher.js +13 -1
- package/dist/lib/mesh/api.d.ts +2 -0
- package/dist/lib/mesh/api.js +3 -0
- package/dist/lib/mesh/client.d.ts +6 -1
- package/dist/lib/mesh/client.js +18 -5
- package/dist/lib/mesh/team-coordination.d.ts +106 -0
- package/dist/lib/mesh/team-coordination.js +297 -0
- package/dist/lib/work-context/auto-bind.d.ts +75 -0
- package/dist/lib/work-context/auto-bind.js +245 -0
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell a lane's senior when a provider 429 put an account on a quota hold.
|
|
3
|
+
*
|
|
4
|
+
* An account-wide hold stops every lane on that account, so the senior needs
|
|
5
|
+
* to see it with the provider's own words and the command that lifts it. One
|
|
6
|
+
* message per (provider, account, hold), so several lanes failing on the same
|
|
7
|
+
* hold produce one notice.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { monitorSessionDir } from "../monitor.js";
|
|
12
|
+
import { appendDropboxMessageOnce, appendDropboxMessageOnceAtPath } from "./dropbox.js";
|
|
13
|
+
import { LANE_PROVIDERS } from "./types.js";
|
|
14
|
+
/** Build the notice for the account's current hold, or null when there is none. */
|
|
15
|
+
export function quotaHoldNotice(state, provider, accountId, laneId) {
|
|
16
|
+
const account = state.providers[provider]?.[accountId];
|
|
17
|
+
const holdUntil = account?.all_models_quota_reset_at;
|
|
18
|
+
if (!account || !holdUntil)
|
|
19
|
+
return null;
|
|
20
|
+
const said = account.provider_text ? `Provider said: "${account.provider_text}".` : "Provider gave no message text.";
|
|
21
|
+
const clear = `If the account is not actually exhausted (check /usage), clear the hold with: hq lanes accounts clear-quota ${provider} ${accountId}`;
|
|
22
|
+
const lines = [
|
|
23
|
+
`Quota hold: ${provider} account ${accountId} is held until ${holdUntil}; no lane will run on it until then. Set by lane ${laneId}.`,
|
|
24
|
+
said,
|
|
25
|
+
];
|
|
26
|
+
if (account.reset_source === "unconfirmed" && account.unconfirmed_reset_at) {
|
|
27
|
+
lines.push(`The provider named a reset at ${account.unconfirmed_reset_at}. That is more than a day away, so this is a short hold; the account gets the full hold only if the next lane after it also gets a 429.`);
|
|
28
|
+
}
|
|
29
|
+
else if (account.reset_source === "derived") {
|
|
30
|
+
lines.push("The provider named no reset time, so this is the default hold.");
|
|
31
|
+
}
|
|
32
|
+
lines.push(clear);
|
|
33
|
+
return {
|
|
34
|
+
message_id: `quota-hold:${provider}:${accountId}:${holdUntil}`,
|
|
35
|
+
text: lines.join("\n"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Deliver the notice to the lane's senior inbox. A session senior gets it in
|
|
40
|
+
* its monitor-session inbox, a lane senior in that lane's dropbox. The notice
|
|
41
|
+
* is always written to stderr as well, so a senior with no inbox still has it
|
|
42
|
+
* in the watcher log. Returns where it was delivered.
|
|
43
|
+
*/
|
|
44
|
+
export function deliverQuotaHoldNotice(hqRoot, lane, notice, at) {
|
|
45
|
+
process.stderr.write(`hq lanes: ${notice.text.replace(/\n/g, " ")}\n`);
|
|
46
|
+
const message = { at, message_id: notice.message_id, kind: "quota_hold", text: notice.text };
|
|
47
|
+
if (lane.senior.kind === "lane" && lane.senior.id) {
|
|
48
|
+
appendDropboxMessageOnce(hqRoot, lane.senior.id, message);
|
|
49
|
+
return "lane_inbox";
|
|
50
|
+
}
|
|
51
|
+
if (lane.senior.kind === "session" && lane.senior.id) {
|
|
52
|
+
const sessionId = lane.senior.id;
|
|
53
|
+
const dirs = LANE_PROVIDERS.map((p) => monitorSessionDir(hqRoot, p, sessionId)).filter((dir) => fs.existsSync(dir));
|
|
54
|
+
if (dirs.length === 1) {
|
|
55
|
+
appendDropboxMessageOnceAtPath(path.join(dirs[0], "dropbox"), message);
|
|
56
|
+
return "session_inbox";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return "stderr_only";
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=quota-notice.js.map
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
* claude-fable-5-1 and still runs claude-opus-5). The record is therefore
|
|
17
17
|
* per (account, model), plus an account-wide reset when the text is a
|
|
18
18
|
* usage limit rather than a switch-model refusal.
|
|
19
|
+
*
|
|
20
|
+
* A single 429 is not trusted with a multi-day hold. When the provider names
|
|
21
|
+
* a reset more than a day away, the first 429 holds the account only for the
|
|
22
|
+
* short default hold and keeps the named reset as `unconfirmed_reset_at`. A
|
|
23
|
+
* second 429 from a lane that ran after that short hold expired confirms the
|
|
24
|
+
* reset; a successful turn drops it. (Measured 2026-10-13: account a returned
|
|
25
|
+
* "You've hit your weekly limit · resets Oct 16 at 1am (America/Denver)"
|
|
26
|
+
* while the owner's /usage showed the week at 43%.)
|
|
19
27
|
*/
|
|
20
28
|
import type { LaneProvider } from "./types.js";
|
|
21
29
|
export declare const QUOTA_STATE_REL: string;
|
|
@@ -26,7 +34,14 @@ export interface ModelQuota {
|
|
|
26
34
|
/** Operator-facing marker, e.g. "needs credits for claude-fable-5-1". */
|
|
27
35
|
marker?: string;
|
|
28
36
|
}
|
|
29
|
-
|
|
37
|
+
/**
|
|
38
|
+
* `parsed`: the provider's own reset. `derived`: the default hold for a 429
|
|
39
|
+
* with no parseable reset. `unconfirmed`: a short hold standing in for a
|
|
40
|
+
* provider reset more than a day away that a second 429 has not confirmed.
|
|
41
|
+
*/
|
|
42
|
+
export type QuotaResetSource = "parsed" | "derived" | "unconfirmed";
|
|
43
|
+
/** A provider reset further away than this needs a second 429 before it holds. */
|
|
44
|
+
export declare const QUOTA_CONFIRM_HORIZON_MS: number;
|
|
30
45
|
export interface AccountQuota {
|
|
31
46
|
/** Set when the 429 is an account-wide usage limit with a reset time. */
|
|
32
47
|
all_models_quota_reset_at?: string;
|
|
@@ -35,6 +50,13 @@ export interface AccountQuota {
|
|
|
35
50
|
* the configured default hold applied to a 429 with no parseable reset.
|
|
36
51
|
*/
|
|
37
52
|
reset_source?: QuotaResetSource;
|
|
53
|
+
/**
|
|
54
|
+
* The provider's named reset, held back until a second 429 on a fresh
|
|
55
|
+
* probe confirms it. Set only while `reset_source` is `unconfirmed`.
|
|
56
|
+
*/
|
|
57
|
+
unconfirmed_reset_at?: string;
|
|
58
|
+
/** Raw provider text from the 429 that set the current hold. */
|
|
59
|
+
provider_text?: string;
|
|
38
60
|
models: Record<string, ModelQuota>;
|
|
39
61
|
}
|
|
40
62
|
export interface QuotaState {
|
|
@@ -51,6 +73,8 @@ export interface QuotaSignal {
|
|
|
51
73
|
account_wide?: boolean;
|
|
52
74
|
/** Set by applyDefaultQuotaHold: whether the reset is the provider's or the default hold. */
|
|
53
75
|
reset_source?: QuotaResetSource;
|
|
76
|
+
/** The provider's raw result text, kept so the hold can be shown to the senior. */
|
|
77
|
+
provider_text?: string;
|
|
54
78
|
}
|
|
55
79
|
/**
|
|
56
80
|
* A 429 whose message names no reset time and no credits wording would
|
|
@@ -64,12 +88,18 @@ export declare function applyDefaultQuotaHold(signal: QuotaSignal, defaultHoldMs
|
|
|
64
88
|
export declare function quotaStatePath(hqRoot: string): string;
|
|
65
89
|
export declare function loadQuotaState(hqRoot: string): QuotaState;
|
|
66
90
|
/**
|
|
67
|
-
* Parse provider reset times
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
91
|
+
* Parse provider reset times into an ISO timestamp. Handled forms:
|
|
92
|
+
*
|
|
93
|
+
* - "resets 2am (UTC)" and "resets 1am (America/Denver)": the next such time.
|
|
94
|
+
* - "resets Sep 24, 2am (UTC)", "resets Oct 16 at 1am (America/Denver)",
|
|
95
|
+
* "resets Sep 24, 2026 2am (UTC)": a dated reset.
|
|
96
|
+
* - Codex's "try again at Sep 14th, 2026 9:24 PM", shown in the provider
|
|
97
|
+
* CLI's local timezone unless it names `(UTC)`.
|
|
98
|
+
*
|
|
99
|
+
* The zone is `UTC` or an IANA name; an unknown zone name fails the parse. A
|
|
100
|
+
* dated provider reset with no zone is read as UTC. The year is the zone's
|
|
101
|
+
* current year for an unyear-tagged reset, rolled forward when it is already
|
|
102
|
+
* past. An explicit provider year is kept as stated.
|
|
73
103
|
*/
|
|
74
104
|
export declare function parseProviderResetTime(text: string, now: Date): string | null;
|
|
75
105
|
/**
|
|
@@ -88,7 +118,13 @@ export declare function quotaSignalFromFields(fields: {
|
|
|
88
118
|
result: unknown;
|
|
89
119
|
}, model: string | undefined, now: Date): QuotaSignal | null;
|
|
90
120
|
export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
|
|
91
|
-
export
|
|
121
|
+
export interface RecordAccountQuotaOptions {
|
|
122
|
+
/** When the 429 was observed. Enables the far-reset confirmation gate. */
|
|
123
|
+
now: Date;
|
|
124
|
+
/** Hold applied while a far provider reset is unconfirmed (the default hold). */
|
|
125
|
+
unconfirmedHoldMs: number;
|
|
126
|
+
}
|
|
127
|
+
export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, incoming: QuotaSignal, options?: RecordAccountQuotaOptions): QuotaState;
|
|
92
128
|
export interface ClearedModelQuotaReset {
|
|
93
129
|
model: string;
|
|
94
130
|
quota_reset_at: string;
|
package/dist/lib/lanes/quota.js
CHANGED
|
@@ -16,12 +16,22 @@
|
|
|
16
16
|
* claude-fable-5-1 and still runs claude-opus-5). The record is therefore
|
|
17
17
|
* per (account, model), plus an account-wide reset when the text is a
|
|
18
18
|
* usage limit rather than a switch-model refusal.
|
|
19
|
+
*
|
|
20
|
+
* A single 429 is not trusted with a multi-day hold. When the provider names
|
|
21
|
+
* a reset more than a day away, the first 429 holds the account only for the
|
|
22
|
+
* short default hold and keeps the named reset as `unconfirmed_reset_at`. A
|
|
23
|
+
* second 429 from a lane that ran after that short hold expired confirms the
|
|
24
|
+
* reset; a successful turn drops it. (Measured 2026-10-13: account a returned
|
|
25
|
+
* "You've hit your weekly limit · resets Oct 16 at 1am (America/Denver)"
|
|
26
|
+
* while the owner's /usage showed the week at 43%.)
|
|
19
27
|
*/
|
|
20
28
|
import * as fs from "node:fs";
|
|
21
29
|
import * as path from "node:path";
|
|
22
30
|
import { withLaneRecordFileLock } from "./store.js";
|
|
23
31
|
export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
|
|
24
32
|
export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
|
|
33
|
+
/** A provider reset further away than this needs a second 429 before it holds. */
|
|
34
|
+
export const QUOTA_CONFIRM_HORIZON_MS = 24 * 60 * 60 * 1000;
|
|
25
35
|
/**
|
|
26
36
|
* A 429 whose message names no reset time and no credits wording would
|
|
27
37
|
* otherwise record no hold at all, and the next lane on the account would
|
|
@@ -127,6 +137,15 @@ export function loadQuotaState(hqRoot) {
|
|
|
127
137
|
else if (raw.reset_source === "default_hold" || raw.reset_source === "derived") {
|
|
128
138
|
account.reset_source = "derived";
|
|
129
139
|
}
|
|
140
|
+
else if (raw.reset_source === "unconfirmed") {
|
|
141
|
+
account.reset_source = "unconfirmed";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (typeof raw.unconfirmed_reset_at === "string" && raw.unconfirmed_reset_at.length > 0) {
|
|
145
|
+
account.unconfirmed_reset_at = raw.unconfirmed_reset_at;
|
|
146
|
+
}
|
|
147
|
+
if (typeof raw.provider_text === "string" && raw.provider_text.length > 0) {
|
|
148
|
+
account.provider_text = raw.provider_text;
|
|
130
149
|
}
|
|
131
150
|
accounts[id] = account;
|
|
132
151
|
}
|
|
@@ -141,37 +160,113 @@ function writeQuotaState(hqRoot, state) {
|
|
|
141
160
|
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
142
161
|
fs.renameSync(tmp, filePath);
|
|
143
162
|
}
|
|
163
|
+
/** True when `zone` is a timezone name the runtime's Intl data knows. */
|
|
164
|
+
function isKnownTimeZone(zone) {
|
|
165
|
+
try {
|
|
166
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
if (err instanceof RangeError)
|
|
171
|
+
return false;
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Offset of `zone` from UTC at instant `ms`, in milliseconds. */
|
|
176
|
+
function zoneOffsetMs(zone, ms) {
|
|
177
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
178
|
+
timeZone: zone,
|
|
179
|
+
hourCycle: "h23",
|
|
180
|
+
year: "numeric",
|
|
181
|
+
month: "numeric",
|
|
182
|
+
day: "numeric",
|
|
183
|
+
hour: "numeric",
|
|
184
|
+
minute: "numeric",
|
|
185
|
+
second: "numeric",
|
|
186
|
+
}).formatToParts(new Date(ms));
|
|
187
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? 0);
|
|
188
|
+
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second"));
|
|
189
|
+
return asUtc - Math.floor(ms / 1000) * 1000;
|
|
190
|
+
}
|
|
144
191
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
192
|
+
* The UTC instant of a wall-clock time in `zone`, or null when that wall time
|
|
193
|
+
* does not exist there (a spring-forward gap) or a field is out of range.
|
|
194
|
+
*/
|
|
195
|
+
function zonedWallTimeToUtc(zone, year, month, day, hour, minute) {
|
|
196
|
+
const wall = Date.UTC(year, month, day, hour, minute, 0, 0);
|
|
197
|
+
const check = new Date(wall);
|
|
198
|
+
if (check.getUTCFullYear() !== year ||
|
|
199
|
+
check.getUTCMonth() !== month ||
|
|
200
|
+
check.getUTCDate() !== day ||
|
|
201
|
+
check.getUTCHours() !== hour ||
|
|
202
|
+
check.getUTCMinutes() !== minute) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (zone.toUpperCase() === "UTC")
|
|
206
|
+
return wall;
|
|
207
|
+
let guess = wall - zoneOffsetMs(zone, wall);
|
|
208
|
+
guess = wall - zoneOffsetMs(zone, guess);
|
|
209
|
+
return guess + zoneOffsetMs(zone, guess) === wall ? guess : null;
|
|
210
|
+
}
|
|
211
|
+
function zonedDateParts(zone, ms) {
|
|
212
|
+
const offset = zone.toUpperCase() === "UTC" ? 0 : zoneOffsetMs(zone, ms);
|
|
213
|
+
const local = new Date(ms + offset);
|
|
214
|
+
return { year: local.getUTCFullYear(), month: local.getUTCMonth(), day: local.getUTCDate() };
|
|
215
|
+
}
|
|
216
|
+
function to24Hour(hourText, minuteText, meridiem) {
|
|
217
|
+
let hour = Number(hourText);
|
|
218
|
+
const minute = Number(minuteText ?? 0);
|
|
219
|
+
if (!Number.isInteger(hour) || hour < 1 || hour > 12 || !Number.isInteger(minute) || minute > 59)
|
|
220
|
+
return null;
|
|
221
|
+
const ap = meridiem.toLowerCase();
|
|
222
|
+
if (ap === "pm" && hour < 12)
|
|
223
|
+
hour += 12;
|
|
224
|
+
if (ap === "am" && hour === 12)
|
|
225
|
+
hour = 0;
|
|
226
|
+
return { hour, minute };
|
|
227
|
+
}
|
|
228
|
+
const ZONE_PATTERN = String.raw `\(\s*(UTC|[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)+)\s*\)`;
|
|
229
|
+
/**
|
|
230
|
+
* Parse provider reset times into an ISO timestamp. Handled forms:
|
|
231
|
+
*
|
|
232
|
+
* - "resets 2am (UTC)" and "resets 1am (America/Denver)": the next such time.
|
|
233
|
+
* - "resets Sep 24, 2am (UTC)", "resets Oct 16 at 1am (America/Denver)",
|
|
234
|
+
* "resets Sep 24, 2026 2am (UTC)": a dated reset.
|
|
235
|
+
* - Codex's "try again at Sep 14th, 2026 9:24 PM", shown in the provider
|
|
236
|
+
* CLI's local timezone unless it names `(UTC)`.
|
|
237
|
+
*
|
|
238
|
+
* The zone is `UTC` or an IANA name; an unknown zone name fails the parse. A
|
|
239
|
+
* dated provider reset with no zone is read as UTC. The year is the zone's
|
|
240
|
+
* current year for an unyear-tagged reset, rolled forward when it is already
|
|
241
|
+
* past. An explicit provider year is kept as stated.
|
|
151
242
|
*/
|
|
152
243
|
export function parseProviderResetTime(text, now) {
|
|
153
|
-
const timeOnlyMatch =
|
|
244
|
+
const timeOnlyMatch = new RegExp(String.raw `\bresets\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*${ZONE_PATTERN}`, "i").exec(text);
|
|
154
245
|
if (timeOnlyMatch) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const meridiem = (timeOnlyMatch[3] ?? "").toLowerCase();
|
|
158
|
-
if (!Number.isInteger(hour) || hour < 1 || hour > 12 || minute < 0 || minute > 59)
|
|
246
|
+
const zone = timeOnlyMatch[4] ?? "UTC";
|
|
247
|
+
if (!isKnownTimeZone(zone))
|
|
159
248
|
return null;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
249
|
+
const time = to24Hour(timeOnlyMatch[1] ?? "", timeOnlyMatch[2], timeOnlyMatch[3] ?? "");
|
|
250
|
+
if (!time)
|
|
251
|
+
return null;
|
|
252
|
+
const today = zonedDateParts(zone, now.getTime());
|
|
253
|
+
for (let offsetDays = 0; offsetDays <= 2; offsetDays += 1) {
|
|
254
|
+
const date = new Date(Date.UTC(today.year, today.month, today.day + offsetDays));
|
|
255
|
+
const ts = zonedWallTimeToUtc(zone, date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), time.hour, time.minute);
|
|
256
|
+
if (ts !== null && ts > now.getTime())
|
|
257
|
+
return new Date(ts).toISOString();
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
168
260
|
}
|
|
169
|
-
const resetMatch =
|
|
261
|
+
const resetMatch = new RegExp(String.raw `\bresets\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?(?:(?:,\s*(?:(\d{4})\s+)?|\s+at\s+)(\d{1,2})(?::(\d{2}))?\s*(am|pm))?(?:\s*${ZONE_PATTERN})?`, "i").exec(text);
|
|
170
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);
|
|
171
263
|
const match = resetMatch ?? codexMatch;
|
|
172
264
|
if (!match)
|
|
173
265
|
return null;
|
|
174
|
-
const codexLocalTime =
|
|
266
|
+
const codexLocalTime = resetMatch === null && (match[7] ?? "").toLowerCase() !== "utc";
|
|
267
|
+
const zone = match[7] ?? "UTC";
|
|
268
|
+
if (!codexLocalTime && !isKnownTimeZone(zone))
|
|
269
|
+
return null;
|
|
175
270
|
const month = MONTHS[(match[1] ?? "").slice(0, 3).toLowerCase()];
|
|
176
271
|
if (month === undefined)
|
|
177
272
|
return null;
|
|
@@ -179,37 +274,31 @@ export function parseProviderResetTime(text, now) {
|
|
|
179
274
|
if (!Number.isInteger(day) || day < 1 || day > 31)
|
|
180
275
|
return null;
|
|
181
276
|
const explicitYear = match[3] !== undefined;
|
|
182
|
-
const parsedYear = explicitYear
|
|
277
|
+
const parsedYear = explicitYear
|
|
278
|
+
? Number(match[3])
|
|
279
|
+
: codexLocalTime
|
|
280
|
+
? now.getFullYear()
|
|
281
|
+
: zonedDateParts(zone, now.getTime()).year;
|
|
183
282
|
if (!Number.isInteger(parsedYear) || parsedYear < 1970)
|
|
184
283
|
return null;
|
|
185
284
|
let hour = 0;
|
|
186
285
|
let minute = 0;
|
|
187
286
|
if (match[4] !== undefined) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const ap = (match[6] ?? "").toLowerCase();
|
|
191
|
-
if (ap === "pm" && hour < 12)
|
|
192
|
-
hour += 12;
|
|
193
|
-
if (ap === "am" && hour === 12)
|
|
194
|
-
hour = 0;
|
|
195
|
-
if (hour > 23 || minute > 59)
|
|
287
|
+
const time = to24Hour(match[4], match[5], match[6] ?? "");
|
|
288
|
+
if (!time)
|
|
196
289
|
return null;
|
|
290
|
+
hour = time.hour;
|
|
291
|
+
minute = time.minute;
|
|
197
292
|
}
|
|
198
293
|
const timestampForYear = (targetYear) => {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const valid =
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
candidate.getMinutes() === minute
|
|
208
|
-
: candidate.getUTCFullYear() === targetYear &&
|
|
209
|
-
candidate.getUTCMonth() === month &&
|
|
210
|
-
candidate.getUTCDate() === day &&
|
|
211
|
-
candidate.getUTCHours() === hour &&
|
|
212
|
-
candidate.getUTCMinutes() === minute;
|
|
294
|
+
if (!codexLocalTime)
|
|
295
|
+
return zonedWallTimeToUtc(zone, targetYear, month, day, hour, minute);
|
|
296
|
+
const candidate = new Date(targetYear, month, day, hour, minute, 0, 0);
|
|
297
|
+
const valid = candidate.getFullYear() === targetYear &&
|
|
298
|
+
candidate.getMonth() === month &&
|
|
299
|
+
candidate.getDate() === day &&
|
|
300
|
+
candidate.getHours() === hour &&
|
|
301
|
+
candidate.getMinutes() === minute;
|
|
213
302
|
return valid ? candidate.getTime() : null;
|
|
214
303
|
};
|
|
215
304
|
let year = parsedYear;
|
|
@@ -279,6 +368,9 @@ export function quotaSignalFromFields(fields, model, now) {
|
|
|
279
368
|
quota_reset_at,
|
|
280
369
|
model,
|
|
281
370
|
account_wide: true,
|
|
371
|
+
// Kept only when it reads as a provider quota message, so the senior can
|
|
372
|
+
// see the provider's own words next to the hold.
|
|
373
|
+
...(isUsageLimitShape(text) && text.length <= 300 ? { provider_text: text } : {}),
|
|
282
374
|
};
|
|
283
375
|
}
|
|
284
376
|
export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
|
|
@@ -314,11 +406,46 @@ export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
|
|
|
314
406
|
const observedAt = Number.isFinite(outputAt.getTime()) ? outputAt : now;
|
|
315
407
|
return quotaSignalFromFields(fields, model, observedAt);
|
|
316
408
|
}
|
|
317
|
-
|
|
409
|
+
/**
|
|
410
|
+
* Hold a provider reset more than a day away to a short hold until a second
|
|
411
|
+
* 429 confirms it. The second 429 counts only when its lane ran after the
|
|
412
|
+
* short hold expired, so lanes that failed together on one 429 burst do not
|
|
413
|
+
* confirm each other.
|
|
414
|
+
*/
|
|
415
|
+
function gateFarReset(current, signal, options) {
|
|
416
|
+
if (!signal.account_wide ||
|
|
417
|
+
!signal.quota_reset_at ||
|
|
418
|
+
signal.needs_credits ||
|
|
419
|
+
signal.reset_source === "derived") {
|
|
420
|
+
return { signal };
|
|
421
|
+
}
|
|
422
|
+
const resetMs = Date.parse(signal.quota_reset_at);
|
|
423
|
+
const nowMs = options.now.getTime();
|
|
424
|
+
if (!Number.isFinite(resetMs) || resetMs - nowMs <= QUOTA_CONFIRM_HORIZON_MS)
|
|
425
|
+
return { signal };
|
|
426
|
+
const pending = current.unconfirmed_reset_at;
|
|
427
|
+
const shortHoldActive = resetStillAhead(current.all_models_quota_reset_at, options.now);
|
|
428
|
+
if (pending !== undefined && !shortHoldActive) {
|
|
429
|
+
return { signal: { ...signal, reset_source: "parsed" } };
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
signal: {
|
|
433
|
+
...signal,
|
|
434
|
+
quota_reset_at: new Date(nowMs + options.unconfirmedHoldMs).toISOString(),
|
|
435
|
+
reset_source: "unconfirmed",
|
|
436
|
+
},
|
|
437
|
+
unconfirmed_reset_at: pending !== undefined && !shouldReplaceReset(pending, signal.quota_reset_at)
|
|
438
|
+
? pending
|
|
439
|
+
: signal.quota_reset_at,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
export function recordAccountQuota(hqRoot, provider, accountId, incoming, options) {
|
|
318
443
|
return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
|
|
319
444
|
const state = loadQuotaState(hqRoot);
|
|
320
445
|
const accounts = state.providers[provider] ?? {};
|
|
321
446
|
const current = accounts[accountId] ?? { models: {} };
|
|
447
|
+
const gated = options ? gateFarReset(current, incoming, options) : { signal: incoming };
|
|
448
|
+
const signal = gated.signal;
|
|
322
449
|
const models = { ...current.models };
|
|
323
450
|
if (signal.model) {
|
|
324
451
|
const prior = models[signal.model] ?? {};
|
|
@@ -340,6 +467,14 @@ export function recordAccountQuota(hqRoot, provider, accountId, signal) {
|
|
|
340
467
|
if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
|
|
341
468
|
account.all_models_quota_reset_at = signal.quota_reset_at;
|
|
342
469
|
account.reset_source = signal.reset_source ?? "parsed";
|
|
470
|
+
if (signal.provider_text)
|
|
471
|
+
account.provider_text = signal.provider_text;
|
|
472
|
+
if (gated.unconfirmed_reset_at) {
|
|
473
|
+
account.unconfirmed_reset_at = gated.unconfirmed_reset_at;
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
delete account.unconfirmed_reset_at;
|
|
477
|
+
}
|
|
343
478
|
}
|
|
344
479
|
else if (current.all_models_quota_reset_at) {
|
|
345
480
|
account.all_models_quota_reset_at = current.all_models_quota_reset_at;
|
|
@@ -367,11 +502,17 @@ export function clearAccountQuota(hqRoot, provider, accountId, options = {}) {
|
|
|
367
502
|
const shouldClear = (reset) => reset !== undefined &&
|
|
368
503
|
(!options.futureOnly || resetStillAhead(reset, now));
|
|
369
504
|
let changed = false;
|
|
505
|
+
// A successful turn or an operator clear disproves a pending far reset.
|
|
506
|
+
if (account.unconfirmed_reset_at !== undefined) {
|
|
507
|
+
delete account.unconfirmed_reset_at;
|
|
508
|
+
changed = true;
|
|
509
|
+
}
|
|
370
510
|
const accountWideReset = account.all_models_quota_reset_at;
|
|
371
511
|
if (shouldClear(accountWideReset)) {
|
|
372
512
|
cleared.all_models_quota_reset_at = accountWideReset;
|
|
373
513
|
delete account.all_models_quota_reset_at;
|
|
374
514
|
delete account.reset_source;
|
|
515
|
+
delete account.provider_text;
|
|
375
516
|
changed = true;
|
|
376
517
|
}
|
|
377
518
|
const modelNames = options.model === undefined
|
|
@@ -395,6 +536,7 @@ export function clearAccountQuota(hqRoot, provider, accountId, options = {}) {
|
|
|
395
536
|
return cleared;
|
|
396
537
|
if (account.all_models_quota_reset_at === undefined) {
|
|
397
538
|
delete account.reset_source;
|
|
539
|
+
delete account.provider_text;
|
|
398
540
|
}
|
|
399
541
|
const accounts = state.providers[provider];
|
|
400
542
|
if (account.all_models_quota_reset_at === undefined &&
|
|
@@ -34,6 +34,7 @@ import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG, LE
|
|
|
34
34
|
import { isFailedQuotaKeepAliveLane, providerSupportsSessionResume } from "./delivery.js";
|
|
35
35
|
import { LANE_PROVIDERS, isIsoDateTime, isTerminalLaneState } from "./types.js";
|
|
36
36
|
import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, loadQuotaState, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
37
|
+
import { deliverQuotaHoldNotice, quotaHoldNotice } from "./quota-notice.js";
|
|
37
38
|
import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
38
39
|
import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
|
|
39
40
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
|
|
@@ -1733,7 +1734,22 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1733
1734
|
classified.account &&
|
|
1734
1735
|
(classification.reason === "account_quota_exhausted" ||
|
|
1735
1736
|
classification.reason === "account_model_unavailable")) {
|
|
1736
|
-
recordAccountQuota(hqRoot, classified.provider, classified.account, quota
|
|
1737
|
+
const quotaState = recordAccountQuota(hqRoot, classified.provider, classified.account, quota, {
|
|
1738
|
+
now,
|
|
1739
|
+
unconfirmedHoldMs: providerQuotaDefaultHoldMs(loadLanesConfig(hqRoot).providers[classified.provider]),
|
|
1740
|
+
});
|
|
1741
|
+
if (classification.reason === "account_quota_exhausted") {
|
|
1742
|
+
const notice = quotaHoldNotice(quotaState, classified.provider, classified.account, lane.lane_id);
|
|
1743
|
+
if (notice) {
|
|
1744
|
+
try {
|
|
1745
|
+
deliverQuotaHoldNotice(hqRoot, lane, notice, now.toISOString());
|
|
1746
|
+
}
|
|
1747
|
+
catch (err) {
|
|
1748
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1749
|
+
process.stderr.write(`hq lanes reconcile: quota hold notice failed for ${lane.lane_id}: ${message}\n`);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1737
1753
|
}
|
|
1738
1754
|
if (!deps.dryRun &&
|
|
1739
1755
|
accountRetryReason &&
|
|
@@ -43,6 +43,7 @@ import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpda
|
|
|
43
43
|
import { enforceRegistryApprovalGate } from "./approval-gate.js";
|
|
44
44
|
import { readWorkerRunResult } from "./delivery.js";
|
|
45
45
|
import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
46
|
+
import { deliverQuotaHoldNotice, quotaHoldNotice } from "./quota-notice.js";
|
|
46
47
|
import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
|
|
47
48
|
import { appendDropboxMessageAtPath, dropboxInboxHasMessages, readToolActivity, toolActivityPath } from "./dropbox.js";
|
|
48
49
|
import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
|
|
@@ -1764,7 +1765,18 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
|
|
|
1764
1765
|
(classification.reason === "account_quota_exhausted" ||
|
|
1765
1766
|
classification.reason === "account_model_unavailable")) {
|
|
1766
1767
|
try {
|
|
1767
|
-
|
|
1768
|
+
const observedAt = new Date(now());
|
|
1769
|
+
const quotaState = recordAccountQuota(hqRoot, quotaLane.provider, quotaLane.account, quota, {
|
|
1770
|
+
now: observedAt,
|
|
1771
|
+
unconfirmedHoldMs: providerQuotaDefaultHoldMs(loadLanesConfig(hqRoot).providers[quotaLane.provider]),
|
|
1772
|
+
});
|
|
1773
|
+
if (classification.reason === "account_quota_exhausted") {
|
|
1774
|
+
const notice = quotaHoldNotice(quotaState, quotaLane.provider, quotaLane.account, quotaLane.lane_id);
|
|
1775
|
+
if (notice) {
|
|
1776
|
+
const deliveredTo = deliverQuotaHoldNotice(hqRoot, quotaLane, notice, observedAt.toISOString());
|
|
1777
|
+
logDecision({ event: "quota_hold_notice", at: tick.emit.at, account: quotaLane.account, delivered_to: deliveredTo });
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1768
1780
|
}
|
|
1769
1781
|
catch (err) {
|
|
1770
1782
|
process.stderr.write(`hq lanes watcher: quota record failed for ${laneId}: ${err instanceof Error ? err.message : String(err)}\n`);
|
package/dist/lib/mesh/api.d.ts
CHANGED
|
@@ -137,6 +137,8 @@ export interface StoryPatch {
|
|
|
137
137
|
status?: string;
|
|
138
138
|
assigneeUid?: string;
|
|
139
139
|
clearAssignee?: boolean;
|
|
140
|
+
/** Override another holder's assignment (server answers 409 otherwise). */
|
|
141
|
+
force?: boolean;
|
|
140
142
|
}
|
|
141
143
|
export interface StoryPatchResult {
|
|
142
144
|
projectId: string;
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -56,6 +56,7 @@ export async function meshJson(token, path, init = {}) {
|
|
|
56
56
|
].filter(Boolean);
|
|
57
57
|
const failure = new Error(`${res.status} ${code}${detail}${context.length ? ` (${context.join("; ")})` : ""}`);
|
|
58
58
|
failure.status = res.status;
|
|
59
|
+
failure.body = data;
|
|
59
60
|
if (typeof err.code === "string" && err.code)
|
|
60
61
|
failure.code = err.code;
|
|
61
62
|
throw failure;
|
|
@@ -398,6 +399,8 @@ export async function patchStory(token, companyUid, projectId, storyId, patch) {
|
|
|
398
399
|
body.assigneeUid = patch.assigneeUid;
|
|
399
400
|
if (patch.clearAssignee)
|
|
400
401
|
body.clearAssignee = true;
|
|
402
|
+
if (patch.force)
|
|
403
|
+
body.force = true;
|
|
401
404
|
const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/stories/${encodeURIComponent(storyId)}`, {
|
|
402
405
|
method: "PATCH",
|
|
403
406
|
body,
|
|
@@ -114,6 +114,11 @@ export declare function fetchCompanyLive(token: string, companyUid: string, opts
|
|
|
114
114
|
cursor?: string;
|
|
115
115
|
get?: (path: string, query: Record<string, string>) => Promise<unknown>;
|
|
116
116
|
}): Promise<CompanyLiveReadResponse>;
|
|
117
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Fill participants[].displayName from the local directory cache when the
|
|
119
|
+
* server omitted it (older hq-pro builds do not send it).
|
|
120
|
+
*/
|
|
121
|
+
export declare function withParticipantDisplayNames(live: CompanyLiveReadResponse, names: ReadonlyMap<string, string>): CompanyLiveReadResponse;
|
|
122
|
+
export declare function formatCompanyLiveTable(live: CompanyLiveReadResponse, companyLabel: string, names?: ReadonlyMap<string, string>): string[];
|
|
118
123
|
export type { OrganizeSubmitDecision, OrganizeReceipt, CandidatesResponse, MigrateReceipt, PostMigrateFn, };
|
|
119
124
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/lib/mesh/client.js
CHANGED
|
@@ -290,22 +290,35 @@ export async function fetchCompanyLive(token, companyUid, opts) {
|
|
|
290
290
|
: await meshJson(token, "/v1/work-mesh/live", { query });
|
|
291
291
|
return data;
|
|
292
292
|
}
|
|
293
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Fill participants[].displayName from the local directory cache when the
|
|
295
|
+
* server omitted it (older hq-pro builds do not send it).
|
|
296
|
+
*/
|
|
297
|
+
export function withParticipantDisplayNames(live, names) {
|
|
298
|
+
return {
|
|
299
|
+
...live,
|
|
300
|
+
participants: (live.participants ?? []).map((p) => {
|
|
301
|
+
const name = p.displayName?.trim() || names.get(p.actorUid);
|
|
302
|
+
return name ? { ...p, displayName: name } : p;
|
|
303
|
+
}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
export function formatCompanyLiveTable(live, companyLabel, names = new Map()) {
|
|
294
307
|
const lines = [
|
|
295
308
|
`Work mesh live: ${companyLabel} generatedAt=${live.generatedAt}`,
|
|
296
|
-
"
|
|
309
|
+
"name presence sessionId status context project task turns",
|
|
297
310
|
];
|
|
298
311
|
for (const p of live.participants ?? []) {
|
|
312
|
+
const who = p.displayName?.trim() || names.get(p.actorUid) || p.actorUid;
|
|
299
313
|
const sessions = p.sessions ?? [];
|
|
300
314
|
if (sessions.length === 0) {
|
|
301
|
-
lines.push(`${
|
|
315
|
+
lines.push(`${who}\t${p.presence ?? "?"}\t-\t-\t-\t-\t-\t-`);
|
|
302
316
|
continue;
|
|
303
317
|
}
|
|
304
318
|
for (const s of sessions) {
|
|
305
319
|
lines.push([
|
|
306
|
-
|
|
320
|
+
who,
|
|
307
321
|
p.presence ?? "?",
|
|
308
|
-
p.displayName ?? "",
|
|
309
322
|
s.sessionId,
|
|
310
323
|
s.status ?? "?",
|
|
311
324
|
s.contextStatus ?? "?",
|