@indigoai-us/hq-cli 5.297.0 → 5.299.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/command-catalog.generated.d.ts +23 -0
- package/dist/command-catalog.generated.js +30 -0
- package/dist/commands/agents.js +59 -5
- package/dist/commands/daemon.js +1 -0
- package/dist/commands/files-browse.d.ts +3 -1
- package/dist/commands/files-browse.js +30 -7
- package/dist/commands/integrations-api.js +14 -3
- package/dist/commands/lanes-accounts.d.ts +3 -0
- package/dist/commands/lanes-accounts.js +25 -1
- package/dist/commands/lanes.js +10 -0
- package/dist/lib/bot/prompt.d.ts +2 -0
- package/dist/lib/bot/prompt.js +29 -4
- package/dist/lib/daemon/legacy.d.ts +5 -2
- package/dist/lib/daemon/legacy.js +6 -4
- package/dist/lib/daemon/migrate.d.ts +10 -1
- package/dist/lib/daemon/migrate.js +22 -7
- package/dist/lib/daemon/run.js +19 -7
- package/dist/lib/daemon/supervisor.js +9 -1
- package/dist/lib/lanes/profile.js +5 -0
- package/dist/lib/lanes/quota.d.ts +19 -0
- package/dist/lib/lanes/quota.js +106 -29
- package/dist/lib/lanes/reconcile.js +12 -1
- package/dist/lib/lanes/store.d.ts +2 -0
- package/dist/lib/lanes/store.js +4 -0
- package/dist/lib/lanes/watcher.js +10 -1
- package/dist/lib/mesh/live/daemon/install.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/install.js +13 -2
- package/dist/lib/policy-validator.d.ts +5 -0
- package/dist/lib/policy-validator.js +69 -18
- package/dist/utils/self-update.js +249 -2
- package/dist/utils/staged-install.js +71 -21
- package/package.json +1 -1
package/dist/lib/daemon/run.js
CHANGED
|
@@ -22,7 +22,7 @@ import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
|
|
|
22
22
|
import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
|
|
23
23
|
import { readLegacySyncEnv } from "./legacy-sync-env.js";
|
|
24
24
|
import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
|
|
25
|
-
import {
|
|
25
|
+
import { retireLegacyMeshUnits } from "./migrate.js";
|
|
26
26
|
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
27
27
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
28
28
|
import { daemonRootRefusal } from "./install.js";
|
|
@@ -72,6 +72,17 @@ export function probeLegacyUnits(platform, home) {
|
|
|
72
72
|
home,
|
|
73
73
|
existsSync: fs.existsSync,
|
|
74
74
|
listDir: (dir) => fs.readdirSync(dir),
|
|
75
|
+
launchdJobLoaded: (label) => {
|
|
76
|
+
if (platform !== "darwin")
|
|
77
|
+
return false;
|
|
78
|
+
const uid = process.getuid?.() ?? 0;
|
|
79
|
+
const result = spawnSync("launchctl", ["print", `gui/${uid}/${label}`], {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
timeout: 15_000,
|
|
83
|
+
});
|
|
84
|
+
return result.status === 0;
|
|
85
|
+
},
|
|
75
86
|
windowsTaskExists,
|
|
76
87
|
});
|
|
77
88
|
}
|
|
@@ -344,7 +355,12 @@ export async function runDaemon(opts) {
|
|
|
344
355
|
if (legacyMesh.length > 0) {
|
|
345
356
|
try {
|
|
346
357
|
const uid = process.getuid?.() ?? 0;
|
|
347
|
-
|
|
358
|
+
retireLegacyMeshUnits(legacyMesh, {
|
|
359
|
+
platform,
|
|
360
|
+
managed: opts.managed === true,
|
|
361
|
+
hosted: opts.host !== undefined,
|
|
362
|
+
meshEnabled: enabledServices(machineType, initial).includes("mesh"),
|
|
363
|
+
}, {
|
|
348
364
|
run: (command, args) => {
|
|
349
365
|
const r = spawnSync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15_000 });
|
|
350
366
|
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
@@ -356,11 +372,7 @@ export async function runDaemon(opts) {
|
|
|
356
372
|
systemAdoptedDir: "/var/lib/hq/daemon/adopted",
|
|
357
373
|
systemRecordPath: "/var/lib/hq/daemon/adopted.json",
|
|
358
374
|
systemUnitDir: "/etc/systemd/system",
|
|
359
|
-
});
|
|
360
|
-
for (const unit of result.adopted)
|
|
361
|
-
log("info", `retired legacy ${unit.unitId} unit ${unit.name}; hq daemon now owns mesh`);
|
|
362
|
-
for (const failure of result.failed)
|
|
363
|
-
log("warn", `could not retire legacy ${failure.unit.unitId} unit ${failure.unit.name}: ${failure.error}`);
|
|
375
|
+
}, (level, message) => log(level, message));
|
|
364
376
|
}
|
|
365
377
|
catch (err) {
|
|
366
378
|
log("error", `could not retire legacy mesh units: ${err.message}`);
|
|
@@ -28,6 +28,7 @@ export const BACKOFF_MAX_MS = 60_000;
|
|
|
28
28
|
export const HEALTHY_RUN_MS = 60_000;
|
|
29
29
|
export const STOP_ESCALATION_MS = 10_000;
|
|
30
30
|
const MESH_OWNER_RECHECK_MS = 15_000;
|
|
31
|
+
const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
|
|
31
32
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
32
33
|
export const HANDOFF_STAGGER_MS = 15_000;
|
|
33
34
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
@@ -297,11 +298,18 @@ export function createSupervisor(deps) {
|
|
|
297
298
|
}
|
|
298
299
|
const meshOwnerReason = slot.plan.service === "mesh" ? deps.meshOwnerReason?.() : undefined;
|
|
299
300
|
if (meshOwnerReason) {
|
|
301
|
+
let lastLoggedReason;
|
|
302
|
+
let lastLoggedAt = Number.NEGATIVE_INFINITY;
|
|
300
303
|
const waitForOwner = (reason) => {
|
|
301
304
|
slot.state.status = "waiting";
|
|
302
305
|
slot.state.reason = reason;
|
|
303
306
|
slot.state.nextRunAt = now() + MESH_OWNER_RECHECK_MS;
|
|
304
|
-
|
|
307
|
+
const at = now();
|
|
308
|
+
if (reason !== lastLoggedReason || at - lastLoggedAt >= MESH_OWNER_LOG_INTERVAL_MS) {
|
|
309
|
+
deps.log("warn", `${reason}; retrying mesh ownership check in ${MESH_OWNER_RECHECK_MS}ms`);
|
|
310
|
+
lastLoggedReason = reason;
|
|
311
|
+
lastLoggedAt = at;
|
|
312
|
+
}
|
|
305
313
|
slot.timer = setT(() => {
|
|
306
314
|
slot.timer = undefined;
|
|
307
315
|
const current = deps.meshOwnerReason?.();
|
|
@@ -10,6 +10,7 @@ import { createHash } from "node:crypto";
|
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { parseUserYaml } from "../../utils/user-yaml-error.js";
|
|
13
|
+
import { policyLoadDiagnostic } from "../policy-validator.js";
|
|
13
14
|
import { WorkerResolutionError } from "./errors.js";
|
|
14
15
|
import { PROVIDER_EFFORT_LEVELS } from "./effort.js";
|
|
15
16
|
import { LANE_PROVIDERS, isLaneProvider, } from "./types.js";
|
|
@@ -261,6 +262,10 @@ function validatePolicyFrontmatter(id, profileDir, relative) {
|
|
|
261
262
|
frontmatter.on.some((value) => typeof value !== "string" || value.trim().length === 0)) {
|
|
262
263
|
throw new WorkerResolutionError("worker_policy_invalid", `Worker policy ${filePath} frontmatter must include a non-empty on string array`, { worker: id, file: filePath, field: "on" });
|
|
263
264
|
}
|
|
265
|
+
const diagnostic = policyLoadDiagnostic(content, filePath);
|
|
266
|
+
if (diagnostic) {
|
|
267
|
+
throw new WorkerResolutionError("worker_policy_invalid", `Worker policy ${filePath} failed policy validation: ${diagnostic.split("\n", 1)[0]}`, { worker: id, file: filePath, field: "frontmatter", diagnostic });
|
|
268
|
+
}
|
|
264
269
|
}
|
|
265
270
|
function readSettings(id, dir) {
|
|
266
271
|
const filePath = path.join(dir, PROFILE_SETTINGS);
|
|
@@ -89,6 +89,25 @@ export declare function quotaSignalFromFields(fields: {
|
|
|
89
89
|
}, model: string | undefined, now: Date): QuotaSignal | null;
|
|
90
90
|
export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
|
|
91
91
|
export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, signal: QuotaSignal): QuotaState;
|
|
92
|
+
export interface ClearedModelQuotaReset {
|
|
93
|
+
model: string;
|
|
94
|
+
quota_reset_at: string;
|
|
95
|
+
}
|
|
96
|
+
export interface AccountQuotaClearResult {
|
|
97
|
+
all_models_quota_reset_at: string | null;
|
|
98
|
+
model_quota_resets: ClearedModelQuotaReset[];
|
|
99
|
+
}
|
|
100
|
+
export interface ClearAccountQuotaOptions {
|
|
101
|
+
/** Clear only this model's reset instead of every per-model reset. */
|
|
102
|
+
model?: string;
|
|
103
|
+
/** Clear only marks whose reset time is still ahead of now. */
|
|
104
|
+
futureOnly?: boolean;
|
|
105
|
+
now?: Date;
|
|
106
|
+
}
|
|
107
|
+
/** Clear quota reset marks and persist them with the quota file's atomic writer. */
|
|
108
|
+
export declare function clearAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, options?: ClearAccountQuotaOptions): AccountQuotaClearResult;
|
|
109
|
+
export declare function clearAccountQuotaAfterSuccessfulTurn(hqRoot: string, provider: LaneProvider, accountId: string, model: string, now: Date): AccountQuotaClearResult;
|
|
110
|
+
export declare function clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot: string, provider: LaneProvider, accountId: string, model: string, now: Date): void;
|
|
92
111
|
export interface QuotaBlock {
|
|
93
112
|
blocked: boolean;
|
|
94
113
|
reason?: "account_quota_exhausted" | "account_model_unavailable";
|
package/dist/lib/lanes/quota.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import * as fs from "node:fs";
|
|
21
21
|
import * as path from "node:path";
|
|
22
|
+
import { withLaneRecordFileLock } from "./store.js";
|
|
22
23
|
export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
|
|
23
24
|
export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
|
|
24
25
|
/**
|
|
@@ -278,40 +279,116 @@ export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
|
|
|
278
279
|
return quotaSignalFromFields(fields, model, now);
|
|
279
280
|
}
|
|
280
281
|
export function recordAccountQuota(hqRoot, provider, accountId, signal) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
282
|
+
return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
|
|
283
|
+
const state = loadQuotaState(hqRoot);
|
|
284
|
+
const accounts = state.providers[provider] ?? {};
|
|
285
|
+
const current = accounts[accountId] ?? { models: {} };
|
|
286
|
+
const models = { ...current.models };
|
|
287
|
+
if (signal.model) {
|
|
288
|
+
const prior = models[signal.model] ?? {};
|
|
289
|
+
const next = { ...prior };
|
|
290
|
+
if (signal.needs_credits) {
|
|
291
|
+
next.needs_credits = true;
|
|
292
|
+
next.marker = signal.marker ?? `needs credits for ${signal.model}`;
|
|
293
|
+
delete next.quota_reset_at;
|
|
294
|
+
}
|
|
295
|
+
if (signal.quota_reset_at &&
|
|
296
|
+
!signal.needs_credits &&
|
|
297
|
+
shouldReplaceReset(next.quota_reset_at, signal.quota_reset_at)) {
|
|
298
|
+
next.quota_reset_at = signal.quota_reset_at;
|
|
299
|
+
}
|
|
300
|
+
models[signal.model] = next;
|
|
292
301
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
shouldReplaceReset(
|
|
296
|
-
|
|
302
|
+
const account = { ...current, models };
|
|
303
|
+
if (signal.account_wide && signal.quota_reset_at) {
|
|
304
|
+
if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
|
|
305
|
+
account.all_models_quota_reset_at = signal.quota_reset_at;
|
|
306
|
+
account.reset_source = signal.reset_source ?? "provider";
|
|
307
|
+
}
|
|
308
|
+
else if (current.all_models_quota_reset_at) {
|
|
309
|
+
account.all_models_quota_reset_at = current.all_models_quota_reset_at;
|
|
310
|
+
account.reset_source = current.reset_source ?? "provider";
|
|
311
|
+
}
|
|
297
312
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
313
|
+
accounts[accountId] = account;
|
|
314
|
+
state.providers[provider] = accounts;
|
|
315
|
+
writeQuotaState(hqRoot, state);
|
|
316
|
+
return state;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** Clear quota reset marks and persist them with the quota file's atomic writer. */
|
|
320
|
+
export function clearAccountQuota(hqRoot, provider, accountId, options = {}) {
|
|
321
|
+
return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
|
|
322
|
+
const cleared = {
|
|
323
|
+
all_models_quota_reset_at: null,
|
|
324
|
+
model_quota_resets: [],
|
|
325
|
+
};
|
|
326
|
+
const state = loadQuotaState(hqRoot);
|
|
327
|
+
const account = state.providers[provider]?.[accountId];
|
|
328
|
+
if (!account)
|
|
329
|
+
return cleared;
|
|
330
|
+
const now = options.now ?? new Date();
|
|
331
|
+
const shouldClear = (reset) => reset !== undefined &&
|
|
332
|
+
(!options.futureOnly || resetStillAhead(reset, now));
|
|
333
|
+
let changed = false;
|
|
334
|
+
const accountWideReset = account.all_models_quota_reset_at;
|
|
335
|
+
if (shouldClear(accountWideReset)) {
|
|
336
|
+
cleared.all_models_quota_reset_at = accountWideReset;
|
|
337
|
+
delete account.all_models_quota_reset_at;
|
|
338
|
+
delete account.reset_source;
|
|
339
|
+
changed = true;
|
|
340
|
+
}
|
|
341
|
+
const modelNames = options.model === undefined
|
|
342
|
+
? Object.keys(account.models).sort()
|
|
343
|
+
: [options.model];
|
|
344
|
+
for (const model of modelNames) {
|
|
345
|
+
const entry = account.models[model];
|
|
346
|
+
const modelReset = entry?.quota_reset_at;
|
|
347
|
+
if (!entry || !shouldClear(modelReset))
|
|
348
|
+
continue;
|
|
349
|
+
cleared.model_quota_resets.push({
|
|
350
|
+
model,
|
|
351
|
+
quota_reset_at: modelReset,
|
|
352
|
+
});
|
|
353
|
+
delete entry.quota_reset_at;
|
|
354
|
+
if (Object.keys(entry).length === 0)
|
|
355
|
+
delete account.models[model];
|
|
356
|
+
changed = true;
|
|
305
357
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
358
|
+
if (!changed)
|
|
359
|
+
return cleared;
|
|
360
|
+
if (account.all_models_quota_reset_at === undefined) {
|
|
361
|
+
delete account.reset_source;
|
|
309
362
|
}
|
|
363
|
+
const accounts = state.providers[provider];
|
|
364
|
+
if (account.all_models_quota_reset_at === undefined &&
|
|
365
|
+
Object.keys(account.models).length === 0) {
|
|
366
|
+
delete accounts[accountId];
|
|
367
|
+
if (Object.keys(accounts).length === 0)
|
|
368
|
+
delete state.providers[provider];
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
accounts[accountId] = account;
|
|
372
|
+
}
|
|
373
|
+
writeQuotaState(hqRoot, state);
|
|
374
|
+
return cleared;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
export function clearAccountQuotaAfterSuccessfulTurn(hqRoot, provider, accountId, model, now) {
|
|
378
|
+
return clearAccountQuota(hqRoot, provider, accountId, {
|
|
379
|
+
model,
|
|
380
|
+
futureOnly: true,
|
|
381
|
+
now,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
export function clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, provider, accountId, model, now) {
|
|
385
|
+
try {
|
|
386
|
+
clearAccountQuotaAfterSuccessfulTurn(hqRoot, provider, accountId, model, now);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
390
|
+
process.stderr.write(`hq lanes: quota cleanup after successful turn failed for provider=${provider} account=${accountId} model=${model}: ${detail}\n`);
|
|
310
391
|
}
|
|
311
|
-
accounts[accountId] = account;
|
|
312
|
-
state.providers[provider] = accounts;
|
|
313
|
-
writeQuotaState(hqRoot, state);
|
|
314
|
-
return state;
|
|
315
392
|
}
|
|
316
393
|
function shouldReplaceReset(current, incoming) {
|
|
317
394
|
if (!current)
|
|
@@ -27,7 +27,7 @@ import { appendDropboxMessage } from "./dropbox.js";
|
|
|
27
27
|
import { DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG, listHostProcesses, runRunawayToolGuard, RUNAWAY_TOOL_GUARD_FLAG, } from "./runaway-tool-guard.js";
|
|
28
28
|
import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG, LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS, LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT, queueLaneTerminalResume, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, undeliveredAnsweredQuestions, } from "./keep-alive.js";
|
|
29
29
|
import { providerSupportsSessionResume } from "./delivery.js";
|
|
30
|
-
import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
30
|
+
import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
31
31
|
import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
32
32
|
import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
|
|
33
33
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
|
|
@@ -1299,6 +1299,17 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1299
1299
|
}
|
|
1300
1300
|
return next;
|
|
1301
1301
|
}, deps.dryRun);
|
|
1302
|
+
if (!deps.dryRun &&
|
|
1303
|
+
result.changed &&
|
|
1304
|
+
result.record.state === "done" &&
|
|
1305
|
+
terminalClassification.state === "done" &&
|
|
1306
|
+
terminalEnvelope?.decision === "done" &&
|
|
1307
|
+
!quota &&
|
|
1308
|
+
!providerRejection &&
|
|
1309
|
+
result.record.account &&
|
|
1310
|
+
result.record.model) {
|
|
1311
|
+
clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, result.record.provider, result.record.account, result.record.model, new Date(nowIso));
|
|
1312
|
+
}
|
|
1302
1313
|
if (result.changed) {
|
|
1303
1314
|
summary.dead_host.push({
|
|
1304
1315
|
id: lane.lane_id,
|
|
@@ -43,6 +43,8 @@ export declare function sanitizeHostComponent(hostname: string): string;
|
|
|
43
43
|
export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> & Partial<Pick<StoreDeps, "generateLaneUlid">>): string;
|
|
44
44
|
export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
|
|
45
45
|
export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
|
|
46
|
+
/** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
|
|
47
|
+
export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T): T;
|
|
46
48
|
export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
|
|
47
49
|
export declare function parseQuestionRecord(raw: unknown, filePath?: string): QuestionRecord;
|
|
48
50
|
/**
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -222,6 +222,10 @@ function withRecordLock(recordPath, deps, fn) {
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
|
+
/** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
|
|
226
|
+
export function withLaneRecordFileLock(recordPath, fn) {
|
|
227
|
+
return withRecordLock(recordPath, defaultStoreDeps(), fn);
|
|
228
|
+
}
|
|
225
229
|
function readJsonFile(filePath, deps) {
|
|
226
230
|
let text;
|
|
227
231
|
try {
|
|
@@ -40,7 +40,7 @@ import { laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
|
|
|
40
40
|
import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpdateLane, withLaneHireLocks } from "./store.js";
|
|
41
41
|
import { enforceRegistryApprovalGate } from "./approval-gate.js";
|
|
42
42
|
import { readWorkerRunResult } from "./delivery.js";
|
|
43
|
-
import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
43
|
+
import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
44
44
|
import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
|
|
45
45
|
import { appendDropboxMessageAtPath, dropboxInboxHasMessages, readToolActivity, toolActivityPath } from "./dropbox.js";
|
|
46
46
|
import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
|
|
@@ -445,6 +445,15 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
445
445
|
}
|
|
446
446
|
return next;
|
|
447
447
|
});
|
|
448
|
+
if (updated.state === "done" &&
|
|
449
|
+
effectiveClassification.state === "done" &&
|
|
450
|
+
effectiveEnvelope?.decision === "done" &&
|
|
451
|
+
opts.quota == null &&
|
|
452
|
+
opts.providerRejection == null &&
|
|
453
|
+
updated.account &&
|
|
454
|
+
updated.model) {
|
|
455
|
+
clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, updated.provider, updated.account, updated.model, new Date(at));
|
|
456
|
+
}
|
|
448
457
|
notifySlotFreed({
|
|
449
458
|
hqRoot,
|
|
450
459
|
provider: updated.provider,
|
|
@@ -307,6 +307,9 @@ export declare function uninstallDaemonService(opts: {
|
|
|
307
307
|
unlinkSync?: typeof fs.unlinkSync;
|
|
308
308
|
existsSync?: typeof fs.existsSync;
|
|
309
309
|
deactivate?: (platform: DaemonInstallPlatform, dest: string) => void;
|
|
310
|
+
/** Service-manager runner for uninstall deactivation (tests inject; never the real binary). */
|
|
311
|
+
run?: RunCommand;
|
|
312
|
+
uid?: number;
|
|
310
313
|
/** Windows: schtasks.exe runner (tests inject; never the real binary). */
|
|
311
314
|
schtasks?: SchtasksRunner;
|
|
312
315
|
/** Windows: terminate the recorded daemon pid after `schtasks /End` (tests inject). */
|
|
@@ -22,7 +22,7 @@ import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqR
|
|
|
22
22
|
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
23
23
|
import { retireLegacyListen } from "./legacy-listen.js";
|
|
24
24
|
import { readDaemonState } from "./state.js";
|
|
25
|
-
import { activateLaunchd, launchdIsLoaded, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
|
|
25
|
+
import { activateLaunchd, defaultRunCommand, launchdIsLoaded, launchdServiceTarget, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
|
|
26
26
|
import { activateSystemd, systemdIsActive } from "../../../service-manager/systemd.js";
|
|
27
27
|
import { nodeBinaryProblem, preferStableNodeBinary } from "../../../node-binary.js";
|
|
28
28
|
/**
|
|
@@ -955,7 +955,18 @@ export function uninstallDaemonService(opts) {
|
|
|
955
955
|
}
|
|
956
956
|
if (platform === "darwin") {
|
|
957
957
|
if (existsSync(paths.plistPath)) {
|
|
958
|
-
opts.deactivate
|
|
958
|
+
if (opts.deactivate) {
|
|
959
|
+
opts.deactivate(platform, paths.plistPath);
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
const run = opts.run ?? defaultRunCommand;
|
|
963
|
+
const target = launchdServiceTarget(paths.plistPath, opts.uid);
|
|
964
|
+
const bootout = run("launchctl", ["bootout", target]);
|
|
965
|
+
if (bootout.status !== 0 && run("launchctl", ["print", target]).status === 0) {
|
|
966
|
+
const detail = (bootout.stderr || bootout.stdout).trim();
|
|
967
|
+
throw new Error(`launchctl bootout failed for ${target}${detail ? `: ${detail}` : ` (exit ${bootout.status})`}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
959
970
|
unlinkSync(paths.plistPath);
|
|
960
971
|
}
|
|
961
972
|
return {
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-time validation: reject policy files that cannot participate in the
|
|
3
|
+
* trigger system. Authoring-only hard-policy constraints remain in the hook.
|
|
4
|
+
*/
|
|
5
|
+
export declare function policyLoadDiagnostic(content: string, filePath: string): string | undefined;
|
|
1
6
|
export declare function policyValidatorDiagnostic(content: string, filePath: string, env: NodeJS.ProcessEnv): string | undefined;
|
|
2
7
|
//# sourceMappingURL=policy-validator.d.ts.map
|
|
@@ -80,13 +80,21 @@ function hasValidTriggerGrammar(expression) {
|
|
|
80
80
|
skip();
|
|
81
81
|
return valid && position === input.length;
|
|
82
82
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const MISSING_TRIGGER_HELP = `Every policy under */policies/ must declare BOTH:
|
|
84
|
+
when: <expression> # e.g. always | git && push | deploy || share
|
|
85
|
+
on: [<events>] # any of PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent, SessionStart
|
|
86
|
+
These drive just-in-time policy injection. See
|
|
87
|
+
core/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").
|
|
88
|
+
Add both fields to the frontmatter, then retry.
|
|
89
|
+
${VALIDATION_NOTE}`;
|
|
90
|
+
function inspectPolicyTriggerFrontmatter(content) {
|
|
86
91
|
const normalized = normalizeValidatorText(content);
|
|
87
92
|
const match = /^\s*---[ \t]*\n([\s\S]*?)\n---[ \t]*(\n|$)/.exec(normalized);
|
|
88
|
-
if (!match)
|
|
89
|
-
return
|
|
93
|
+
if (!match) {
|
|
94
|
+
return {
|
|
95
|
+
diagnostic: `BLOCKED: policy file is missing required trigger frontmatter (missing: no-frontmatter).\n\n${MISSING_TRIGGER_HELP}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
90
98
|
const frontmatter = match[1];
|
|
91
99
|
const whenLines = [...frontmatter.matchAll(/^[ \t]*when:[ \t]*(.*)$/gm)].map((entry) => entry[1] ?? '');
|
|
92
100
|
const hasWhen = whenLines.some((line) => /\S/.test(line));
|
|
@@ -96,8 +104,46 @@ export function policyValidatorDiagnostic(content, filePath, env) {
|
|
|
96
104
|
missing.push('when');
|
|
97
105
|
if (!onMatch)
|
|
98
106
|
missing.push('on');
|
|
99
|
-
if (missing.length > 0)
|
|
100
|
-
return
|
|
107
|
+
if (missing.length > 0) {
|
|
108
|
+
return {
|
|
109
|
+
diagnostic: `BLOCKED: policy file is missing required trigger frontmatter (missing: ${missing.join(',')}).\n\n${MISSING_TRIGGER_HELP}`,
|
|
110
|
+
whenLines,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { parsed: { normalized, match, frontmatter, whenLines }, whenLines };
|
|
114
|
+
}
|
|
115
|
+
function malformedTriggerDiagnostic(whenLines) {
|
|
116
|
+
let malformedWhen;
|
|
117
|
+
for (const when of whenLines) {
|
|
118
|
+
if (!hasValidTriggerGrammar(when))
|
|
119
|
+
malformedWhen = when;
|
|
120
|
+
}
|
|
121
|
+
if (malformedWhen === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
const when = malformedWhen;
|
|
124
|
+
return `BLOCKED: policy file has a malformed when: trigger expression:\n\n when: ${when}\n\nThe canonical trigger evaluator rejected it. A space is NOT an AND, and quoted\nphrases are NOT tokens. Use explicit boolean operators instead:\n "pull request" -> (pull && request)\n "force-push" -> force-push\n\nUse only identifiers joined by the documented boolean grammar:\n when: <identifier> # e.g. always | git | /deep-plan\n when: <expr> && <expr> # AND\n when: <expr> || <expr> # OR\n when: ! <expr> # NOT\n when: ( <expr> ) # grouping\nIdentifiers may contain letters, digits, _, ., /, and internal -. Quotes,\nYAML block scalars, adjacent identifiers without an operator, and other\npunctuation are not valid. See core/knowledge/public/hq-core/policies-spec.md\n("Trigger Expressions"). Fix the expression, then retry.\n${VALIDATION_NOTE}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Load-time validation: reject policy files that cannot participate in the
|
|
128
|
+
* trigger system. Authoring-only hard-policy constraints remain in the hook.
|
|
129
|
+
*/
|
|
130
|
+
export function policyLoadDiagnostic(content, filePath) {
|
|
131
|
+
if (!isPolicyPath(filePath))
|
|
132
|
+
return undefined;
|
|
133
|
+
// Partial frontmatter (for example `when:` without `on:`) still has its
|
|
134
|
+
// `when:` grammar checked, so a malformed expression is never loaded.
|
|
135
|
+
const { whenLines } = inspectPolicyTriggerFrontmatter(content);
|
|
136
|
+
if (!whenLines)
|
|
137
|
+
return undefined;
|
|
138
|
+
return malformedTriggerDiagnostic(whenLines);
|
|
139
|
+
}
|
|
140
|
+
export function policyValidatorDiagnostic(content, filePath, env) {
|
|
141
|
+
if (!isPolicyPath(filePath))
|
|
142
|
+
return undefined;
|
|
143
|
+
const inspected = inspectPolicyTriggerFrontmatter(content);
|
|
144
|
+
if (inspected.diagnostic)
|
|
145
|
+
return inspected.diagnostic;
|
|
146
|
+
const { normalized, match, frontmatter, whenLines } = inspected.parsed;
|
|
101
147
|
const enforcement = /^[ \t]*enforcement:[ \t]*["']?([A-Za-z]+)["']?/m.exec(frontmatter)?.[1]?.toLowerCase();
|
|
102
148
|
if (enforcement === 'hard') {
|
|
103
149
|
const tautological = whenLines.some((line) => !/[&!]/.test(line.toLowerCase()) && /(^|[^A-Za-z0-9_./-])always([^A-Za-z0-9_./-]|$)/i.test(line));
|
|
@@ -105,7 +151,21 @@ export function policyValidatorDiagnostic(content, filePath, env) {
|
|
|
105
151
|
const reactive = ['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'AssistantIntent'].filter((event) => onLine.includes(event));
|
|
106
152
|
if (tautological && reactive.length > 0) {
|
|
107
153
|
const events = reactive.join(', ');
|
|
108
|
-
return `BLOCKED: an enforcement: hard policy pairs an unconditional \`when:\` with a
|
|
154
|
+
return `BLOCKED: an enforcement: hard policy pairs an unconditional \`when:\` with a
|
|
155
|
+
reactive event (${events}).
|
|
156
|
+
|
|
157
|
+
\`when: always\` is TRUE for every command, prompt, and message. On a reactive
|
|
158
|
+
event the injector ranks that as an event-specific match, so it takes the
|
|
159
|
+
session cap slot of a policy that genuinely matched the thing you are doing —
|
|
160
|
+
the loose trigger crowds out the precise one.
|
|
161
|
+
|
|
162
|
+
Pick one:
|
|
163
|
+
when: always + on: [SessionStart] # ambient governance rule
|
|
164
|
+
when: <real signal> + on: [PreToolUse, ...] # e.g. deploy || publish
|
|
165
|
+
|
|
166
|
+
Name the word(s) that actually appear when the rule is relevant. See
|
|
167
|
+
core/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").
|
|
168
|
+
${VALIDATION_NOTE}`;
|
|
109
169
|
}
|
|
110
170
|
const after = normalized.slice(match.index + match[0].length);
|
|
111
171
|
const binding = [];
|
|
@@ -122,15 +182,6 @@ export function policyValidatorDiagnostic(content, filePath, env) {
|
|
|
122
182
|
return `BLOCKED: enforcement: hard policy body is ${byteCount} bytes; the limit is ${rawMax}.\n\nA hard policy is injected VERBATIM into the session, so its length is a\nrecurring context cost for every session it fires in. Keep the binding rule\ntight and move the reasoning out of the injected span: everything from the\nfirst \`## Rationale\` / \`## Background\` / \`## Change history\` /\n\`## Examples\` / \`## References\` heading onward is NOT counted and NOT\ninjected, so long-form context belongs there.\n\nIf the rule genuinely needs more than that, either split it into separate\npolicies with distinct triggers, or raise HQ_POLICY_HARD_RULE_MAX_BYTES in\n.claude/settings.local.json "env".\n${VALIDATION_NOTE}`;
|
|
123
183
|
}
|
|
124
184
|
}
|
|
125
|
-
|
|
126
|
-
for (const when of whenLines) {
|
|
127
|
-
if (!hasValidTriggerGrammar(when))
|
|
128
|
-
malformedWhen = when;
|
|
129
|
-
}
|
|
130
|
-
if (malformedWhen !== undefined) {
|
|
131
|
-
const when = malformedWhen;
|
|
132
|
-
return `BLOCKED: policy file has a malformed when: trigger expression:\n\n when: ${when}\n\nThe canonical trigger evaluator rejected it. A space is NOT an AND, and quoted\nphrases are NOT tokens. Use explicit boolean operators instead:\n "pull request" -> (pull && request)\n "force-push" -> force-push\n\nUse only identifiers joined by the documented boolean grammar:\n when: <identifier> # e.g. always | git | /deep-plan\n when: <expr> && <expr> # AND\n when: <expr> || <expr> # OR\n when: ! <expr> # NOT\n when: ( <expr> ) # grouping\nIdentifiers may contain letters, digits, _, ., /, and internal -. Quotes,\nYAML block scalars, adjacent identifiers without an operator, and other\npunctuation are not valid. See core/knowledge/public/hq-core/policies-spec.md\n("Trigger Expressions"). Fix the expression, then retry.\n${VALIDATION_NOTE}`;
|
|
133
|
-
}
|
|
134
|
-
return undefined;
|
|
185
|
+
return malformedTriggerDiagnostic(whenLines);
|
|
135
186
|
}
|
|
136
187
|
//# sourceMappingURL=policy-validator.js.map
|