@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.
Files changed (38) hide show
  1. package/dist/agent-rules.js +2 -1
  2. package/dist/backfill-lock.js +1 -1
  3. package/dist/commands/backfill-checkpoint.js +3 -1
  4. package/dist/commands/backfill-issues.js +8 -55
  5. package/dist/commands/backfill-report.js +22 -6
  6. package/dist/commands/backfill-scan.js +2 -1
  7. package/dist/commands/backfill-skip-policy.js +134 -0
  8. package/dist/commands/careers.js +16 -0
  9. package/dist/commands/doctor-access.js +34 -10
  10. package/dist/commands/doctor-lock-wait.js +46 -0
  11. package/dist/commands/doctor-pipeline-verdicts.js +238 -0
  12. package/dist/commands/doctor-pipeline.js +49 -111
  13. package/dist/commands/doctor-registration.js +23 -2
  14. package/dist/commands/doctor-report.js +48 -9
  15. package/dist/commands/doctor-update.js +16 -5
  16. package/dist/commands/doctor.js +98 -58
  17. package/dist/commands/local-args-collector-setup.js +6 -0
  18. package/dist/commands/local-args-tower-careers.js +20 -0
  19. package/dist/commands/local-args-tower-pages.js +17 -2
  20. package/dist/commands/local-args-tower-usage.js +2 -2
  21. package/dist/commands/local-args-tower.js +2 -1
  22. package/dist/commands/local-args.js +3 -1
  23. package/dist/commands/local-help-commands-tower.js +4 -2
  24. package/dist/commands/local-help-commands.js +28 -12
  25. package/dist/commands/local-help.js +4 -2
  26. package/dist/commands/local.js +4 -0
  27. package/dist/commands/notes-file.js +8 -1
  28. package/dist/commands/notes-folders.js +35 -0
  29. package/dist/commands/notes-writes.js +37 -4
  30. package/dist/commands/notes.js +6 -0
  31. package/dist/commands/public-root.js +4 -4
  32. package/dist/commands/usage-format.js +18 -0
  33. package/dist/commands/usage.js +13 -3
  34. package/dist/cursors/backfill-completion-marker.js +135 -0
  35. package/dist/cursors/backfill-cursor.js +18 -99
  36. package/dist/scheduled-self-update.js +1 -1
  37. package/dist/sync-lock.js +15 -1
  38. package/package.json +2 -2
@@ -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 onboard` or `cockpit agent-rules install`. Outside that folder, 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 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
  "",
@@ -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, oversizedCandidateKeys } = options.ctx;
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) => !oversizedCandidateKeys.has(key)).length;
150
- const unresolvedRetryableBlocking = [...scan.retryable_candidate_keys].filter((key) => !oversizedCandidateKeys.has(key)).length;
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
- !oversizedCandidateKeys.has(candidateCursorKey(candidate)))?.reason;
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?.session_state === "valid" &&
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
- if (roots.length > 0) {
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) There is no `--repair` flag; the onboard-rerun is the repair path.",
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 onboard --workspace ${shellQuote(workspace)}${dashboard}`;
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
+ }