@bli-cockpit/cli 0.2.156 → 0.2.157

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.
@@ -26,7 +26,11 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
26
26
  failureReason ??= sessionReportFailureReason(posted);
27
27
  }
28
28
  if (posted.acknowledged) {
29
- await checkpointResolvedBackfillProgress(ctx, upload);
29
+ // BLI-11112: the sync's stub pass saw only stubbed sessions, so it records
30
+ // their pointers and never moves the history cursor past anything else.
31
+ await checkpointResolvedBackfillProgress(ctx, upload, {
32
+ advanceCursor: !command.stubPass,
33
+ });
30
34
  }
31
35
  const completion = summarizeBackfillCompletion({
32
36
  ctx,
@@ -35,7 +39,7 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
35
39
  stoppedEarly: Boolean(blockedAt),
36
40
  failureReason,
37
41
  });
38
- if (completion.status === "complete" && command.all) {
42
+ if (completion.status === "complete" && command.all && !command.stubPass) {
39
43
  await writeAllHistoryCompletionMarker(ctx);
40
44
  }
41
45
  return {
@@ -47,7 +51,9 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
47
51
  completion,
48
52
  blockedAt,
49
53
  }),
50
- stub_reoffer: summarizeStubReoffer(ctx.stubReoffer, upload.uploadable),
54
+ stub_reoffer: summarizeStubReoffer(ctx.stubReoffer, upload.uploadable, posted.acknowledged ? upload.durableCandidateKeys : new Set(),
55
+ // One sync result per batch the loop reached; a stopped run reached fewer.
56
+ upload.batches.slice(0, upload.syncResults.length).flatMap((batch) => batch.candidates)),
51
57
  };
52
58
  }
53
59
  /**
@@ -114,7 +120,7 @@ function sessionReportFailureReason(posted) {
114
120
  * both writes wait on the report acknowledgement, because a cursor advanced
115
121
  * past a session the server never recorded can never be walked back.
116
122
  */
