@bli-cockpit/cli 0.2.99 → 0.2.101
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/dist/agent-rules.js +2 -1
- package/dist/backfill-lock.js +1 -1
- package/dist/commands/backfill-checkpoint.js +3 -1
- package/dist/commands/backfill-issues.js +8 -55
- package/dist/commands/backfill-report.js +22 -6
- package/dist/commands/backfill-scan.js +2 -1
- package/dist/commands/backfill-skip-policy.js +134 -0
- package/dist/commands/careers.js +16 -0
- package/dist/commands/doctor-access.js +34 -10
- package/dist/commands/doctor-lock-wait.js +46 -0
- package/dist/commands/doctor-pipeline-verdicts.js +238 -0
- package/dist/commands/doctor-pipeline.js +49 -111
- package/dist/commands/doctor-registration.js +23 -2
- package/dist/commands/doctor-report.js +48 -9
- package/dist/commands/doctor-update.js +16 -5
- package/dist/commands/doctor.js +98 -58
- package/dist/commands/local-args-collector-setup.js +6 -0
- package/dist/commands/local-args-tower-careers.js +20 -0
- package/dist/commands/local-args-tower-pages.js +17 -2
- package/dist/commands/local-args-tower-usage.js +2 -2
- package/dist/commands/local-args-tower.js +2 -1
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower.js +4 -2
- package/dist/commands/local-help-commands.js +28 -12
- package/dist/commands/local-help.js +4 -2
- package/dist/commands/local.js +4 -0
- package/dist/commands/notes-file.js +8 -1
- package/dist/commands/notes-folders.js +35 -0
- package/dist/commands/notes-writes.js +37 -4
- package/dist/commands/notes.js +6 -0
- package/dist/commands/public-root.js +4 -4
- package/dist/commands/usage-format.js +18 -0
- package/dist/commands/usage.js +13 -3
- package/dist/cursors/backfill-completion-marker.js +135 -0
- package/dist/cursors/backfill-cursor.js +18 -99
- package/dist/scheduled-self-update.js +1 -1
- package/dist/sync-lock.js +15 -1
- package/package.json +2 -2
package/dist/agent-rules.js
CHANGED
|
@@ -141,13 +141,14 @@ export function cockpitAgentRulesBlock(options = {}) {
|
|
|
141
141
|
? `- Only applies when the current working directory is inside the Tower-onboarded workspace/repo: \`${scopePaths[0]}\`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.`
|
|
142
142
|
: scopePaths.length > 1
|
|
143
143
|
? `- Only applies when the current working directory is inside one of these Tower-onboarded workspace roots: ${scopePaths.map((scopePath) => `\`${scopePath}\``).join(", ")}. Outside those folders, do not run Tower ticket binding or sync commands for private chats or unrelated repos.`
|
|
144
|
-
: "- Only applies when the current working directory is inside the workspace/repo that ran `cockpit
|
|
144
|
+
: "- Only applies when the current working directory is inside the workspace/repo that ran `cockpit doctor` or `cockpit agent-rules install`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.";
|
|
145
145
|
return [
|
|
146
146
|
MANAGED_BLOCK_START,
|
|
147
147
|
"## Tower Ticket Binding",
|
|
148
148
|
"",
|
|
149
149
|
scopeLine,
|
|
150
150
|
"- Ticketed work (implement / debug / review / PR / ship): run `cockpit start --ticket <ticket-id> --workspace \"$PWD\"` before the first code edit (the flag is `--ticket`). No ticket ID visible → search Linear first; none exists and the work is ticket-worthy → create a narrow Linear ticket, then bind.",
|
|
151
|
+
"- If setup or collection needs repair, run `cockpit doctor`. It repairs this machine and prints any remaining action.",
|
|
151
152
|
"- Truly no ticket → say the session stays in general ambient capture; never invent one.",
|
|
152
153
|
"- After the first meaningful checkpoint, run `cockpit sync --workspace \"$PWD\" --json`.",
|
|
153
154
|
"",
|
package/dist/backfill-lock.js
CHANGED
|
@@ -54,7 +54,7 @@ export async function inspectBackfillLock(paths, now = new Date()) {
|
|
|
54
54
|
now.getTime() - record.heartbeat_ms > BACKFILL_LOCK_STALE_TAKEOVER_MS) {
|
|
55
55
|
return { held: false, held_since: null };
|
|
56
56
|
}
|
|
57
|
-
return { held: true, held_since: record.heartbeat_at };
|
|
57
|
+
return { held: true, held_since: record.heartbeat_at, pid: record.pid };
|
|
58
58
|
}
|
|
59
59
|
export function backfillLockPath(paths) {
|
|
60
60
|
return path.join(paths.cursors_dir, BACKFILL_LOCK_FILENAME);
|
|
@@ -77,8 +77,10 @@ export function advanceBackfillCursorThroughResolvedPrefix(options) {
|
|
|
77
77
|
.sort(compareBackfillCandidates);
|
|
78
78
|
for (const candidate of remaining) {
|
|
79
79
|
const key = candidateCursorKey(candidate);
|
|
80
|
-
if (options.retryableCandidateKeys.has(key)
|
|
80
|
+
if (options.retryableCandidateKeys.has(key) &&
|
|
81
|
+
!options.deterministicSkipCandidateKeys?.has(key)) {
|
|
81
82
|
break;
|
|
83
|
+
}
|
|
82
84
|
const resolved = isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null)
|
|
83
85
|
? options.durableCandidateKeys.has(key)
|
|
84
86
|
: true;
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Everything the scan could not account for cleanly, in one ledger — the
|
|
3
3
|
* module that exists so a session is never silently dropped.
|
|
4
4
|
*
|
|
5
|
+
* What any of those records MEAN for the run — retryable versus a deliberate
|
|
6
|
+
* cap, and therefore what blocks completion — lives in the sibling
|
|
7
|
+
* `backfill-skip-policy.ts`, and every name it owns is re-exported from here.
|
|
8
|
+
*
|
|
5
9
|
* Scope is the load-bearing distinction. A global issue means discovery may
|
|
6
10
|
* have hidden a session at any mtime, so no source watermark is safe to
|
|
7
11
|
* advance; a candidate issue only stops the contiguous cursor prefix at the
|
|
@@ -79,39 +83,6 @@ function scanIssuePriority(scope) {
|
|
|
79
83
|
return 1;
|
|
80
84
|
return 2;
|
|
81
85
|
}
|
|
82
|
-
export function retryableCandidateKeys(candidates) {
|
|
83
|
-
return new Set(candidates
|
|
84
|
-
.filter((candidate) => candidate.reason === "file_read_failed" ||
|
|
85
|
-
candidate.reason === "repo_not_on_disk" ||
|
|
86
|
-
(isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
87
|
-
!candidate.worktree) ||
|
|
88
|
-
Boolean(candidate.claude?.main_file_oversized) ||
|
|
89
|
-
(candidate.claude?.sidecars_capped ?? 0) > 0 ||
|
|
90
|
-
Boolean(candidate.claude?.sidecar_files.some((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
|
|
91
|
-
sidecar.skipped_reason === "file_too_large")))
|
|
92
|
-
.map(candidateCursorKey));
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* A main session file whose only story is "too large to upload under the
|
|
96
|
-
* current cap" (BLI-2727). This mirrors exactly the two branches in
|
|
97
|
-
* `countReadOnlyGuards` that emit the `file_too_large` reason, so a candidate
|
|
98
|
-
* is in this set if and only if it contributed to that scan issue's count —
|
|
99
|
-
* one predicate, no drift between "why the issue fired" and "which candidate
|
|
100
|
-
* caused it". Deterministic and non-retryable: rerunning backfill cannot
|
|
101
|
-
* resolve it (only a larger cap or a smaller file can), so unlike a transient
|
|
102
|
-
* read failure it must never poison completion or a batch's success.
|
|
103
|
-
*/
|
|
104
|
-
export function oversizedBackfillCandidateKeys(candidates) {
|
|
105
|
-
const keys = new Set();
|
|
106
|
-
for (const candidate of candidates) {
|
|
107
|
-
if ((candidate.source === "claude_code" &&
|
|
108
|
-
candidate.claude?.main_file_oversized) ||
|
|
109
|
-
candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
110
|
-
keys.add(candidateCursorKey(candidate));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return keys;
|
|
114
|
-
}
|
|
115
86
|
// --- Scan issue ledger --------------------------------------------------
|
|
116
87
|
//
|
|
117
88
|
// `scan.issues` is the running ledger of everything the scan could not
|
|
@@ -161,27 +132,6 @@ export function addReadOnlyGuardIssues(issues, guardCounts) {
|
|
|
161
132
|
});
|
|
162
133
|
}
|
|
163
134
|
}
|
|
164
|
-
// BLI-2727: a deterministic, labeled oversized skip must never poison
|
|
165
|
-
// completion — it is a permanent, non-retryable fact about the file, not an
|
|
166
|
-
// in-flight problem a rerun can fix. `scan.issues`/`retryable_candidate_keys`
|
|
167
|
-
// still carry it (so it's never silently dropped from reporting); every
|
|
168
|
-
// completion-gating computation excludes it explicitly instead, by filtering
|
|
169
|
-
// through `blockingScanIssues` below.
|
|
170
|
-
//
|
|
171
|
-
// Two scan issues describe the exact same oversized-main candidates:
|
|
172
|
-
// `backfillScanIssues` pushes the Claude-specific `claude_main_file_too_large`
|
|
173
|
-
// (from `claude?.main_file_oversized`) and `countReadOnlyGuards` (via
|
|
174
|
-
// `addReadOnlyGuardIssues` above) separately pushes the source-agnostic
|
|
175
|
-
// `file_too_large` (same predicate as `oversizedBackfillCandidateKeys`, so
|
|
176
|
-
// this list can never drift from it). Both must be excluded from
|
|
177
|
-
// completion-gating together.
|
|
178
|
-
const OVERSIZED_SCAN_ISSUE_REASONS = new Set([
|
|
179
|
-
"file_too_large",
|
|
180
|
-
"claude_main_file_too_large",
|
|
181
|
-
]);
|
|
182
|
-
export function blockingScanIssues(issues) {
|
|
183
|
-
return issues.filter((issue) => !OVERSIZED_SCAN_ISSUE_REASONS.has(issue.reason));
|
|
184
|
-
}
|
|
185
135
|
async function countUnreadableClaudeSidecarDirs(candidates) {
|
|
186
136
|
let unreadable = 0;
|
|
187
137
|
for (const candidate of candidates) {
|
|
@@ -262,4 +212,7 @@ export async function countReadOnlyGuards(candidates) {
|
|
|
262
212
|
}
|
|
263
213
|
export function increment(counts, key) {
|
|
264
214
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
265
|
-
}
|
|
215
|
+
}
|
|
216
|
+
// Re-exported so every caller keeps importing the skip policy from
|
|
217
|
+
// `./backfill-issues.js` regardless of which sibling decides it.
|
|
218
|
+
export { blockingScanIssues, cappedSidecarCount, deterministicSkipCandidateKeys, oversizedBackfillCandidateKeys, retryableCandidateKeys, } from "./backfill-skip-policy.js";
|
|
@@ -4,7 +4,7 @@ import { readLocalWorkContextForRepo, startLocalWorkContext, } from "../local-st
|
|
|
4
4
|
import { postCodexSessionReport, } from "../upload.js";
|
|
5
5
|
import { candidateCursorKey } from "./backfill-candidates.js";
|
|
6
6
|
import { advanceBackfillCursorThroughResolvedPrefix, recordBackfillDurableSessionPointers, } from "./backfill-checkpoint.js";
|
|
7
|
-
import { blockingScanIssues } from "./backfill-issues.js";
|
|
7
|
+
import { blockingScanIssues, cappedSidecarCount } from "./backfill-issues.js";
|
|
8
8
|
import { backfillResultBaseArgs, baseBackfillResult, emptyReport, } from "./backfill-result.js";
|
|
9
9
|
import { buildBackfillSessionReport } from "./backfill-session-report.js";
|
|
10
10
|
/**
|
|
@@ -111,7 +111,7 @@ function sessionReportFailureReason(posted) {
|
|
|
111
111
|
* past a session the server never recorded can never be walked back.
|
|
112
112
|
*/
|
|
113
113
|
async function checkpointResolvedBackfillProgress(ctx, upload) {
|
|
114
|
-
const { paths, scan, cursor, now } = ctx;
|
|
114
|
+
const { paths, scan, cursor, now, deterministicSkipCandidateKeys } = ctx;
|
|
115
115
|
if (upload.durableCandidateKeys.size > 0) {
|
|
116
116
|
await recordBackfillDurableSessionPointers({
|
|
117
117
|
paths,
|
|
@@ -125,6 +125,10 @@ async function checkpointResolvedBackfillProgress(ctx, upload) {
|
|
|
125
125
|
candidates: scan.candidates,
|
|
126
126
|
durableCandidateKeys: upload.durableCandidateKeys,
|
|
127
127
|
retryableCandidateKeys: scan.retryable_candidate_keys,
|
|
128
|
+
// BLI-4303: a permanent cap must not stop the prefix either, or the cursor
|
|
129
|
+
// stalls at the first sidecar-capped session for good and every later run
|
|
130
|
+
// re-walks the same history.
|
|
131
|
+
deterministicSkipCandidateKeys,
|
|
128
132
|
discoveryComplete: !scan.issues.some((issue) => issue.scope === "global"),
|
|
129
133
|
now,
|
|
130
134
|
});
|
|
@@ -141,13 +145,13 @@ async function checkpointResolvedBackfillProgress(ctx, upload) {
|
|
|
141
145
|
* that no rerun can resolve and completion must not wait on forever.
|
|
142
146
|
*/
|
|
143
147
|
function summarizeBackfillCompletion(options) {
|
|
144
|
-
const { scan,
|
|
148
|
+
const { scan, deterministicSkipCandidateKeys } = options.ctx;
|
|
145
149
|
const { upload, posted } = options;
|
|
146
150
|
const unresolvedUploadableKeys = new Set(upload.uploadable
|
|
147
151
|
.filter((candidate) => !upload.durableCandidateKeys.has(candidateCursorKey(candidate)))
|
|
148
152
|
.map(candidateCursorKey));
|
|
149
|
-
const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !
|
|
150
|
-
const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !
|
|
153
|
+
const unresolvedUploadableBlocking = [...unresolvedUploadableKeys].filter((key) => !deterministicSkipCandidateKeys.has(key)).length;
|
|
154
|
+
const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !deterministicSkipCandidateKeys.has(key)).length;
|
|
151
155
|
const remaining = countRemainingHistory({
|
|
152
156
|
scan,
|
|
153
157
|
unresolvedUploadableKeys,
|
|
@@ -164,7 +168,7 @@ function summarizeBackfillCompletion(options) {
|
|
|
164
168
|
let failureReason = options.failureReason;
|
|
165
169
|
if (!failureReason && completionBlocked) {
|
|
166
170
|
const retryableCandidateReason = scan.candidates.find((candidate) => scan.retryable_candidate_keys.has(candidateCursorKey(candidate)) &&
|
|
167
|
-
!
|
|
171
|
+
!deterministicSkipCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
|
|
168
172
|
failureReason =
|
|
169
173
|
blockingIssues[0]?.reason ??
|
|
170
174
|
(unresolvedUploadableBlocking > 0
|
|
@@ -211,6 +215,7 @@ function countRemainingHistory(options) {
|
|
|
211
215
|
*/
|
|
212
216
|
async function writeAllHistoryCompletionMarker(ctx) {
|
|
213
217
|
const { paths, cursor, sources, scopedCursor, scan, oversizedCandidateKeys, now } = ctx;
|
|
218
|
+
const cappedSidecars = cappedSidecarCount(scan.candidates);
|
|
214
219
|
recordBackfillScanCoverage(cursor, sources, now, now);
|
|
215
220
|
await writeBackfillCursor(paths, cursor);
|
|
216
221
|
const oversizedCandidates = scan.candidates.filter((candidate) => oversizedCandidateKeys.has(candidateCursorKey(candidate)));
|
|
@@ -231,6 +236,17 @@ async function writeAllHistoryCompletionMarker(ctx) {
|
|
|
231
236
|
},
|
|
232
237
|
}
|
|
233
238
|
: {}),
|
|
239
|
+
// BLI-4303: additive on the same schema version, so an older marker simply
|
|
240
|
+
// omits it. A cap that is now excused from completion has to be visible
|
|
241
|
+
// wherever completion is claimed.
|
|
242
|
+
...(cappedSidecars > 0
|
|
243
|
+
? {
|
|
244
|
+
sidecar_cap_skips: {
|
|
245
|
+
reason: "claude_sidecar_limit_applied",
|
|
246
|
+
count: cappedSidecars,
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
: {}),
|
|
234
250
|
});
|
|
235
251
|
}
|
|
236
252
|
/** The `--json` payload: every stage's numbers merged onto the scan's baseline. */
|
|
@@ -16,7 +16,7 @@ import { emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCu
|
|
|
16
16
|
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
17
17
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
18
18
|
import { candidateCursorKey, compareBackfillCandidates, isAfterCursor, } from "./backfill-candidates.js";
|
|
19
|
-
import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, countReadOnlyGuards, oversizedBackfillCandidateKeys, retryableCandidateKeys, sortScanIssuesByPriority, } from "./backfill-issues.js";
|
|
19
|
+
import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, countReadOnlyGuards, deterministicSkipCandidateKeys, oversizedBackfillCandidateKeys, retryableCandidateKeys, sortScanIssuesByPriority, } from "./backfill-issues.js";
|
|
20
20
|
import { reasonCountsFor } from "./backfill-reasons.js";
|
|
21
21
|
import { blockedBackfillResult } from "./backfill-result.js";
|
|
22
22
|
import { resolveBackfillScope } from "./backfill-scope.js";
|
|
@@ -73,6 +73,7 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
73
73
|
scan,
|
|
74
74
|
reasonCounts,
|
|
75
75
|
oversizedCandidateKeys: oversizedBackfillCandidateKeys(scan.candidates),
|
|
76
|
+
deterministicSkipCandidateKeys: deterministicSkipCandidateKeys(scan.candidates),
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
/**
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which candidates a rerun could actually resolve, and which are a cap.
|
|
3
|
+
*
|
|
4
|
+
* The sibling ledger (`backfill-issues.ts`) records everything the scan could
|
|
5
|
+
* not account for; this module answers the only question the rest of the run
|
|
6
|
+
* asks about those records: does the thing block completion, or is it a
|
|
7
|
+
* permanent fact about the file that no rerun can change. The distinction is
|
|
8
|
+
* load-bearing in three places at once — completion, the cursor's contiguous
|
|
9
|
+
* prefix, and whether a batch counts as failed — so it has one owner.
|
|
10
|
+
*
|
|
11
|
+
* retryableCandidateKeys a rerun still owes this session something
|
|
12
|
+
* oversizedBackfillCandidateKeys the file is past the upload cap (BLI-2727)
|
|
13
|
+
* deterministicSkipCandidateKeys oversized, or sidecar-capped and nothing
|
|
14
|
+
* else wrong with it (BLI-4303)
|
|
15
|
+
* blockingScanIssues the ledger minus the deliberate caps
|
|
16
|
+
* cappedSidecarCount what the sidecar cap left behind
|
|
17
|
+
*
|
|
18
|
+
* Every name here is re-exported from `./backfill-issues.js`, the address its
|
|
19
|
+
* callers already know.
|
|
20
|
+
*/
|
|
21
|
+
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
22
|
+
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
23
|
+
import { candidateCursorKey } from "./backfill-candidates.js";
|
|
24
|
+
export function retryableCandidateKeys(candidates) {
|
|
25
|
+
return new Set(candidates
|
|
26
|
+
.filter((candidate) => candidate.reason === "file_read_failed" ||
|
|
27
|
+
candidate.reason === "repo_not_on_disk" ||
|
|
28
|
+
(isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
29
|
+
!candidate.worktree) ||
|
|
30
|
+
Boolean(candidate.claude?.main_file_oversized) ||
|
|
31
|
+
(candidate.claude?.sidecars_capped ?? 0) > 0 ||
|
|
32
|
+
Boolean(candidate.claude?.sidecar_files.some((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
|
|
33
|
+
sidecar.skipped_reason === "file_too_large")))
|
|
34
|
+
.map(candidateCursorKey));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A main session file whose only story is "too large to upload under the
|
|
38
|
+
* current cap" (BLI-2727). This mirrors exactly the two branches in
|
|
39
|
+
* `countReadOnlyGuards` that emit the `file_too_large` reason, so a candidate
|
|
40
|
+
* is in this set if and only if it contributed to that scan issue's count —
|
|
41
|
+
* one predicate, no drift between "why the issue fired" and "which candidate
|
|
42
|
+
* caused it". Deterministic and non-retryable: rerunning backfill cannot
|
|
43
|
+
* resolve it (only a larger cap or a smaller file can), so unlike a transient
|
|
44
|
+
* read failure it must never poison completion or a batch's success.
|
|
45
|
+
*/
|
|
46
|
+
export function oversizedBackfillCandidateKeys(candidates) {
|
|
47
|
+
const keys = new Set();
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
if ((candidate.source === "claude_code" &&
|
|
50
|
+
candidate.claude?.main_file_oversized) ||
|
|
51
|
+
candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
52
|
+
keys.add(candidateCursorKey(candidate));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return keys;
|
|
56
|
+
}
|
|
57
|
+
// BLI-2727: a deterministic, labeled oversized skip must never poison
|
|
58
|
+
// completion — it is a permanent, non-retryable fact about the file, not an
|
|
59
|
+
// in-flight problem a rerun can fix. `scan.issues`/`retryable_candidate_keys`
|
|
60
|
+
// still carry it (so it's never silently dropped from reporting); every
|
|
61
|
+
// completion-gating computation excludes it explicitly instead, by filtering
|
|
62
|
+
// through `blockingScanIssues` below.
|
|
63
|
+
//
|
|
64
|
+
// Two scan issues describe the exact same oversized-main candidates:
|
|
65
|
+
// `backfillScanIssues` pushes the Claude-specific `claude_main_file_too_large`
|
|
66
|
+
// (from `claude?.main_file_oversized`) and `countReadOnlyGuards` (via
|
|
67
|
+
// `addReadOnlyGuardIssues` above) separately pushes the source-agnostic
|
|
68
|
+
// `file_too_large` (same predicate as `oversizedBackfillCandidateKeys`, so
|
|
69
|
+
// this list can never drift from it). Both must be excluded from
|
|
70
|
+
// completion-gating together.
|
|
71
|
+
//
|
|
72
|
+
// BLI-4303 adds the third member of the same class, and it is the one that
|
|
73
|
+
// kept the reference Mac's `backfill-complete` row red forever:
|
|
74
|
+
// `claude_sidecar_limit_applied` counts helper transcripts past
|
|
75
|
+
// `CLAUDE_SESSION_MAX_SIDECAR_FILES`, a fixed constant. It is exactly as
|
|
76
|
+
// permanent as an oversized file — no rerun can produce the 41st sidecar of a
|
|
77
|
+
// session whose cap is 40 — yet it gated completion, so the all-history
|
|
78
|
+
// marker could never be written on a machine that uses subagents heavily
|
|
79
|
+
// (513 capped sidecars there). A cap is a collection boundary, not a failure;
|
|
80
|
+
// raising it is a separate decision, and until then it is a labeled fact that
|
|
81
|
+
// travels in the marker and in what doctor says.
|
|
82
|
+
const DETERMINISTIC_SKIP_SCAN_ISSUE_REASONS = new Set([
|
|
83
|
+
"file_too_large",
|
|
84
|
+
"claude_main_file_too_large",
|
|
85
|
+
"claude_sidecar_limit_applied",
|
|
86
|
+
]);
|
|
87
|
+
export function blockingScanIssues(issues) {
|
|
88
|
+
return issues.filter((issue) => !DETERMINISTIC_SKIP_SCAN_ISSUE_REASONS.has(issue.reason));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* How many helper transcripts this run left behind purely because of the
|
|
92
|
+
* per-session sidecar cap (BLI-4303). Metadata only — a count, no paths — and
|
|
93
|
+
* it is what the completion marker and doctor's green row say out loud so an
|
|
94
|
+
* excused skip is never a silent one.
|
|
95
|
+
*/
|
|
96
|
+
export function cappedSidecarCount(candidates) {
|
|
97
|
+
return candidates.reduce((total, candidate) => total + (candidate.claude?.sidecars_capped ?? 0), 0);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Candidates whose ONLY unfinished business is a cap this collector applies on
|
|
101
|
+
* purpose (BLI-4303): the oversized-main set above, plus a session that hit the
|
|
102
|
+
* per-session sidecar cap and has nothing else wrong with it.
|
|
103
|
+
*
|
|
104
|
+
* The second half carries a condition the first does not, deliberately: a
|
|
105
|
+
* sidecar-capped session still uploads its main transcript, so it can and must
|
|
106
|
+
* earn a durable pointer. If anything ELSE about it is retryable — an
|
|
107
|
+
* unreadable file, a repo that is not on disk, an uploadable state with no
|
|
108
|
+
* worktree, a sidecar that could not be read — it is not excused, because then
|
|
109
|
+
* a rerun genuinely has work to do on it.
|
|
110
|
+
*/
|
|
111
|
+
export function deterministicSkipCandidateKeys(candidates) {
|
|
112
|
+
const keys = oversizedBackfillCandidateKeys(candidates);
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (!isSidecarCapOnlyCandidate(candidate))
|
|
115
|
+
continue;
|
|
116
|
+
keys.add(candidateCursorKey(candidate));
|
|
117
|
+
}
|
|
118
|
+
return keys;
|
|
119
|
+
}
|
|
120
|
+
function isSidecarCapOnlyCandidate(candidate) {
|
|
121
|
+
const cappedSidecars = candidate.claude?.sidecars_capped ?? 0;
|
|
122
|
+
const oversizedSidecars = Boolean(candidate.claude?.sidecar_files.some((sidecar) => sidecar.skipped_reason === "file_too_large"));
|
|
123
|
+
if (cappedSidecars === 0 && !oversizedSidecars)
|
|
124
|
+
return false;
|
|
125
|
+
return !hasRetryableProblem(candidate);
|
|
126
|
+
}
|
|
127
|
+
/** The parts of `retryableCandidateKeys` a rerun could actually resolve. */
|
|
128
|
+
function hasRetryableProblem(candidate) {
|
|
129
|
+
return (candidate.reason === "file_read_failed" ||
|
|
130
|
+
candidate.reason === "repo_not_on_disk" ||
|
|
131
|
+
(isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
132
|
+
!candidate.worktree) ||
|
|
133
|
+
Boolean(candidate.claude?.sidecar_files.some((sidecar) => sidecar.skipped_reason === "file_read_failed")));
|
|
134
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from './agent-door.js';
|
|
2
|
+
export async function runCareers(command, io) {
|
|
3
|
+
const door = await openAgentDoor('careers', command, io);
|
|
4
|
+
const query = new URLSearchParams();
|
|
5
|
+
if (command.role)
|
|
6
|
+
query.set('role', command.role);
|
|
7
|
+
if (command.minScore !== undefined)
|
|
8
|
+
query.set('min_score', String(command.minScore));
|
|
9
|
+
if (command.since)
|
|
10
|
+
query.set('since', command.since);
|
|
11
|
+
const path = command.action === 'list' ? `/api/careers/applications?${query}` : `/api/careers/applications/${encodeURIComponent(command.id)}${command.action === 'rescreen' ? '/rescreen' : ''}`;
|
|
12
|
+
const answer = await askAgentDoor(door, { path, method: command.action === 'rescreen' ? 'POST' : 'GET', label: `careers ${command.action}`, timeoutMs: 60_000 });
|
|
13
|
+
if (!answer.ok)
|
|
14
|
+
return failAgentDoor(door, '[careers]', answer.reason, answer.detail);
|
|
15
|
+
return emitAgentDoor(door, answer.body);
|
|
16
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
|
|
2
|
+
import { isInteractiveDoctorFix } from "./doctor-report.js";
|
|
1
3
|
import { describeError } from "../health-detail.js";
|
|
2
|
-
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
4
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
|
|
3
5
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
4
6
|
import { detectSecondCockpitInstall } from "../second-install.js";
|
|
5
7
|
import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
|
|
@@ -12,6 +14,21 @@ import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
|
|
|
12
14
|
* operator decide.
|
|
13
15
|
*/
|
|
14
16
|
export async function fixAuthState(context, state) {
|
|
17
|
+
const renewed = await sendCollectorHeartbeatBestEffort({
|
|
18
|
+
homeDir: context.command.homeDir,
|
|
19
|
+
dashboardUrl: context.command.dashboardUrl,
|
|
20
|
+
roots: await doctorRoots(context),
|
|
21
|
+
facts: { status: "skipped", reason: "doctor_auth_renewal" },
|
|
22
|
+
io: context.io,
|
|
23
|
+
});
|
|
24
|
+
if (renewed) {
|
|
25
|
+
context.authVerified = true;
|
|
26
|
+
const checked = await context.deps.readAuth(context);
|
|
27
|
+
if (checked.status === "ok")
|
|
28
|
+
return checked;
|
|
29
|
+
}
|
|
30
|
+
if (!isInteractiveDoctorFix(context))
|
|
31
|
+
return { ...state, nextAction: "cockpit login" };
|
|
15
32
|
const code = await context.deps.runLogin(context).catch((error) => {
|
|
16
33
|
// Exit code 1 with no reason at all is what an operator saw when doctor
|
|
17
34
|
// tried and failed to repair their auth — the same output as a login that
|
|
@@ -24,6 +41,7 @@ export async function fixAuthState(context, state) {
|
|
|
24
41
|
});
|
|
25
42
|
if (code !== 0)
|
|
26
43
|
return state;
|
|
44
|
+
context.authVerified = true;
|
|
27
45
|
const checked = await context.deps.readAuth(context);
|
|
28
46
|
return checked.status === "ok" ? checked : state;
|
|
29
47
|
}
|
|
@@ -42,11 +60,14 @@ export async function fixRootState(context, state) {
|
|
|
42
60
|
return checked.status === "ok" ? checked : state;
|
|
43
61
|
}
|
|
44
62
|
export async function readAuthState(context) {
|
|
45
|
-
const paths = getCollectorRuntimePaths();
|
|
63
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
46
64
|
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
47
|
-
if (session
|
|
65
|
+
if (session && toSessionReference(session).session_state === "valid" &&
|
|
48
66
|
typeof session.device_token === "string" &&
|
|
49
67
|
session.device_token) {
|
|
68
|
+
if (!context.command.checkOnly && !context.command.dryRun && !context.authVerified) {
|
|
69
|
+
return needsFix("authed", "token_validation_required", "checking that the saved device token is accepted");
|
|
70
|
+
}
|
|
50
71
|
return ok("authed", "device_token_present", "this machine is signed in");
|
|
51
72
|
}
|
|
52
73
|
return hardStop("authed", "pairing_required", [
|
|
@@ -58,10 +79,11 @@ export async function readAuthState(context) {
|
|
|
58
79
|
].join("\n"));
|
|
59
80
|
}
|
|
60
81
|
export async function readRootState(context) {
|
|
61
|
-
const paths = getCollectorRuntimePaths();
|
|
82
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
62
83
|
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
63
84
|
const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
64
|
-
|
|
85
|
+
const requested = normalizeCollectionRoots(context.command.collectionRoots ?? (context.command.repoRoot ? [context.command.repoRoot] : []));
|
|
86
|
+
if (roots.length > 0 && requested.every((root) => roots.includes(root))) {
|
|
65
87
|
return {
|
|
66
88
|
...ok("roots-ok", "saved_roots_present", `saved roots: ${roots.join(", ")}`),
|
|
67
89
|
roots,
|
|
@@ -72,7 +94,7 @@ export async function readRootState(context) {
|
|
|
72
94
|
"What you can do:",
|
|
73
95
|
` 1) Run \`${onboardOneLiner(context.command)}\` to save the workspace roots again.`,
|
|
74
96
|
" 2) If this is the wrong folder, rerun from the BLI workspace or pass `--workspace <path>`.",
|
|
75
|
-
" 3)
|
|
97
|
+
" 3) Run `cockpit doctor` interactively to choose approved roots.",
|
|
76
98
|
].join("\n"));
|
|
77
99
|
}
|
|
78
100
|
/**
|
|
@@ -107,12 +129,14 @@ export async function checkSingleInstallState(context) {
|
|
|
107
129
|
].join("\n"));
|
|
108
130
|
}
|
|
109
131
|
export async function doctorRoots(context) {
|
|
132
|
+
if (context.command.collectionRoots?.length)
|
|
133
|
+
return context.command.collectionRoots;
|
|
110
134
|
if (context.command.repoRoot)
|
|
111
135
|
return [context.command.repoRoot];
|
|
112
|
-
return savedRoots();
|
|
136
|
+
return savedRoots(context.command.homeDir);
|
|
113
137
|
}
|
|
114
|
-
export async function savedRoots() {
|
|
115
|
-
const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
|
|
138
|
+
export async function savedRoots(homeDir) {
|
|
139
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
116
140
|
return normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
117
141
|
}
|
|
118
142
|
function onboardOneLiner(command) {
|
|
@@ -120,7 +144,7 @@ function onboardOneLiner(command) {
|
|
|
120
144
|
const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
|
|
121
145
|
? ""
|
|
122
146
|
: ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
|
|
123
|
-
return `cockpit
|
|
147
|
+
return `cockpit doctor --workspace ${shellQuote(workspace)}${dashboard}`;
|
|
124
148
|
}
|
|
125
149
|
function shellQuote(value) {
|
|
126
150
|
if (value === "$PWD")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
2
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
3
|
+
import { inspectSyncLock } from "../sync-lock.js";
|
|
4
|
+
const BUSY = new Set(["sync_already_running", "backfill_already_running", "live_sync_paused_during_backfill"]);
|
|
5
|
+
/** Retry the operation itself: its existing exclusive lock acquisition owns the
|
|
6
|
+
* handoff. Merely observing a free lock never grants permission to collect. */
|
|
7
|
+
export async function withDoctorLockWait(context, run, timing = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
|
|
8
|
+
const started = timing.now();
|
|
9
|
+
const limit = (context.command.lockWaitSeconds ?? 600) * 1000;
|
|
10
|
+
let nextProgress = 0;
|
|
11
|
+
let row = await run();
|
|
12
|
+
while (BUSY.has(row.code)) {
|
|
13
|
+
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
14
|
+
const sync = await inspectSyncLock(paths);
|
|
15
|
+
const backfill = await inspectBackfillLock(paths);
|
|
16
|
+
const owner = backfill.held
|
|
17
|
+
? { pid: backfill.pid, heartbeat_at: backfill.held_since }
|
|
18
|
+
: sync;
|
|
19
|
+
const elapsed = timing.now() - started;
|
|
20
|
+
if (elapsed >= limit) {
|
|
21
|
+
const pid = owner?.pid;
|
|
22
|
+
let name = "unknown process";
|
|
23
|
+
if (pid && pid > 0 && context.io.exec) {
|
|
24
|
+
const result = process.platform === "win32"
|
|
25
|
+
? await context.io.exec("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"])
|
|
26
|
+
: await context.io.exec("ps", ["-p", String(pid), "-o", "comm="]);
|
|
27
|
+
if (result.code === 0 && result.stdout.trim())
|
|
28
|
+
name = result.stdout.trim().split("\n")[0] ?? name;
|
|
29
|
+
}
|
|
30
|
+
return { ...row, status: "needs_fix", code: "lock_wait_timeout", nextAction: "cockpit doctor --lock-wait 600", message: `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.` };
|
|
31
|
+
}
|
|
32
|
+
if (elapsed >= nextProgress) {
|
|
33
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
34
|
+
console.error(`[doctor] ${row.id} waiting`, JSON.stringify({ reason: row.code, owner_pid: owner?.pid ?? null, heartbeat_at: owner?.heartbeat_at ?? null, elapsed_seconds: seconds }));
|
|
35
|
+
context.io.stderr.write(`waiting for the background sync to finish, ${Math.floor(seconds / 60)}m${seconds % 60}s\n`);
|
|
36
|
+
nextProgress = elapsed + 30_000;
|
|
37
|
+
}
|
|
38
|
+
await timing.sleep(Math.min(1000, limit - elapsed));
|
|
39
|
+
// A live heartbeat means there is no point spawning a collector yet.
|
|
40
|
+
const currentSync = await inspectSyncLock(paths);
|
|
41
|
+
const currentBackfill = await inspectBackfillLock(paths);
|
|
42
|
+
if (!currentSync?.held && !currentBackfill.held)
|
|
43
|
+
row = await run();
|
|
44
|
+
}
|
|
45
|
+
return row;
|
|
46
|
+
}
|