@bridge4dev/runner 0.49.0 → 0.51.0

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.
@@ -58,8 +58,39 @@ export interface ProbeResult {
58
58
  stdout: string;
59
59
  stderr: string;
60
60
  }
61
- /** Run one probe. Rejects on a non-zero exit, a timeout or a missing binary. */
62
- export type ProbeRunner = (file: string, argv: readonly string[], timeoutMs: number) => Promise<ProbeResult>;
61
+ export interface ProbeOptions {
62
+ /**
63
+ * Keep the output of a command that exited non-zero.
64
+ *
65
+ * **This is the bug the flag exists for, and it is worth stating in full.**
66
+ * `codex doctor --json` exits 1 whenever ANY of its checks fails — and three
67
+ * of them are network checks (`network.provider_reachability`,
68
+ * `network.websocket_reachability`, `updates.status`) that have nothing to
69
+ * do with the question we are asking. Measured on a live machine
70
+ * (codex-cli 0.153.4): with the network reachable, exit 0; with it blocked,
71
+ * **exit 1 while `runtime.provenance` still reports `status: ok` and
72
+ * `install method: npm (…)` on stdout**.
73
+ *
74
+ * So a passing hiccup on somebody's dev server used to throw away an answer
75
+ * we already held, the machine reported «install method unknown», and the
76
+ * «Update» button disappeared from that agent's row for the hour the
77
+ * measurement is cached. It came back on its own later, which is why it read
78
+ * as a permanent property of three machines rather than as a fault.
79
+ *
80
+ * A verdict about the machine's health is not a verdict about whether the
81
+ * command told us what we asked. Timeouts and «no such binary» are still
82
+ * failures — those really are «no answer».
83
+ */
84
+ tolerateExitCode?: boolean;
85
+ }
86
+ /** Run one probe. Rejects on a timeout, a missing binary, or (by default) a non-zero exit. */
87
+ export type ProbeRunner = (file: string, argv: readonly string[], timeoutMs: number, options?: ProbeOptions) => Promise<ProbeResult>;
88
+ /**
89
+ * Exported for its own tests: the interesting behaviour of this function is
90
+ * entirely in how it reads `execFile`'s error, and a stub `ProbeRunner` — which
91
+ * is what every other test here uses — cannot reproduce that by construction.
92
+ */
93
+ export declare const runProbe: ProbeRunner;
63
94
  /**
64
95
  * Fold whatever an agent calls its install method into the five values the
65
96
  * product reasons about.
@@ -26,12 +26,30 @@ let cached = null;
26
26
  export function invalidateAgentVersions() {
27
27
  cached = null;
28
28
  }
29
- const runProbe = (file, argv, timeoutMs) => new Promise((resolve, reject) => {
29
+ /**
30
+ * Exported for its own tests: the interesting behaviour of this function is
31
+ * entirely in how it reads `execFile`'s error, and a stub `ProbeRunner` — which
32
+ * is what every other test here uses — cannot reproduce that by construction.
33
+ */
34
+ export const runProbe = (file, argv, timeoutMs, options) => new Promise((resolve, reject) => {
30
35
  const child = execFile(file, [...argv], { timeout: timeoutMs, maxBuffer: PROBE_MAX_BUFFER }, (error, stdout, stderr) => {
31
- if (error)
32
- reject(error);
33
- else
36
+ if (!error) {
37
+ resolve({ stdout, stderr });
38
+ return;
39
+ }
40
+ /*
41
+ * `killed` is how Node reports the timeout it was given, and a process
42
+ * we killed produced a TRUNCATED page of JSON — parsing that would be
43
+ * reading half an answer. A missing binary never runs at all and has no
44
+ * numeric exit code, so it falls through to `reject` too.
45
+ */
46
+ const exited = typeof error.code === 'number';
47
+ const killed = error.killed === true;
48
+ if (options?.tolerateExitCode && exited && !killed) {
34
49
  resolve({ stdout, stderr });
50
+ return;
51
+ }
52
+ reject(error);
35
53
  });
36
54
  // `claude doctor` reads stdin. An `execFile` child inherits an open pipe
37
55
  // nobody ever writes to, so without this EOF the probe would sit there
@@ -117,10 +135,16 @@ export async function measureAgent(runtime, run = runProbe) {
117
135
  }
118
136
  // The install method is a nice-to-have: it decides whether a BUTTON appears,
119
137
  // never whether the version is shown. A doctor that failed leaves `unknown`.
138
+ //
139
+ // `tolerateExitCode` — see `ProbeOptions`. The diagnostics command grades the
140
+ // whole machine and fails its own exit code over an unreachable endpoint,
141
+ // while answering our question perfectly on stdout.
120
142
  let managedBy = 'unknown';
121
143
  try {
122
144
  const probe = runtime.managedByProbe;
123
- const { stdout, stderr } = await run(binary, probe.argv, PROBE_TIMEOUT_MS);
145
+ const { stdout, stderr } = await run(binary, probe.argv, PROBE_TIMEOUT_MS, {
146
+ tolerateExitCode: true,
147
+ });
124
148
  const output = `${stdout}\n${stderr}`;
125
149
  const source = probe.jsonPath ? readJsonPath(stdout, probe.jsonPath) : output;
126
150
  managedBy = normalizeManagedBy(source ? matchProbe(probe, source) : null);
package/dist/index.js CHANGED
@@ -465,6 +465,12 @@ function runnerCapabilities(apiUrlOverride) {
465
465
  ? ['verify_start', 'verify_status', 'verify_cancel', 'preview_checkout', 'preview_stop']
466
466
  : []),
467
467
  ...(agentInstallEnabled ? ['agent_install'] : []),
468
+ // «Перемерить версии агентов сейчас» — единственный путь, которым машину
469
+ // можно СПРОСИТЬ про версии; всё остальное она присылает сама и по своему
470
+ // расписанию. Объявлено отдельно от `agent_install` и БЕЗ его условия:
471
+ // это чтение, а не установка, и машину, чей владелец запретил ставить из
472
+ // дашборда, спросить о том, что на ней стоит, по-прежнему можно.
473
+ 'agent_versions_refresh',
468
474
  ],
469
475
  };
470
476
  }