117
- async function checkpointResolvedBackfillProgress(ctx, upload) {
123
+ async function checkpointResolvedBackfillProgress(ctx, upload, options) {
118
124
  const { paths, scan, cursor, now, deterministicSkipCandidateKeys } = ctx;
119
125
  if (upload.durableCandidateKeys.size > 0) {
120
126
  await recordBackfillDurableSessionPointers({
@@ -124,6 +130,8 @@ async function checkpointResolvedBackfillProgress(ctx, upload) {
124
130
  now,
125
131
  });
126
132
  }
133
+ if (!options.advanceCursor)
134
+ return;
127
135
  const cursorAdvanced = advanceBackfillCursorThroughResolvedPrefix({
128
136
  cursor,
129
137
  candidates: scan.candidates,
@@ -22,6 +22,7 @@ import { reasonCountsFor } from "./backfill-reasons.js";
22
22
  import { blockedBackfillResult } from "./backfill-result.js";
23
23
  import { emptyStubReofferPlan, loadStubReofferPlan, reopenStubbedSessions, } from "./backfill-stub-reoffer.js";
24
24
  import { resolveBackfillScope } from "./backfill-scope.js";
25
+ import { stubSessionKey } from "../evidence-stubs-client.js";
25
26
  /**
26
27
  * SCAN: is this machine paired, what roots/sources/window apply, and what did
27
28
  * the archived Codex + Claude session stores actually contain. Returns either
@@ -51,14 +52,17 @@ export async function scanBackfillRun(command, io, now) {
51
52
  // BLI-11102: a redaction-crash stub is not a delivery. The server names
52
53
  // them; those sessions reopen exactly like pointer-less ones. A dry run
53
54
  // keeps its one-request contract (the reachability probe) and does not ask.
54
- const stubReoffer = command.dryRun
55
- ? emptyStubReofferPlan("dry_run")
56
- : await loadStubReofferPlan({
57
- paths,
58
- dashboardUrl: scope.dashboardUrl,
59
- sources,
60
- fetchImpl: io.fetch,
61
- });
55
+ // BLI-11112: the sync's stub pass brings the door's answer with it.
56
+ const stubReoffer = command.stubPass
57
+ ? command.stubPass.plan
58
+ : command.dryRun
59
+ ? emptyStubReofferPlan("dry_run")
60
+ : await loadStubReofferPlan({
61
+ paths,
62
+ dashboardUrl: scope.dashboardUrl,
63
+ sources,
64
+ fetchImpl: io.fetch,
65
+ });
62
66
  reopenStubbedSessions(pointerlessTerminalSessions, stubReoffer);
63
67
  const scan = await scanBackfillSessions({
64
68
  command,
@@ -131,6 +135,9 @@ async function scanBackfillSessions(options) {
131
135
  cursor: options.cursor,
132
136
  pointerlessTerminalSessions: options.pointerlessTerminalSessions,
133
137
  maxFiles: options.command.maxFiles,
138
+ onlySessions: options.command.stubPass
139
+ ? new Set(options.command.stubPass.plan.sessions.keys())
140
+ : undefined,
134
141
  });
135
142
  // BLI-11091: a session outside every approved root is labelled, not
136
143
  // refused. It rides the synthetic outside-roots workspace from here on, so
@@ -198,6 +205,8 @@ function selectBackfillCandidates(options) {
198
205
  ...(options.codexAttribution?.results.map(normalizeCodexCandidate) ?? []),
199
206
  ...(options.claudeAttribution?.results.map(normalizeClaudeCandidate) ?? []),
200
207
  ]
208
+ .filter((candidate) => !options.onlySessions ||
209
+ options.onlySessions.has(stubSessionKey(candidate.source, candidate.session_id)))
201
210
  .filter((candidate) => isAfterCursor(candidate, options.cursor) ||
202
211
  isPointerlessTerminalRetry(candidate, options.pointerlessTerminalSessions))
203
212
  .sort((a, b) => compareBackfillCandidatesForRetry(a, b, options.pointerlessTerminalSessions));
@@ -2,6 +2,7 @@ import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor,
2
2
  import { fetchStubbedSessions, stubSessionKey } from "../evidence-stubs-client.js";
3
3
  import { describeError } from "../health-detail.js";
4
4
  import { readLocalCollectorSessionFile } from "../local-state.js";
5
+ import { candidateCursorKey } from "./backfill-candidates.js";
5
6
  export function emptyStubReofferPlan(reason) {
6
7
  return { status: "skipped", reason, sessions: new Map(), stub_count: 0, marked_local_sessions: 0 };
7
8
  }
@@ -53,8 +54,10 @@ export function stubClaimsForCandidates(candidates, plan) {
53
54
  }
54
55
  return claims;
55
56
  }
56
- export function summarizeStubReoffer(plan, candidates) {
57
- const reoffered = candidates.filter((candidate) => plan.sessions.has(stubSessionKey(candidate.source, candidate.session_id))).length;
57
+ export function summarizeStubReoffer(plan, candidates, durableCandidateKeys = new Set(), attempted = candidates) {
58
+ const offered = candidates.filter((candidate) => plan.sessions.has(stubSessionKey(candidate.source, candidate.session_id)));
59
+ const stubsOf = (list) => list.reduce((total, candidate) => total + (plan.sessions.get(stubSessionKey(candidate.source, candidate.session_id))?.length ?? 0), 0);
60
+ const reoffered = offered.length;
58
61
  const summary = {
59
62
  status: plan.status,
60
63
  reason: plan.reason,
@@ -62,6 +65,8 @@ export function summarizeStubReoffer(plan, candidates) {
62
65
  sessions: plan.sessions.size,
63
66
  marked_local_sessions: plan.marked_local_sessions,
64
67
  reoffered_sessions: reoffered,
68
+ reoffered_stubs: stubsOf(offered.filter((candidate) => attempted.includes(candidate))),
69
+ replaced_stubs: stubsOf(offered.filter((candidate) => durableCandidateKeys.has(candidateCursorKey(candidate)))),
65
70
  };
66
71
  if (plan.sessions.size > 0) {
67
72
  console.error("[backfill] redaction-stubbed sessions re-offered", JSON.stringify({
@@ -20,10 +20,11 @@ const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
20
20
  */
21
21
  export async function uploadBackfillBatches(command, io, lock, ctx) {
22
22
  const uploadable = uploadableCandidates(ctx.scan.candidates);
23
- // Doctor needs a boundary after each session. Ordinary backfill retains
24
- // its existing batching, and scheduled sync does not use this command.
23
+ // Doctor needs a boundary after each session, and so does the scheduled
24
+ // sync's stub pass (BLI-11112), which hands the lock to a waiting backfill
25
+ // at the next one. Ordinary backfill retains its existing batching.
25
26
  const plannedBatches = buildBackfillBatches(uploadable);
26
- const batches = command.deadlineMs !== undefined || command.onProgress
27
+ const batches = command.deadlineMs !== undefined || command.onProgress || command.stubPass
27
28
  ? plannedBatches.flatMap((batch) => batch.candidates.map((candidate) => ({
28
29
  worktree: batch.worktree, candidates: [candidate],
29
30
  })))
@@ -72,6 +73,12 @@ export async function uploadBackfillBatches(command, io, lock, ctx) {
72
73
  failureReason = "deferred_budget_exhausted";
73
74
  break;
74
75
  }
76
+ if (command.stubPass?.shouldYield && (await command.stubPass.shouldYield())) {
77
+ blockedAt = stoppedAt("yielded to a waiting backfill", index);
78
+ failureReason = "yielded_to_backfill";
79
+ console.error("[collector stubs] stub pass yielded the collection lock to a waiting backfill", JSON.stringify({ reason: "backfill_waiting", sessions_done: index, sessions_left: batches.length - index }));
80
+ break;
81
+ }
75
82
  const outcome = await uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget);
76
83
  foldBatchIntoTally(tally, batch, outcome);
77
84
  reportProgress();
@@ -103,7 +103,14 @@ export async function runBackfill(command, io) {
103
103
  if (plan.kind === "result")
104
104
  return plan.result;
105
105
  const { paths } = scanned;
106
- const lock = await acquireBackfillLock(paths, now);
106
+ // BLI-11112: the sync's stub pass does not take the backfill lock. It runs
107
+ // under the collection lock, which already serialises it against any
108
+ // backfill, and the backfill lock is the flag a WAITING backfill raises: a
109
+ // person's backfill that arrives mid-pass must wait its turn, not bounce off
110
+ // `backfill_already_running`.
111
+ const lock = command.stubPass
112
+ ? { acquired: true, handle: { release: async () => undefined, heartbeat: async () => undefined } }
113
+ : await acquireBackfillLock(paths, now);
107
114
  if (!lock.acquired) {
108
115
  return lockHeldResult(command, scanned, {
109
116
  failureReason: "backfill_already_running",
@@ -112,29 +119,36 @@ export async function runBackfill(command, io) {
112
119
  }
113
120
  // BLI-11091: the scheduled sync holds this lock most of the time an agent
114
121
  // is working, so wait for it (bounded) instead of exiting at batch 0. Giving
115
- // up is `sync_lock_held`, a blocked result and so a non-zero exit.
116
- let waited;
117
- try {
118
- waited = await acquireSyncLockWithWait(paths, command.syncLockWaitMs ?? BACKFILL_SYNC_LOCK_WAIT_MS);
119
- }
120
- catch (error) {
121
- await lock.handle.release();
122
- throw error;
122
+ // up is `sync_lock_held`, a blocked result and so a non-zero exit. The
123
+ // sync's own stub pass (BLI-11112) runs inside the sync's lock and skips it.
124
+ let collectionLock;
125
+ if (command.stubPass) {
126
+ collectionLock = { handle: { release: async () => undefined } };
123
127
  }
124
- if (!waited.acquired) {
125
- await lock.handle.release();
126
- return lockHeldResult(command, scanned, {
127
- failureReason: "sync_lock_held",
128
- what: "sync collection lock held",
129
- });
128
+ else {
129
+ let waited;
130
+ try {
131
+ waited = await acquireSyncLockWithWait(paths, command.syncLockWaitMs ?? BACKFILL_SYNC_LOCK_WAIT_MS);
132
+ }
133
+ catch (error) {
134
+ await lock.handle.release();
135
+ throw error;
136
+ }
137
+ if (!waited.acquired) {
138
+ await lock.handle.release();
139
+ return lockHeldResult(command, scanned, {
140
+ failureReason: "sync_lock_held",
141
+ what: "sync collection lock held",
142
+ });
143
+ }
144
+ collectionLock = waited.lock;
130
145
  }
131
- const collectionLock = waited.lock;
132
146
  try {
133
147
  // Backfill and scheduled/manual sync share upload spool and raw-evidence
134
148
  // cursors. Holding the same collection lock makes their read-modify-write
135
149
  // updates serial, while the dedicated backfill lock still prevents two
136
150
  // historical scans from running together.
137
- if (scanned.scopedCursor.reset) {
151
+ if (scanned.scopedCursor.reset && !command.stubPass) {
138
152
  await writeBackfillCursor(paths, scanned.cursor);
139
153
  }
140
154
  // BLI-11102: mark server-named stubs as not delivered, under the lock
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.156");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.157");
25
25
  return 0;
26
26
  }
27
27
 
@@ -10,6 +10,7 @@ import { startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from
10
10
  import { LocalUploadBlockedError, syncLocalAmbientEnvelope, } from "../upload.js";
11
11
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
12
12
  import { liveSyncTargetKey, liveSyncTargetWorktrees, matchesLiveSyncWorktree, } from "./session-sync-attribution.js";
13
+ import { runYieldingAtBoundaries } from "./sync-yield.js";
13
14
  const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
14
15
  const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
15
16
  /**
@@ -33,10 +34,13 @@ export async function syncEveryTargetWorktree(options) {
33
34
  remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
34
35
  remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
35
36
  };
36
- const outcomes = [];
37
- let everyWorktreeUploaded = true;
38
- for (const worktree of syncWorktrees) {
39
- const outcome = await syncOneWorktree({
37
+ // BLI-11112: a waiting backfill takes the lock at the next worktree
38
+ // boundary; the worktrees not reached stay pending for it or the next tick.
39
+ const loop = await runYieldingAtBoundaries({
40
+ items: syncWorktrees,
41
+ shouldYield: run.yieldCheck,
42
+ describe: (outcome) => ({ status: outcome.sync.status }),
43
+ runOne: (worktree) => syncOneWorktree({
40
44
  run,
41
45
  now: options.now,
42
46
  plan: options.plan,
@@ -50,16 +54,16 @@ export async function syncEveryTargetWorktree(options) {
50
54
  recordDampedClaudeSession: (sessionId, priorPointer) => {
51
55
  dampedClaudePointers.set(sessionId, priorPointer);
52
56
  },
53
- });
54
- everyWorktreeUploaded =
55
- everyWorktreeUploaded && outcome.sync.status === "uploaded";
56
- outcomes.push(outcome);
57
- }
57
+ }),
58
+ });
59
+ const outcomes = loop.results;
60
+ const everyWorktreeUploaded = outcomes.every((outcome) => outcome.sync.status === "uploaded");
58
61
  return {
59
62
  outcomes,
60
63
  everyWorktreeUploaded,
61
64
  claudePriorDurablePointers: claudeDurablePointersFromCursor(options.plan.claudeCursorBefore),
62
65
  growthDampedSessionCount: dampedClaudePointers.size,
66
+ yieldedWorktreesLeft: loop.left,
63
67
  };
64
68
  }
65
69
  /** Every Claude session the cursor already holds a durable pointer for. */
@@ -151,6 +151,7 @@ export async function runAttributedWorktreeSync(options) {
151
151
  failure_records: health.failure_records,
152
152
  notice: health.notice,
153
153
  hold: health.hold,
154
+ yielded_worktrees_left: worktreePass.yieldedWorktreesLeft,
154
155
  sessions_observed: sessions.length,
155
156
  sessions_outside_root: health.sessions_outside_root,
156
157
  outside_root_directories: health.outside_root_directories,
@@ -3,6 +3,8 @@ import { discoverCommandWorktrees } from "./local-discovery.js";
3
3
  import { runAttributedWorktreeSync } from "./session-sync.js";
4
4
  import { reportMultiRepoSync, reportNoWorktreeSync, reportSingleRepoSync, } from "./sync-report.js";
5
5
  import { resolveSyncCollectionRoots } from "./sync-roots.js";
6
+ import { healStubsDuringTick } from "./sync-stub-heal.js";
7
+ import { backfillWaitingCheck } from "./sync-yield.js";
6
8
  import { getCollectorRuntimePaths } from "../local-state.js";
7
9
  import { rawEvidenceDedupSummary, sweepDuplicateStagedRawEvidence, } from "../raw-evidence-gc.js";
8
10
  /**
@@ -18,7 +20,8 @@ export async function runSyncLocked(command, io) {
18
20
  // holds 559 copies of one rollout needs the disk back before it stages
19
21
  // anything else (BLI-3066). Safe by construction — a duplicate is identical
20
22
  // by content hash to the survivor.
21
- const dedup = await sweepDuplicateStagedRawEvidence(getCollectorRuntimePaths(command.homeDir), io.env);
23
+ const paths = getCollectorRuntimePaths(command.homeDir);
24
+ const dedup = await sweepDuplicateStagedRawEvidence(paths, io.env);
22
25
  if (!dedup.skipped && dedup.removed_dirs > 0) {
23
26
  writeLine(io.stdout, rawEvidenceDedupSummary(dedup));
24
27
  }
@@ -37,7 +40,11 @@ export async function runSyncLocked(command, io) {
37
40
  startContexts: false,
38
41
  worktrees,
39
42
  fetchImpl: io.fetch,
43
+ yieldCheck: backfillWaitingCheck(paths),
40
44
  });
45
+ // After the tick's own uploads, still under the lock: re-offer up to 25 of
46
+ // this machine's stubbed sessions. Never fails the tick.
47
+ await healStubsDuringTick(command, io);
41
48
  if (discoveryNotice) {
42
49
  run.discovery_partial = true;
43
50
  run.notice = [discoveryNotice, run.notice].filter(Boolean).join("; ");
@@ -0,0 +1,135 @@
1
+ import { runBackfill } from "./backfill.js";
2
+ import { loadStubReofferPlan } from "./backfill-stub-reoffer.js";
3
+ import { backfillWaitingCheck } from "./sync-yield.js";
4
+ import { inspectBackfillLock } from "../backfill-lock.js";
5
+ import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
6
+ import { describeError } from "../health-detail.js";
7
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
8
+ /** At most this many stubbed sessions are re-offered per tick (one backfill batch). */
9
+ export const SYNC_STUB_SESSIONS_PER_TICK = 25;
10
+ const DAY_MS = 24 * 60 * 60_000;
11
+ const DEFAULT_DEPS = { runBackfill, loadPlan: loadStubReofferPlan };
12
+ export async function healStubsDuringTick(command, io, deps = DEFAULT_DEPS, now = new Date()) {
13
+ const summary = {
14
+ reason: "unknown",
15
+ stubs_asked: 0,
16
+ stubs_reoffered: 0,
17
+ stubs_remaining: 0,
18
+ sessions_local: 0,
19
+ sessions_selected: 0,
20
+ };
21
+ try {
22
+ summary.reason = await runStubPass(command, io, deps, now, summary);
23
+ }
24
+ catch (error) {
25
+ summary.reason = "stub_pass_threw";
26
+ console.error("[collector stubs] tick stub pass threw; the tick is unaffected", JSON.stringify({ reason: "stub_pass_threw", ...describeError(error) }));
27
+ }
28
+ console.error("[collector stubs] tick", JSON.stringify(summary));
29
+ return summary;
30
+ }
31
+ async function runStubPass(command, io, deps, now, summary) {
32
+ const paths = getCollectorRuntimePaths(command.homeDir);
33
+ // A backfill waiting for the lock heals stubs itself, with the whole door.
34
+ if ((await inspectBackfillLock(paths)).held)
35
+ return "backfill_running";
36
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
37
+ const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => null);
38
+ if (!sessionFile)
39
+ return "no_device_session";
40
+ const sources = config?.collect_claude_jsonl === false ? ["codex"] : ["codex", "claude_code"];
41
+ const dashboardUrl = (sessionFile.dashboard_url ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL)
42
+ .trim()
43
+ .replace(/\/+$/, "");
44
+ const plan = await deps.loadPlan({ paths, dashboardUrl, sources, fetchImpl: io.fetch });
45
+ summary.stubs_asked = plan.stub_count;
46
+ summary.stubs_remaining = plan.stub_count;
47
+ if (plan.status === "skipped")
48
+ return plan.reason;
49
+ if (plan.status === "unavailable")
50
+ return `stub_door_unavailable:${plan.reason}`;
51
+ if (plan.sessions.size === 0)
52
+ return "no_stubs";
53
+ const local = await locallyTrackedStubSessions(paths, plan);
54
+ summary.sessions_local = local.length;
55
+ if (local.length === 0)
56
+ return "stubs_not_on_this_machine";
57
+ const selected = local.slice(0, SYNC_STUB_SESSIONS_PER_TICK);
58
+ summary.sessions_selected = selected.length;
59
+ const trimmed = {
60
+ ...plan,
61
+ sessions: new Map(selected.map((session) => [session.key, session.pointerIds])),
62
+ stub_count: selected.reduce((total, session) => total + session.pointerIds.length, 0),
63
+ marked_local_sessions: 0,
64
+ };
65
+ const window = stubWindow(selected, sessionFile.paired_at, now);
66
+ const result = await deps.runBackfill({
67
+ homeDir: command.homeDir,
68
+ repoRoot: command.repoRoot,
69
+ maxDepth: command.maxDepth,
70
+ maxRepos: command.maxRepos,
71
+ ...window,
72
+ dryRun: false,
73
+ yes: true,
74
+ json: true,
75
+ stubPass: { plan: trimmed, shouldYield: backfillWaitingCheck(paths) },
76
+ }, backfillIoFor(io));
77
+ const outcome = result.stub_reoffer;
78
+ summary.stubs_reoffered = outcome?.reoffered_stubs ?? 0;
79
+ summary.stubs_remaining = Math.max(0, plan.stub_count - (outcome?.replaced_stubs ?? 0));
80
+ return stubPassReason(result);
81
+ }
82
+ /** Door-order list of the stubbed sessions whose cursor entry lives on this machine. */
83
+ async function locallyTrackedStubSessions(paths, plan) {
84
+ const cursors = {
85
+ codex: await readRawEvidenceCursor(paths).catch(() => null),
86
+ claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME }).catch(() => null),
87
+ };
88
+ const local = [];
89
+ for (const [key, pointerIds] of plan.sessions) {
90
+ const at = key.indexOf(":");
91
+ const source = key.slice(0, at);
92
+ const entry = cursors[source]?.sessions[key.slice(at + 1)];
93
+ if (entry)
94
+ local.push({ key, pointerIds, mtimeMs: entry.file_mtime_ms });
95
+ }
96
+ return local;
97
+ }
98
+ /**
99
+ * The narrowest backfill window that still reaches every selected session:
100
+ * back to the oldest one's file time plus a day. A backfill window never
101
+ * reaches before pairing, so an older session (or one with no recorded time)
102
+ * asks for all history instead.
103
+ */
104
+ function stubWindow(selected, pairedAt, now) {
105
+ const times = selected.map((session) => session.mtimeMs);
106
+ const oldest = Math.min(...times);
107
+ const paired = pairedAt ? Date.parse(pairedAt) : Number.NaN;
108
+ if (times.some((time) => !Number.isFinite(time) || time <= 0))
109
+ return { all: true };
110
+ if (!Number.isFinite(paired) || oldest < paired)
111
+ return { all: true };
112
+ return { all: false, sinceDays: Math.max(1, Math.ceil((now.getTime() - oldest) / DAY_MS) + 1) };
113
+ }
114
+ function stubPassReason(result) {
115
+ const outcome = result.stub_reoffer;
116
+ if (result.failure_reason === "yielded_to_backfill")
117
+ return "yielded_to_backfill";
118
+ if (result.status === "blocked")
119
+ return `stub_backfill_blocked:${result.failure_reason ?? "unknown"}`;
120
+ if (!outcome || outcome.reoffered_sessions === 0)
121
+ return "stubs_not_found_locally";
122
+ if (outcome.replaced_stubs < outcome.reoffered_stubs) {
123
+ return `stubs_partly_replaced:${result.failure_reason ?? "unknown"}`;
124
+ }
125
+ return "stubs_reoffered";
126
+ }
127
+ /**
128
+ * The backfill writes its `--json` result and progress to stdout; the sync's
129
+ * stdout is its own `--json` receipt, so those lines go nowhere and the
130
+ * backfill's own `[backfill]` logs still reach stderr.
131
+ */
132
+ function backfillIoFor(io) {
133
+ const discard = { write: () => true };
134
+ return { stdin: io.stdin, stdout: discard, stderr: io.stderr, fetch: io.fetch };
135
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * BLI-11112: the scheduled sync hands the collection lock to a waiting
3
+ * backfill at its next worktree boundary.
4
+ *
5
+ * A tick on a machine with a backlog now runs 10 to 15 minutes, longer than
6
+ * the backfill's old wait, so a person who typed `cockpit backfill` sat out the
7
+ * whole tick and got `sync_lock_held`. The rule is plain: a backfill that wants
8
+ * the lock takes the backfill lock FIRST and then waits for the collection
9
+ * lock (`backfill-lock-wait.ts`). The backfill lock being held is the waiter's
10
+ * flag. Before each worktree upload the sync asks whether that flag is up; if
11
+ * it is, it finishes nothing new, writes down what it already did, and
12
+ * releases the lock. The worktrees it did not reach stay pending in the cursor
13
+ * and are exactly what the backfill (or the next tick) picks up. A worktree
14
+ * already uploading is never interrupted, so the wait is bounded by one
15
+ * worktree's upload, which is why each one logs its own elapsed time.
16
+ */
17
+ import { inspectBackfillLock } from "../backfill-lock.js";
18
+ /** The waiter's flag: the backfill lock is held while its owner waits for ours. */
19
+ export function backfillWaitingCheck(paths, inspect = inspectBackfillLock) {
20
+ return async () => {
21
+ try {
22
+ return (await inspect(paths)).held;
23
+ }
24
+ catch {
25
+ // A lock probe that cannot read the file means nobody is waiting; the
26
+ // sync carries on exactly as it did before this rule existed.
27
+ return false;
28
+ }
29
+ };
30
+ }
31
+ /**
32
+ * Run `runOne` over `items` in order, asking `shouldYield` before each one.
33
+ * The first `true` stops the loop; nothing already started is interrupted.
34
+ */
35
+ export async function runYieldingAtBoundaries(options) {
36
+ const now = options.now ?? (() => Date.now());
37
+ const results = [];
38
+ const started = now();
39
+ for (const [index, item] of options.items.entries()) {
40
+ if (options.shouldYield && (await options.shouldYield())) {
41
+ const left = options.items.length - index;
42
+ console.error("[session-sync] yielded the collection lock to a waiting backfill", JSON.stringify({
43
+ reason: "backfill_waiting",
44
+ worktrees_done: index,
45
+ worktrees_left: left,
46
+ elapsed_ms: now() - started,
47
+ }));
48
+ return { results, yielded: true, left };
49
+ }
50
+ const itemStarted = now();
51
+ const result = await options.runOne(item);
52
+ results.push(result);
53
+ console.error("[session-sync] worktree synced", JSON.stringify({
54
+ index: index + 1,
55
+ total: options.items.length,
56
+ elapsed_ms: now() - itemStarted,
57
+ ...(options.describe ? options.describe(result) : {}),
58
+ }));
59
+ }
60
+ return { results, yielded: false, left: 0 };
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.156",
3
+ "version": "0.2.157",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {