@bli-cockpit/cli 0.2.207 → 0.2.208

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.
@@ -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.207");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.208");
25
25
  return 0;
26
26
  }
27
27
 
@@ -23,7 +23,7 @@
23
23
  import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_HANDLING_MODE, base64Of, findEmbeddedImages, replaceImagesWithMarkers, } from "@bli-cockpit/telemetry-core";
24
24
  import { postImageToDescribeDoor } from "./image-describe-client.js";
25
25
  import { slimOrKeep, slimTranscript } from "./slim/slim-transcript.js";
26
- import { imageStateKey, readDescribedImageKeys, writeDescribedImageKeys, } from "./image-description-state.js";
26
+ import { imageStateKey, imageRetryDelayMs, readDescribedImageKeys, readFailedImages, writeImageDescriptionState, } from "./image-description-state.js";
27
27
  /** New images sent to the door in one sync (all worktrees together). */
28
28
  export const IMAGE_DESCRIBE_MAX_PER_RUN = 100;
29
29
  /** After this long in one sync, remaining images wait for the next tick. */
@@ -35,6 +35,8 @@ export async function openImageDescriptions(options) {
35
35
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
36
36
  const described = new Set(await readDescribedImageKeys(options.stateDir));
37
37
  const orderedKeys = [...described];
38
+ const failedImages = await readFailedImages(options.stateDir);
39
+ let stateDirty = false;
38
40
  const settledThisRun = new Map();
39
41
  const now = options.now ?? Date.now;
40
42
  const startedAt = now();
@@ -59,12 +61,33 @@ export async function openImageDescriptions(options) {
59
61
  source: input.source,
60
62
  session_id: input.sessionId.slice(0, 200),
61
63
  });
64
+ const key = imageStateKey(image.sha256);
62
65
  if (outcome.ok) {
63
66
  markDescribed(image.sha256);
64
67
  settledThisRun.set(image.sha256, outcome.status);
68
+ if (failedImages[key]) {
69
+ delete failedImages[key];
70
+ stateDirty = true;
71
+ }
65
72
  return;
66
73
  }
67
74
  settledThisRun.set(image.sha256, `failed:${outcome.reason}`);
75
+ rememberFailure(key, outcome);
76
+ }
77
+ /**
78
+ * BLI-16979: the door answered about THIS image and could not describe it (a 4xx such as
79
+ * `describer_rejected_image`, or a 5xx). Wait before sending the same bytes again, instead of
80
+ * re-sending them every sync. A transport error, a 401 and a 429 say nothing about the image, so
81
+ * they are not remembered.
82
+ */
83
+ function rememberFailure(key, outcome) {
84
+ if (outcome.httpStatus < 400 || outcome.httpStatus === 401 || outcome.httpStatus === 429)
85
+ return;
86
+ const attempts = (failedImages[key]?.attempts ?? 0) + 1;
87
+ const delay = imageRetryDelayMs(attempts, outcome.permanent);
88
+ failedImages[key] = { reason: outcome.reason, attempts, retry_after: now() + delay };
89
+ stateDirty = true;
90
+ console.error(TAG + " image not described, will not be re-sent until later", JSON.stringify({ reason: outcome.reason, http_status: outcome.httpStatus, attempts, retry_in_hours: Math.round(delay / 3_600_000), sha256_prefix: key.slice(0, 16) }));
68
91
  }
69
92
  async function describeAndReplace(input) {
70
93
  const images = findEmbeddedImages(input.text);
@@ -74,7 +97,7 @@ export async function openImageDescriptions(options) {
74
97
  for (const image of images)
75
98
  if (!unique.has(image.sha256))
76
99
  unique.set(image.sha256, image);
77
- const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0 };
100
+ const counts = { already: 0, described_now: 0, known: 0, too_large: 0, over_budget: 0, failed: 0, backing_off: 0 };
78
101
  const failedReasons = {};
79
102
  const toSend = [];
80
103
  for (const image of unique.values()) {
@@ -90,6 +113,11 @@ export async function openImageDescriptions(options) {
90
113
  counts.too_large += 1;
91
114
  continue;
92
115
  }
116
+ const earlierFailure = failedImages[imageStateKey(image.sha256)];
117
+ if (earlierFailure && earlierFailure.retry_after > now()) {
118
+ counts.backing_off += 1;
119
+ continue;
120
+ }
93
121
  toSend.push(image);
94
122
  }
95
123
  let next = 0;
@@ -116,8 +144,10 @@ export async function openImageDescriptions(options) {
116
144
  failedReasons[reason] = (failedReasons[reason] ?? 0) + 1;
117
145
  }
118
146
  }