@@ -244,7 +244,17 @@ export declare class Supervisor {
244
244
  agent: string;
245
245
  from: string | null;
246
246
  to: string;
247
- }): Promise<void>;
247
+ },
248
+ /**
249
+ * Force a fresh probe on a path the reason alone does not describe.
250
+ *
251
+ * «Перемерить сейчас» sends `measured` — it IS a plain measurement — but it
252
+ * is also the one caller that must not be answered out of the hour-long
253
+ * cache, because a person pressed a button precisely to get past it.
254
+ */
255
+ options?: {
256
+ force?: boolean;
257
+ }): Promise<AgentVersionsMeasurement | null>;
248
258
  private queueVersionChange;
249
259
  /**
250
260
  * Р13: the agent of a starting session, moved forward in the background.
@@ -259,14 +259,22 @@ export class Supervisor {
259
259
  * missing audit lines are not the problem worth solving.
260
260
  */
261
261
  static MAX_PENDING_VERSION_CHANGES = 8;
262
- async publishAgentVersions(reason, changed) {
262
+ async publishAgentVersions(reason, changed,
263
+ /**
264
+ * Force a fresh probe on a path the reason alone does not describe.
265
+ *
266
+ * «Перемерить сейчас» sends `measured` — it IS a plain measurement — but it
267
+ * is also the one caller that must not be answered out of the hour-long
268
+ * cache, because a person pressed a button precisely to get past it.
269
+ */
270
+ options) {
263
271
  try {
264
272
  const measure = this.opts.measureAgentVersions ?? measureAgentVersions;
265
273
  // After an install — ours or the auto one — the cached number is a lie,
266
274
  // and a stale `at` with it would be dropped by the API's ordering guard
267
275
  // together with the audit line the install owes. Forcing it here means a
268
276
  // caller cannot forget to invalidate the cache first.
269
- const measurement = await measure({ force: reason !== 'measured' });
277
+ const measurement = await measure({ force: options?.force ?? reason !== 'measured' });
270
278
  // A plain tick carries an earlier install's news rather than replacing it.
271
279
  // The API keys the audit line by the measurement's `at`, so the same
272
280
  // change arriving under a later `at` is one line, not two.
@@ -294,11 +302,15 @@ export class Supervisor {
294
302
  void this.publishAgentVersions('measured');
295
303
  }
296
304
  }
305
+ // Handed back so a caller can answer with EXACTLY what was published,
306
+ // rather than measuring a second time and hoping the two agree.
307
+ return measurement;
297
308
  }
298
309
  catch (error) {
299
310
  if (changed && reason !== 'measured')
300
311
  this.queueVersionChange({ reason, changed });
301
312
  log.warn('supervisor: could not report agent versions', { error: String(error) });
313
+ return null;
302
314
  }
303
315
  }
304
316
  queueVersionChange(entry) {
@@ -5119,6 +5131,43 @@ export class Supervisor {
5119
5131
  void this.publishAgentVersions('manual', moved);
5120
5132
  return;
5121
5133
  }
5134
+ /**
5135
+ * «Перемерить версии агентов прямо сейчас» — за кнопкой «Check again».
5136
+ *
5137
+ * Единственный путь, которым машину можно СПРОСИТЬ про версии. Всё
5138
+ * остальное присылает она сама и по своему расписанию: кадр уходит на
5139
+ * каждом переподключении (из кэша), раз в час по таймеру, и свежий —
5140
+ * только после установки, которую сделал сам DevBridge.
5141
+ *
5142
+ * Отсюда и дыра, которую эта команда закрывает: человек обновил агента
5143
+ * руками через `claude update` на машине, раннеру об этом никто не
5144
+ * сказал, и карточка держала старое число до часа. «Новая сессия
5145
+ * чинила» его побочным эффектом — автообновление шло ставить, честно
5146
+ * мерило перед установкой и сбрасывало кэш, — а на машине с
5147
+ * ВЫКЛЮЧЕННЫМ автообновлением не происходило и этого.
5148
+ *
5149
+ * Меряем один раз и отдаём измеренное в ответе, а не только кадром:
5150
+ * кадр и ответ идут по одному сокету, но обрабатываются на той стороне
5151
+ * независимо, и запрос, который вернулся раньше, чем доехала запись,
5152
+ * оставил бы на экране ровно то устаревшее число, ради которого кнопку
5153
+ * и нажали. Кадр при этом всё равно уходит — у него свой путь с
5154
+ * аудитом, и повторная запись с тем же `at` отбрасывается сторожем
5155
+ * порядка на стороне API.
5156
+ */
5157
+ case 'agent_versions_refresh': {
5158
+ // Один замер на нажатие, и это структура, а не совпадение: публикация
5159
+ // отдаёт то, что измерила, и ответ собирается из НЕГО. Мерить второй
5160
+ // раз «для ответа» значило бы держаться за кэш, который вызывающий не
5161
+ // видит, — а кэш здесь и есть то, мимо чего нажимают кнопку.
5162
+ const snapshot = await this.publishAgentVersions('measured', undefined, { force: true });
5163
+ if (!snapshot) {
5164
+ return void reply({ ok: false, error: 'Could not measure the agents' });
5165
+ }
5166
+ return void reply({
5167
+ ok: true,
5168
+ result: { at: snapshot.at, reason: 'measured', agents: snapshot.agents },
5169
+ });
5170
+ }
5122
5171
  /**
5123
5172
  * «Would this file reach the agent, and how big is it» (ticket #192).
5124
5173
  *
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.49.0";
1
+ export declare const RUNNER_VERSION = "0.51.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.49.0';
2
+ export const RUNNER_VERSION = '0.51.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",