@indigoai-us/hq-cli 5.345.151 → 5.345.153
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 +129 -0
- package/dist/bin/hq-auth-refresh.d.ts +4 -0
- package/dist/bin/hq-auth-refresh.js +9 -1
- package/dist/command-catalog.generated.d.ts +188 -3
- package/dist/command-catalog.generated.js +230 -3
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +12 -0
- package/dist/commands/agent-enroll.js +37 -4
- package/dist/commands/core-policy.js +4 -4
- package/dist/commands/core-session-project-native.js +19 -4
- package/dist/commands/decide.d.ts +13 -0
- package/dist/commands/decide.js +74 -0
- package/dist/commands/files.js +23 -7
- package/dist/commands/integrations-connect.js +24 -1
- package/dist/commands/integrations-core.d.ts +14 -0
- package/dist/commands/integrations-core.js +19 -1
- package/dist/commands/integrations-import.js +22 -1
- package/dist/commands/integrations.js +43 -2
- package/dist/commands/lanes-message.js +4 -11
- package/dist/commands/lanes-questions.js +3 -0
- package/dist/commands/lanes-resume.js +4 -11
- package/dist/commands/lanes-stories.js +3 -0
- package/dist/commands/rescue.d.ts +7 -0
- package/dist/commands/rescue.js +22 -13
- package/dist/commands/run.js +14 -5
- package/dist/commands/secrets.d.ts +13 -0
- package/dist/commands/secrets.js +51 -8
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +186 -186
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
- package/dist/lib/agent-kit/adopt-identity.js +18 -3
- package/dist/lib/agent-kit/services.d.ts +3 -1
- package/dist/lib/agent-kit/services.js +5 -1
- package/dist/lib/daemon/ops/policy.d.ts +9 -1
- package/dist/lib/daemon/ops/policy.js +37 -2
- package/dist/lib/decisions/client.d.ts +67 -0
- package/dist/lib/decisions/client.js +120 -0
- package/dist/lib/decisions/expiry.d.ts +2 -0
- package/dist/lib/decisions/expiry.js +12 -0
- package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
- package/dist/lib/decisions/fixtures/us001.js +21 -0
- package/dist/lib/decisions/import-lanes.d.ts +16 -0
- package/dist/lib/decisions/import-lanes.js +152 -0
- package/dist/lib/decisions/principal.d.ts +11 -0
- package/dist/lib/decisions/principal.js +52 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
- package/dist/lib/doctor/checks/claude-wiring.js +21 -10
- package/dist/lib/doctor/checks/work-context.js +7 -2
- package/dist/lib/doctor/fix/apply.d.ts +3 -2
- package/dist/lib/doctor/fix/apply.js +19 -15
- package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
- package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
- package/dist/lib/doctor/fix/remediation.js +4 -2
- package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
- package/dist/lib/doctor/hook-gate-profiles.js +34 -0
- package/dist/lib/doctor/types.d.ts +5 -0
- package/dist/lib/install/provider-wrappers.js +5 -1
- package/dist/lib/lanes/autopilot.js +4 -1
- package/dist/lib/lanes/imported-question.d.ts +5 -0
- package/dist/lib/lanes/imported-question.js +17 -0
- package/dist/lib/lanes/questions.d.ts +0 -5
- package/dist/lib/lanes/questions.js +2 -0
- package/dist/lib/lanes/reconcile.js +21 -10
- package/dist/lib/lanes/store.d.ts +0 -8
- package/dist/lib/lanes/store.js +2 -1
- package/dist/lib/lanes/stories.d.ts +15 -0
- package/dist/lib/lanes/stories.js +25 -0
- package/dist/lib/mesh/client.d.ts +8 -0
- package/dist/lib/mesh/client.js +44 -3
- package/dist/lib/mesh/denial-codes.js +1 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
- package/dist/lib/mesh/live/daemon/run.js +30 -1
- package/dist/lib/mesh/live/flush.d.ts +7 -0
- package/dist/lib/mesh/live/flush.js +53 -10
- package/dist/lib/registry/registry.js +6 -0
- package/dist/lib/scan-packages/index.js +7 -3
- package/dist/lib/work-context/organize.d.ts +2 -0
- package/dist/lib/work-context/organize.js +22 -4
- package/dist/lib/workers-registry/index.d.ts +12 -1
- package/dist/lib/workers-registry/index.js +39 -1
- package/dist/utils/cognito-session.d.ts +4 -1
- package/dist/utils/cognito-session.js +11 -1
- package/dist/utils/hook-trust.d.ts +27 -0
- package/dist/utils/hook-trust.js +150 -8
- package/dist/utils/shipped-codex-hooks.d.ts +17 -0
- package/dist/utils/shipped-codex-hooks.js +86 -0
- package/package.json +8 -8
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { LanesError } from "./errors.js";
|
|
5
|
+
export function importedQuestionMarker(root, id) {
|
|
6
|
+
return path.join(root, "workspace", "decisions", "legacy-read-only", `${createHash("sha256").update(id).digest("hex")}.json`);
|
|
7
|
+
}
|
|
8
|
+
/** A pending transfer is also read-only: its remote POST may already have succeeded. */
|
|
9
|
+
export function assertQuestionNotImported(root, id) {
|
|
10
|
+
if (isQuestionImported(root, id)) {
|
|
11
|
+
throw new LanesError("question_imported_read_only", "This question was transferred to hq decide and is read-only in hq lanes. Use hq decide list to find and answer the decision.", { question_id: id });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function isQuestionImported(root, id) {
|
|
15
|
+
return fs.existsSync(importedQuestionMarker(root, id));
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=imported-question.js.map
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
|
|
3
|
-
* path and for envelope `decision: "ask"` ingestion. Two writers here would
|
|
4
|
-
* drift on hop, origin, raised_via, body store, and routing_history.
|
|
5
|
-
*/
|
|
6
1
|
import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionPriority, type QuestionRecord, type RaisedVia } from "./types.js";
|
|
7
2
|
export interface QuestionBody {
|
|
8
3
|
question: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertQuestionNotImported } from "./imported-question.js";
|
|
1
2
|
/**
|
|
2
3
|
* Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
|
|
3
4
|
* path and for envelope `decision: "ask"` ingestion. Two writers here would
|
|
@@ -143,6 +144,7 @@ export function questionBodyAbsPath(hqRoot, question) {
|
|
|
143
144
|
return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
|
|
144
145
|
}
|
|
145
146
|
export function writeQuestionBody(hqRoot, question, body) {
|
|
147
|
+
assertQuestionNotImported(hqRoot, question.question_id);
|
|
146
148
|
assertQuestionBodySecretSafe(body);
|
|
147
149
|
const store = parseQuestionBodyStore(question.body.store);
|
|
148
150
|
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
@@ -17,6 +17,7 @@ import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
|
|
|
17
17
|
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
18
18
|
import { laneDepthCap } from "./depth-cap.js";
|
|
19
19
|
import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
|
|
20
|
+
import { isQuestionImported } from "./imported-question.js";
|
|
20
21
|
import { loadWorkerProfile } from "./profile.js";
|
|
21
22
|
import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./grants.js";
|
|
22
23
|
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
|
|
@@ -650,16 +651,26 @@ async function closeInterruptedDoneStoryWorkers(hqRoot, lanes, questions, summar
|
|
|
650
651
|
for (const question of questions) {
|
|
651
652
|
if (question.origin.id !== lane.lane_id || question.status !== "pending")
|
|
652
653
|
continue;
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
654
|
+
if (isQuestionImported(hqRoot, question.question_id))
|
|
655
|
+
continue;
|
|
656
|
+
try {
|
|
657
|
+
casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending", (current) => ({
|
|
658
|
+
...current,
|
|
659
|
+
status: "cancelled",
|
|
660
|
+
routing_history: appendCappedHistory(current.routing_history, {
|
|
661
|
+
hop: current.hop,
|
|
662
|
+
audience: { kind: "user" },
|
|
663
|
+
at,
|
|
664
|
+
reason: "cancelled",
|
|
665
|
+
}),
|
|
666
|
+
}), false);
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
// Import may acquire the record lock after our initial check. The
|
|
670
|
+
// decision service owns that question; continue closing this lane.
|
|
671
|
+
if (!(error instanceof LanesError) || error.error !== "question_imported_read_only")
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
663
674
|
}
|
|
664
675
|
appendWatcherEvent(watcherEventsPath(hqRoot, lane.lane_id), {
|
|
665
676
|
event: "story_closed",
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File-backed lane / question record store (spec §10, §10.1).
|
|
3
|
-
*
|
|
4
|
-
* Root: workspace/lanes/ under the HQ root. One stable path per record;
|
|
5
|
-
* `status` is the pending/answered discriminator — files are never moved
|
|
6
|
-
* between directories. Every mutation is temp-file-plus-rename and a
|
|
7
|
-
* compare-and-set on `version`. Histories are capped at 50 at the append site.
|
|
8
|
-
*/
|
|
9
1
|
import * as fs from "node:fs";
|
|
10
2
|
import { type OutpostIdentity } from "./admission-host.js";
|
|
11
3
|
import { type AttributionHistoryEntry, type LaneRecord, type LaneWriter, type QuestionRecord, type RoutingHistoryEntry, type SeniorHistoryEntry } from "./types.js";
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertQuestionNotImported } from "./imported-question.js";
|
|
1
2
|
/**
|
|
2
3
|
* File-backed lane / question record store (spec §10, §10.1).
|
|
3
4
|
*
|
|
@@ -2013,7 +2014,7 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
2013
2014
|
return created;
|
|
2014
2015
|
}
|
|
2015
2016
|
export function updateQuestion(hqRoot, questionId, expectedVersion, updater, deps = defaultStoreDeps()) {
|
|
2016
|
-
const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, updater, deps);
|
|
2017
|
+
const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, (current) => { assertQuestionNotImported(hqRoot, questionId); return updater(current); }, deps);
|
|
2017
2018
|
syncPendingMarker(hqRoot, next, deps);
|
|
2018
2019
|
syncLaneQuestionReference(hqRoot, next, deps);
|
|
2019
2020
|
return next;
|
|
@@ -20,4 +20,19 @@ export declare function storyStatusForEnvelope(status: string): "in_progress" |
|
|
|
20
20
|
* and explicit story commands own those transitions.
|
|
21
21
|
*/
|
|
22
22
|
export declare function terminalEnvelopeStoryStatus(lane: LaneRecord, decision: "done" | "blocked" | "ask", envelopeStatus?: string): LaneStoryStatus | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* The stories array after attributing a lane to projectId/storyId.
|
|
25
|
+
*
|
|
26
|
+
* A lane with no story (empty story_id plus no_story_reason) carries no story
|
|
27
|
+
* rows, so attributing it to another project without a story keeps the rows
|
|
28
|
+
* unchanged. This is the single write site for attribution-driven appends:
|
|
29
|
+
* it never produces a row with an empty story_id, which the lane schema
|
|
30
|
+
* rejects (and which would roll back the whole write).
|
|
31
|
+
*/
|
|
32
|
+
export declare function storiesAfterAttribution(lane: LaneRecord, projectId: string, storyId: string, at: string): LaneStory[];
|
|
33
|
+
/**
|
|
34
|
+
* The row attribution would append, or undefined when nothing is appended
|
|
35
|
+
* (the story is already carried, or there is no story id).
|
|
36
|
+
*/
|
|
37
|
+
export declare function attributedStoryRow(lane: LaneRecord, projectId: string, storyId: string, at: string): LaneStory | undefined;
|
|
23
38
|
//# sourceMappingURL=stories.d.ts.map
|
|
@@ -89,4 +89,29 @@ export function terminalEnvelopeStoryStatus(lane, decision, envelopeStatus = "in
|
|
|
89
89
|
return "review";
|
|
90
90
|
return storyStatusForEnvelope(envelopeStatus);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The stories array after attributing a lane to projectId/storyId.
|
|
94
|
+
*
|
|
95
|
+
* A lane with no story (empty story_id plus no_story_reason) carries no story
|
|
96
|
+
* rows, so attributing it to another project without a story keeps the rows
|
|
97
|
+
* unchanged. This is the single write site for attribution-driven appends:
|
|
98
|
+
* it never produces a row with an empty story_id, which the lane schema
|
|
99
|
+
* rejects (and which would roll back the whole write).
|
|
100
|
+
*/
|
|
101
|
+
export function storiesAfterAttribution(lane, projectId, storyId, at) {
|
|
102
|
+
const current = laneStories(lane);
|
|
103
|
+
const added = attributedStoryRow(lane, projectId, storyId, at);
|
|
104
|
+
return added ? [...current, added] : current;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The row attribution would append, or undefined when nothing is appended
|
|
108
|
+
* (the story is already carried, or there is no story id).
|
|
109
|
+
*/
|
|
110
|
+
export function attributedStoryRow(lane, projectId, storyId, at) {
|
|
111
|
+
if (!storyId.trim())
|
|
112
|
+
return undefined;
|
|
113
|
+
if (laneStory(lane, projectId, storyId))
|
|
114
|
+
return undefined;
|
|
115
|
+
return { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at };
|
|
116
|
+
}
|
|
92
117
|
//# sourceMappingURL=stories.js.map
|
|
@@ -54,6 +54,14 @@ export declare function createWorkSessionDeliverer(opts: {
|
|
|
54
54
|
body: unknown;
|
|
55
55
|
}>;
|
|
56
56
|
}): DeliverFn;
|
|
57
|
+
/**
|
|
58
|
+
* Outbox error code for a 401/403 register refusal. Only real auth / identity
|
|
59
|
+
* refusals are AUTH_DENIED. A company whose work registry is off
|
|
60
|
+
* (WORK_REGISTRY_DISABLED) or an unknown company (INVALID_COMPANY) is not an
|
|
61
|
+
* auth problem, so those keep their own code and doctor does not point at
|
|
62
|
+
* login (Shepherd issue 2726, feedback_6a792ab4).
|
|
63
|
+
*/
|
|
64
|
+
export declare function registerDenialOutboxCode(serverCode: string | undefined): string;
|
|
57
65
|
/**
|
|
58
66
|
* Probe whether the server advertises migration capability (US-017A).
|
|
59
67
|
* Failures return false (default-company stays locked).
|
package/dist/lib/mesh/client.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* vault HTTP + receipt-shaped responses (US-007A / US-007B).
|
|
5
5
|
*/
|
|
6
6
|
import { listActiveMembershipCompanies, meshJson, requireToken, resolveMeshCompany, } from "./api.js";
|
|
7
|
-
import { isWorkSessionDenialCode } from "./denial-codes.js";
|
|
7
|
+
import { isWorkSessionDenialCode, WORK_SESSION_DENIAL_CODES, } from "./denial-codes.js";
|
|
8
8
|
export { meshJson, requireToken, resolveMeshCompany, listActiveMembershipCompanies };
|
|
9
9
|
export async function openMeshTransport(opts) {
|
|
10
10
|
const token = await requireToken();
|
|
@@ -63,11 +63,11 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
63
63
|
if (status === 401 || status === 403) {
|
|
64
64
|
const serverCode = isWorkSessionDenialCode(errorFields.code)
|
|
65
65
|
? errorFields.code
|
|
66
|
-
:
|
|
66
|
+
: denialCodeFromMessage(message);
|
|
67
67
|
return {
|
|
68
68
|
ok: false,
|
|
69
69
|
retryable: false,
|
|
70
|
-
code:
|
|
70
|
+
code: registerDenialOutboxCode(serverCode),
|
|
71
71
|
httpStatus: status,
|
|
72
72
|
...(serverCode ? { serverCode } : {}),
|
|
73
73
|
};
|
|
@@ -98,6 +98,15 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
98
98
|
return { ok: false, retryable: false, code: `HTTP_${status}` };
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
const messageCode = denialCodeFromMessage(message);
|
|
102
|
+
if (messageCode && registerDenialOutboxCode(messageCode) !== "AUTH_DENIED") {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
retryable: false,
|
|
106
|
+
code: registerDenialOutboxCode(messageCode),
|
|
107
|
+
serverCode: messageCode,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
101
110
|
if (/unauthorized|forbidden|auth/i.test(message)) {
|
|
102
111
|
return { ok: false, retryable: false, code: "AUTH_DENIED" };
|
|
103
112
|
}
|
|
@@ -112,6 +121,27 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
112
121
|
}
|
|
113
122
|
};
|
|
114
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Outbox error code for a 401/403 register refusal. Only real auth / identity
|
|
126
|
+
* refusals are AUTH_DENIED. A company whose work registry is off
|
|
127
|
+
* (WORK_REGISTRY_DISABLED) or an unknown company (INVALID_COMPANY) is not an
|
|
128
|
+
* auth problem, so those keep their own code and doctor does not point at
|
|
129
|
+
* login (Shepherd issue 2726, feedback_6a792ab4).
|
|
130
|
+
*/
|
|
131
|
+
export function registerDenialOutboxCode(serverCode) {
|
|
132
|
+
if (serverCode === "WORK_REGISTRY_DISABLED" || serverCode === "INVALID_COMPANY") {
|
|
133
|
+
return serverCode;
|
|
134
|
+
}
|
|
135
|
+
return "AUTH_DENIED";
|
|
136
|
+
}
|
|
137
|
+
/** Find an allowlisted denial code in an error message when the error has no code field. */
|
|
138
|
+
function denialCodeFromMessage(message) {
|
|
139
|
+
for (const code of WORK_SESSION_DENIAL_CODES) {
|
|
140
|
+
if (new RegExp(`\\b${code}\\b`).test(message))
|
|
141
|
+
return code;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
115
145
|
function mapRegisterResponse(status, body, operationId) {
|
|
116
146
|
if (status >= 200 && status < 300) {
|
|
117
147
|
const rec = body && typeof body === "object" ? body : {};
|
|
@@ -124,6 +154,17 @@ function mapRegisterResponse(status, body, operationId) {
|
|
|
124
154
|
if (status >= 500 || status === 429) {
|
|
125
155
|
return { ok: false, retryable: true, code: `HTTP_${status}` };
|
|
126
156
|
}
|
|
157
|
+
if (status === 401 || status === 403) {
|
|
158
|
+
const rec = body && typeof body === "object" ? body : {};
|
|
159
|
+
const serverCode = isWorkSessionDenialCode(rec.code) ? rec.code : undefined;
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
retryable: false,
|
|
163
|
+
code: registerDenialOutboxCode(serverCode),
|
|
164
|
+
httpStatus: status,
|
|
165
|
+
...(serverCode ? { serverCode } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
127
168
|
if (status >= 400 && status < 500) {
|
|
128
169
|
return { ok: false, retryable: false, code: `HTTP_${status}` };
|
|
129
170
|
}
|
|
@@ -26,7 +26,7 @@ export function isWorkSessionDenialCode(value) {
|
|
|
26
26
|
}
|
|
27
27
|
/** Plain-language reasons for denial codes the user can act on. */
|
|
28
28
|
const WORK_SESSION_DENIAL_REASONS = {
|
|
29
|
-
WORK_REGISTRY_DISABLED: "Work
|
|
29
|
+
WORK_REGISTRY_DISABLED: "Work registry is disabled for this company; ask a company admin to enable it with: hq company --company <slug> settings set --work-registry-enabled true. This is not a login problem.",
|
|
30
30
|
FEATURE_DISABLED: "Live work tracking is turned off for this company. The company owner can turn it on with: hq company --company <slug> settings set --work-mesh-live-enabled true",
|
|
31
31
|
};
|
|
32
32
|
/** Plain reason for a server denial code, or undefined when there is none. */
|
|
@@ -24,6 +24,12 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
|
|
|
24
24
|
export declare const FLUSH_INTERVAL_MS = 10000;
|
|
25
25
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
26
26
|
export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
27
|
+
/**
|
|
28
|
+
* A long-running daemon re-runs the held backfill this often while held
|
|
29
|
+
* events remain, so a backlog larger than one batch keeps draining after a
|
|
30
|
+
* reinstall instead of waiting for the next daemon start (Shepherd 2361).
|
|
31
|
+
*/
|
|
32
|
+
export declare const HELD_BACKFILL_INTERVAL_MS: number;
|
|
27
33
|
export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
|
|
28
34
|
/** Skip further membership probes after a transport failure so a network drop
|
|
29
35
|
* cannot fan out one hanging lookup per held session. */
|
|
@@ -60,10 +66,12 @@ export interface DaemonRunDeps {
|
|
|
60
66
|
quarantined: number;
|
|
61
67
|
skipped?: number;
|
|
62
68
|
}>;
|
|
63
|
-
/** Injected held-session recovery (tests). Runs
|
|
69
|
+
/** Injected held-session recovery (tests). Runs before the startup flush and then periodically while held events remain. */
|
|
64
70
|
backfillHeld?: () => Promise<BackfillHeldResult>;
|
|
65
71
|
/** Maximum distinct held sessions reconciled during one daemon startup. */
|
|
66
72
|
heldBackfillBatchSize?: number;
|
|
73
|
+
/** Minimum gap between periodic held backfills. Default HELD_BACKFILL_INTERVAL_MS. */
|
|
74
|
+
heldBackfillIntervalMs?: number;
|
|
67
75
|
/** Injected board refresh (tests). */
|
|
68
76
|
refreshBoards?: () => Promise<{
|
|
69
77
|
refreshed: number;
|
|
@@ -46,6 +46,12 @@ export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
|
46
46
|
export const FLUSH_INTERVAL_MS = 10_000;
|
|
47
47
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
48
48
|
export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
49
|
+
/**
|
|
50
|
+
* A long-running daemon re-runs the held backfill this often while held
|
|
51
|
+
* events remain, so a backlog larger than one batch keeps draining after a
|
|
52
|
+
* reinstall instead of waiting for the next daemon start (Shepherd 2361).
|
|
53
|
+
*/
|
|
54
|
+
export const HELD_BACKFILL_INTERVAL_MS = 15 * 60 * 1_000;
|
|
49
55
|
export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
|
|
50
56
|
/** Skip further membership probes after a transport failure so a network drop
|
|
51
57
|
* cannot fan out one hanging lookup per held session. */
|
|
@@ -350,6 +356,19 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
350
356
|
};
|
|
351
357
|
/** In-memory held re-attempt throttle (survives watch-triggered flushes). */
|
|
352
358
|
let lastHeldRetryAtMs = 0;
|
|
359
|
+
/** Epoch ms of the last held backfill (startup or periodic). */
|
|
360
|
+
let lastHeldBackfillAtMs = 0;
|
|
361
|
+
const heldBackfillIntervalMs = deps.heldBackfillIntervalMs ?? HELD_BACKFILL_INTERVAL_MS;
|
|
362
|
+
const heldBackfillDue = (atMs) => {
|
|
363
|
+
if (atMs - lastHeldBackfillAtMs < heldBackfillIntervalMs)
|
|
364
|
+
return false;
|
|
365
|
+
try {
|
|
366
|
+
return fs.statSync(workMeshHeldPath(meshRoot)).size > 0;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
353
372
|
const shutdownController = new AbortController();
|
|
354
373
|
const flushFn = deps.flush ??
|
|
355
374
|
(async (signal) => {
|
|
@@ -380,6 +399,12 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
380
399
|
return "unavailable";
|
|
381
400
|
return authorization.status === "authorized";
|
|
382
401
|
},
|
|
402
|
+
// Held sessions whose local evidence only names a slug need the
|
|
403
|
+
// membership slug -> uid lookup, or they stay NEEDS_COMPANY forever.
|
|
404
|
+
resolveCompanyUid: async (slug) => {
|
|
405
|
+
const authorization = await tenantGate.authorize({ companySlug: slug, deviceDefault: false });
|
|
406
|
+
return authorization.status === "authorized" ? authorization.uid : null;
|
|
407
|
+
},
|
|
383
408
|
});
|
|
384
409
|
});
|
|
385
410
|
const replayFn = deps.replayOutbox ??
|
|
@@ -674,7 +699,11 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
674
699
|
// A held session has already ended, so no later hook will naturally
|
|
675
700
|
// revisit its context. Repair a bounded batch before the first flush;
|
|
676
701
|
// recovered state is then retried immediately in this same cycle.
|
|
677
|
-
|
|
702
|
+
// A long-running daemon repeats the bounded batch on an interval
|
|
703
|
+
// while held events remain, so the cursor keeps rotating through a
|
|
704
|
+
// backlog larger than one batch.
|
|
705
|
+
if (reason === "start" || heldBackfillDue(now().getTime())) {
|
|
706
|
+
lastHeldBackfillAtMs = now().getTime();
|
|
678
707
|
try {
|
|
679
708
|
const recovered = await backfillFn();
|
|
680
709
|
if (recovered.reconciled > 0 ||
|
|
@@ -58,6 +58,13 @@ export interface FlushDeps {
|
|
|
58
58
|
* stripped. Defaults to a per-flush cached git resolver (tests inject).
|
|
59
59
|
*/
|
|
60
60
|
resolveWorkLocation?: WorkLocationResolver;
|
|
61
|
+
/**
|
|
62
|
+
* Membership-validated slug -> companyUid lookup for held sessions whose
|
|
63
|
+
* local evidence names a company slug but no uid. Return the uid, or null
|
|
64
|
+
* when the caller is not a member or the lookup is unavailable (the events
|
|
65
|
+
* stay held and are retried). Called at most once per slug per flush.
|
|
66
|
+
*/
|
|
67
|
+
resolveCompanyUid?: (slug: string) => Promise<string | null>;
|
|
61
68
|
}
|
|
62
69
|
export interface FlushSummary {
|
|
63
70
|
claimed: number;
|
|
@@ -262,6 +262,7 @@ export async function flushSessionEvents(inputDeps) {
|
|
|
262
262
|
pendingRestore.delete(event);
|
|
263
263
|
};
|
|
264
264
|
const loggedProject404 = new Set();
|
|
265
|
+
const heldUidCache = new Map();
|
|
265
266
|
sessionLoop: for (const [sessionId, items] of bySession) {
|
|
266
267
|
if (deps.signal?.aborted)
|
|
267
268
|
break sessionLoop;
|
|
@@ -283,7 +284,8 @@ export async function flushSessionEvents(inputDeps) {
|
|
|
283
284
|
if (leaveResult.abort)
|
|
284
285
|
continue;
|
|
285
286
|
const remainingItems = leaveResult.items;
|
|
286
|
-
|
|
287
|
+
const heldBinding = bindHeldCompanyIfResolved(sessionId, remainingItems, state, deps, now, heldUidCache);
|
|
288
|
+
state = heldBinding instanceof Promise ? await heldBinding : heldBinding;
|
|
287
289
|
if (state?.companyConfidence === "device_default" &&
|
|
288
290
|
deps.authorizeDeviceDefaultTenant) {
|
|
289
291
|
const allowed = await deps.authorizeDeviceDefaultTenant(state);
|
|
@@ -646,12 +648,12 @@ const SESSION_EVENT_HARNESSES = new Set([
|
|
|
646
648
|
"agent-box",
|
|
647
649
|
"agents-v2",
|
|
648
650
|
]);
|
|
649
|
-
function
|
|
651
|
+
function resolveHeldCompany(sessionId, items, state, deps) {
|
|
650
652
|
if (state?.companyUid)
|
|
651
|
-
return
|
|
653
|
+
return null;
|
|
652
654
|
const sample = items[0]?.event;
|
|
653
655
|
if (!sample)
|
|
654
|
-
return
|
|
656
|
+
return null;
|
|
655
657
|
const cwd = eventText(sample, "cwd");
|
|
656
658
|
const hqRoot = eventText(sample, "hqRoot");
|
|
657
659
|
const remoteOwnerSlug = cwd
|
|
@@ -675,14 +677,16 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
|
|
|
675
677
|
remoteOwnerSlug,
|
|
676
678
|
repoIdentityKey,
|
|
677
679
|
});
|
|
678
|
-
if (resolution.status !== "resolved"
|
|
679
|
-
return
|
|
680
|
+
if (resolution.status !== "resolved")
|
|
681
|
+
return null;
|
|
680
682
|
if (resolution.company.source === "trusted_explicit" &&
|
|
681
683
|
!eventText(sample, "companySlug")) {
|
|
682
|
-
return
|
|
684
|
+
return null;
|
|
683
685
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
+
return resolution.company;
|
|
687
|
+
}
|
|
688
|
+
function writeHeldCompanyBinding(sessionId, state, company, deps, now) {
|
|
689
|
+
const { uid, slug } = company;
|
|
686
690
|
const nowIso = now().toISOString();
|
|
687
691
|
const next = updateSessionState(sessionId, deps.workContextRoot, (current) => {
|
|
688
692
|
const base = current ?? state;
|
|
@@ -703,7 +707,7 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
|
|
|
703
707
|
contractVersion: base.contractVersion ?? 1,
|
|
704
708
|
companyUid: uid,
|
|
705
709
|
...(slug ? { companySlug: slug } : {}),
|
|
706
|
-
...(
|
|
710
|
+
...(company.source === "device_default"
|
|
707
711
|
? { companyConfidence: "device_default" }
|
|
708
712
|
: {}),
|
|
709
713
|
contextStatus,
|
|
@@ -712,6 +716,45 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
|
|
|
712
716
|
});
|
|
713
717
|
return next ?? readSessionState(sessionId, deps.workContextRoot) ?? state;
|
|
714
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Bind a held session to a company. Local evidence with a uid binds directly.
|
|
721
|
+
* Local evidence that only names a slug (session meta, cwd, git remote) needs
|
|
722
|
+
* a membership-validated slug -> uid lookup; without it the session would stay
|
|
723
|
+
* NEEDS_COMPANY on every retry (Shepherd 2361).
|
|
724
|
+
*/
|
|
725
|
+
function bindHeldCompanyIfResolved(sessionId, items, state, deps, now, uidCache) {
|
|
726
|
+
// Stay synchronous unless a uid lookup is needed: the flush loop's
|
|
727
|
+
// microtask ordering is relied on by retry/back-pressure timing.
|
|
728
|
+
const company = resolveHeldCompany(sessionId, items, state, deps);
|
|
729
|
+
if (!company)
|
|
730
|
+
return state;
|
|
731
|
+
if (company.uid) {
|
|
732
|
+
return writeHeldCompanyBinding(sessionId, state, { uid: company.uid, slug: company.slug, source: company.source }, deps, now);
|
|
733
|
+
}
|
|
734
|
+
const slug = company.slug?.trim().toLowerCase();
|
|
735
|
+
if (!slug || !deps.resolveCompanyUid || company.source === "device_default")
|
|
736
|
+
return state;
|
|
737
|
+
let pending = uidCache.get(slug);
|
|
738
|
+
if (!pending) {
|
|
739
|
+
const resolver = deps.resolveCompanyUid;
|
|
740
|
+
pending = (async () => {
|
|
741
|
+
try {
|
|
742
|
+
const uid = await resolver(slug);
|
|
743
|
+
return typeof uid === "string" && uid.trim() ? uid.trim() : null;
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
deps.log?.(`held company uid lookup failed for slug=${slug}: ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`);
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
})();
|
|
750
|
+
uidCache.set(slug, pending);
|
|
751
|
+
}
|
|
752
|
+
return pending.then((uid) => {
|
|
753
|
+
if (!uid)
|
|
754
|
+
return state;
|
|
755
|
+
return writeHeldCompanyBinding(sessionId, state, { uid, slug: company.slug, source: company.source }, deps, now);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
715
758
|
function applySessionMetaCompanyRebind(sessionId, items, state, deps, now) {
|
|
716
759
|
if (!state)
|
|
717
760
|
return state;
|
|
@@ -90,6 +90,12 @@ export function pathKey(dir) {
|
|
|
90
90
|
export function canonicalDir(dir) {
|
|
91
91
|
const abs = path.resolve(dir);
|
|
92
92
|
try {
|
|
93
|
+
// macOS volumes are case-insensitive by default: /x/CaseRepo and
|
|
94
|
+
// /x/caserepo are one folder, but the JS realpath keeps the caller's
|
|
95
|
+
// spelling, so the same repo produced two registry keys. realpath(3)
|
|
96
|
+
// returns the on-disk spelling. Other platforms keep the JS realpath.
|
|
97
|
+
if (process.platform === "darwin")
|
|
98
|
+
return fs.realpathSync.native(abs);
|
|
93
99
|
return fs.realpathSync(abs);
|
|
94
100
|
}
|
|
95
101
|
catch {
|
|
@@ -4,7 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import * as yaml from 'js-yaml';
|
|
5
5
|
import { contributionLinks } from '../../utils/pack-contribution-links.js';
|
|
6
6
|
import { CONTRIBUTION_TABLE } from '../../utils/contribution-table.js';
|
|
7
|
-
import { isExcludedWorkerDirectory } from '../workers-registry/index.js';
|
|
7
|
+
import { isExcludedWorkerDirectory, isNestedWorkerCheckout } from '../workers-registry/index.js';
|
|
8
8
|
function isDirectory(target) { try {
|
|
9
9
|
return fs.statSync(target).isDirectory();
|
|
10
10
|
}
|
|
@@ -77,8 +77,9 @@ function allWorkerYamlFiles(root, relative) {
|
|
|
77
77
|
const output = [];
|
|
78
78
|
const walk = (next, seen) => {
|
|
79
79
|
// Prune exactly what the worker registry prunes (tenant repos/ checkouts,
|
|
80
|
-
// _template, _overrides). A worker.yaml the registry
|
|
81
|
-
// collide with a pack worker, and walking source
|
|
80
|
+
// nested git checkouts, _template, _overrides). A worker.yaml the registry
|
|
81
|
+
// never indexes cannot collide with a pack worker, and walking source
|
|
82
|
+
// checkouts is slow.
|
|
82
83
|
if (isExcludedWorkerDirectory(next))
|
|
83
84
|
return;
|
|
84
85
|
const absolute = path.join(root, next);
|
|
@@ -101,7 +102,10 @@ function allWorkerYamlFiles(root, relative) {
|
|
|
101
102
|
catch {
|
|
102
103
|
return;
|
|
103
104
|
}
|
|
105
|
+
const checkout = isNestedWorkerCheckout(relative, next, names);
|
|
104
106
|
for (const name of names) {
|
|
107
|
+
if (checkout && name !== 'worker.yaml')
|
|
108
|
+
continue;
|
|
105
109
|
const child = path.join(next, name);
|
|
106
110
|
const childAbsolute = path.join(root, child);
|
|
107
111
|
if (name === 'worker.yaml' && (() => { try {
|
|
@@ -92,6 +92,8 @@ export declare function prepareOrganizeDecision(input: {
|
|
|
92
92
|
/** Skip network; leave unresolved without candidates. */
|
|
93
93
|
offline?: boolean;
|
|
94
94
|
}): Promise<OrganizeListResult>;
|
|
95
|
+
/** True when a local decision's server window has passed (or is unparsable). */
|
|
96
|
+
export declare function isLocalDecisionExpired(expiresAt: string | undefined, now?: Date): boolean;
|
|
95
97
|
export declare function formatOrganizeList(result: OrganizeListResult): string[];
|
|
96
98
|
/**
|
|
97
99
|
* Mark that the ask was cancelled / timed out / surface incapable.
|
|
@@ -114,8 +114,17 @@ export async function prepareOrganizeDecision(input) {
|
|
|
114
114
|
reason: state.contextStatus,
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
|
-
// Ask-once: an existing decision is never replaced or re-armed
|
|
118
|
-
|
|
117
|
+
// Ask-once: an existing decision is never replaced or re-armed — unless its
|
|
118
|
+
// server decision window has expired. The server stamps a fresh window only
|
|
119
|
+
// when candidates are listed, so returning the stale decision would make
|
|
120
|
+
// every later organize fail with DECISION_EXPIRED / DECISION_WINDOW_MISSING.
|
|
121
|
+
const refreshExpired = Boolean(state.decision?.decisionId) &&
|
|
122
|
+
state.decision?.askAfter === true &&
|
|
123
|
+
isLocalDecisionExpired(state.decision?.expiresAt, nowFn()) &&
|
|
124
|
+
Boolean(state.companyUid) &&
|
|
125
|
+
!input.offline &&
|
|
126
|
+
Boolean(input.fetchCandidates);
|
|
127
|
+
if (state.decision?.decisionId && !refreshExpired) {
|
|
119
128
|
return {
|
|
120
129
|
sessionId: input.sessionId,
|
|
121
130
|
contextStatus: state.contextStatus,
|
|
@@ -124,7 +133,7 @@ export async function prepareOrganizeDecision(input) {
|
|
|
124
133
|
reason: state.decision.askAfter ? "pending_ask" : "ask_already_settled",
|
|
125
134
|
};
|
|
126
135
|
}
|
|
127
|
-
const ready = shouldAskAfter({
|
|
136
|
+
const ready = refreshExpired || shouldAskAfter({
|
|
128
137
|
toolWrites: state.toolWrites,
|
|
129
138
|
startedAt: state.startedAt,
|
|
130
139
|
hasTurn: state.hasTurn,
|
|
@@ -168,9 +177,18 @@ export async function prepareOrganizeDecision(input) {
|
|
|
168
177
|
contextStatus: next.contextStatus,
|
|
169
178
|
pending: true,
|
|
170
179
|
decision,
|
|
171
|
-
reason: "ask_prepared",
|
|
180
|
+
reason: refreshExpired ? "ask_refreshed" : "ask_prepared",
|
|
172
181
|
};
|
|
173
182
|
}
|
|
183
|
+
/** True when a local decision's server window has passed (or is unparsable). */
|
|
184
|
+
export function isLocalDecisionExpired(expiresAt, now = new Date()) {
|
|
185
|
+
if (!expiresAt)
|
|
186
|
+
return false;
|
|
187
|
+
const ms = Date.parse(expiresAt);
|
|
188
|
+
if (!Number.isFinite(ms))
|
|
189
|
+
return true;
|
|
190
|
+
return now.getTime() >= ms;
|
|
191
|
+
}
|
|
174
192
|
export function formatOrganizeList(result) {
|
|
175
193
|
const lines = [
|
|
176
194
|
`session ${result.sessionId} status=${result.contextStatus}` +
|
|
@@ -28,9 +28,20 @@ export interface GenerateWorkersRegistryResult {
|
|
|
28
28
|
* (`.codex/worktrees/<name>/`) are the same trap in a different folder: a
|
|
29
29
|
* nested HQ root under a company project. Those copies land in the registry as
|
|
30
30
|
* `companies/<slug>/…/.claude/worktrees/…` or `.codex/worktrees/…` paths, so
|
|
31
|
-
* `hq lanes create` then fails with `worker_tenancy_unresolved`.
|
|
31
|
+
* `hq lanes create` then fails with `worker_tenancy_unresolved`. HQ's own
|
|
32
|
+
* `workspace/worktrees/<name>/` is the same shape.
|
|
33
|
+
*
|
|
34
|
+
* `node_modules` and `.git` never hold workers and are large, so they are
|
|
35
|
+
* skipped wherever they appear. Any other checkout (a directory holding `.git`)
|
|
36
|
+
* is caught by the walk itself, because only the filesystem can tell.
|
|
32
37
|
*/
|
|
33
38
|
export declare function isExcludedWorkerDirectory(relativeDirectory: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* True when `relativeDirectory`, found while walking `scanRoot`, is a separate
|
|
41
|
+
* git checkout (its listing `names` holds `.git`). The caller may still take a
|
|
42
|
+
* `worker.yaml` directly inside it, but must not descend any further.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isNestedWorkerCheckout(scanRoot: string, relativeDirectory: string, names: readonly string[]): boolean;
|
|
34
45
|
/**
|
|
35
46
|
* Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
|
|
36
47
|
* are quarantined but do not prevent all valid workers from being registered.
|