119
- if (counts.described_now + counts.known > 0)
120
- await writeDescribedImageKeys(options.stateDir, orderedKeys);
147
+ if (counts.described_now + counts.known > 0 || stateDirty) {
148
+ await writeImageDescriptionState(options.stateDir, { described: orderedKeys, failed: failedImages });
149
+ stateDirty = false;
150
+ }
121
151
  const replaced = mode === "describe_and_drop"
122
152
  ? replaceImagesWithMarkers(input.text, images, (image) => described.has(imageStateKey(image.sha256)))
123
153
  : { text: input.text, replacedCount: 0, charsSaved: 0 };
@@ -35,10 +35,54 @@ export async function readDescribedImageKeys(stateDir) {
35
35
  return [];
36
36
  }
37
37
  }
38
- export async function writeDescribedImageKeys(stateDir, keys) {
38
+ /** Oldest failures leave first past this many. */
39
+ export const IMAGE_FAILED_STATE_MAX_KEYS = 5_000;
40
+ const HOUR_MS = 3_600_000;
41
+ /** A refusal that repeats for the same bytes (a 4xx): a week, then a month. */
42
+ const PERMANENT_BACKOFF_MS = [7 * 24 * HOUR_MS, 30 * 24 * HOUR_MS];
43
+ /** A failure that may pass (the door or the model was down): 1 h, 6 h, 24 h, then 72 h. */
44
+ const TRANSIENT_BACKOFF_MS = [HOUR_MS, 6 * HOUR_MS, 24 * HOUR_MS, 72 * HOUR_MS];
45
+ /** How long to leave an image alone after its Nth failed attempt. */
46
+ export function imageRetryDelayMs(attempts, permanent) {
47
+ const steps = permanent ? PERMANENT_BACKOFF_MS : TRANSIENT_BACKOFF_MS;
48
+ return steps[Math.min(Math.max(attempts, 1), steps.length) - 1];
49
+ }
50
+ /** The failures on record, key to record. A missing or unreadable file is an empty map, said out loud when unreadable. */
51
+ export async function readFailedImages(stateDir) {
52
+ try {
53
+ const raw = (await readJsonFile(imageDescriptionStatePath(stateDir)));
54
+ if (!raw?.failed || typeof raw.failed !== "object" || Array.isArray(raw.failed))
55
+ return {};
56
+ const kept = {};
57
+ for (const [key, value] of Object.entries(raw.failed)) {
58
+ const record = value;
59
+ if (key.length !== KEY_CHARS || !record)
60
+ continue;
61
+ if (typeof record.reason !== "string" || typeof record.attempts !== "number" || typeof record.retry_after !== "number")
62
+ continue;
63
+ kept[key] = { reason: record.reason, attempts: record.attempts, retry_after: record.retry_after };
64
+ }
65
+ return kept;
66
+ }
67
+ catch (error) {
68
+ if (!isMissingFileFailure(error)) {
69
+ console.error("[image-descriptions] failed-image list unreadable, starting from an empty list", JSON.stringify({ reason: "image_failed_state_unreadable" }));
70
+ }
71
+ return {};
72
+ }
73
+ }
74
+ export async function writeImageDescriptionState(stateDir, state) {
75
+ const keys = state.described;
39
76
  const kept = keys.length > IMAGE_DESCRIPTION_STATE_MAX_KEYS ? keys.slice(keys.length - IMAGE_DESCRIPTION_STATE_MAX_KEYS) : keys;
77
+ const failedEntries = Object.entries(state.failed).sort((a, b) => a[1].retry_after - b[1].retry_after);
78
+ const failed = Object.fromEntries(failedEntries.slice(Math.max(0, failedEntries.length - IMAGE_FAILED_STATE_MAX_KEYS)));
40
79
  await writeJsonFile(imageDescriptionStatePath(stateDir), {
41
80
  schema_version: "cockpit-image-descriptions.v1",
42
81
  described: kept,
82
+ failed,
43
83
  });
84
+ }
85
+ /** Writes the described list and keeps whatever failures are on record. */
86
+ export async function writeDescribedImageKeys(stateDir, keys) {
87
+ await writeImageDescriptionState(stateDir, { described: keys, failed: await readFailedImages(stateDir) });
44
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.207",
3
+ "version": "0.2.208",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {