crewx-bridge 0.2.7 → 0.2.8

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/daemon.js CHANGED
@@ -1,34 +1,29 @@
1
- import { execFile } from "node:child_process";
1
+ import { randomBytes } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
- import { promisify } from "node:util";
3
+ import { probeAdapter } from "crewx-agent-cli/adapters";
4
4
  import { BridgeCommandPayloadSchema, } from "crewx-agent-protocol";
5
5
  import { BridgeApi } from "./api.js";
6
6
  import { BRIDGE_VERSION, ADAPTERS, DEFAULT_POLL_INTERVAL_MS } from "./constants.js";
7
+ import { BridgeDaemonLock, } from "./daemon-lock.js";
7
8
  import { BridgeError, errorMessage, redactSecrets } from "./errors.js";
8
9
  import { folderDescriptors, verifyLaunchFolder } from "./folders.js";
9
10
  import { BridgeJournal } from "./journal.js";
10
11
  import { validateStartPolicy } from "./policy.js";
11
12
  import { BridgeRunLogs, publicRunDiagnostic } from "./run-logs.js";
12
13
  import { ProcessSupervisor } from "./supervisor.js";
13
- const execFileAsync = promisify(execFile);
14
- async function probeRuntime(adapter) {
15
- try {
16
- const result = await execFileAsync(adapter, ["--version"], {
17
- encoding: "utf8",
18
- timeout: 5_000,
19
- maxBuffer: 64 * 1024,
20
- });
21
- const output = `${result.stdout}\n${result.stderr}`;
22
- const version = output.match(/\b\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z._-]+)?\b/)?.[0];
23
- return { available: true, ...(version ? { version } : {}) };
24
- }
25
- catch {
26
- return { available: false };
27
- }
14
+ const CAPABILITY_REFRESH_INTERVAL_MS = 5 * 60 * 1_000;
15
+ const LEGACY_OUTCOME_RETRY_INTERVAL_MS = 5 * 60 * 1_000;
16
+ const MAX_IDLE_CLAIM_DELAY_MS = 8_000;
17
+ export function adaptiveIdleClaimDelay(serverMinimumMs, consecutiveEmptyClaims, random = Math.random) {
18
+ const serverMinimum = Math.max(250, Math.ceil(serverMinimumMs));
19
+ const exponential = Math.min(MAX_IDLE_CLAIM_DELAY_MS, DEFAULT_POLL_INTERVAL_MS *
20
+ 2 ** Math.max(0, Math.min(8, consecutiveEmptyClaims - 1)));
21
+ const sampled = Math.floor(Math.max(0, Math.min(1, random())) * exponential);
22
+ return Math.max(serverMinimum, Math.min(MAX_IDLE_CLAIM_DELAY_MS, sampled));
28
23
  }
29
- export async function probeCapabilities() {
24
+ export async function probeCapabilities(runtimeProbe = probeAdapter) {
30
25
  const runtimes = await Promise.all(ADAPTERS.map(async (adapter) => {
31
- const result = await probeRuntime(adapter);
26
+ const result = await runtimeProbe(adapter);
32
27
  return {
33
28
  adapter,
34
29
  available: result.available,
@@ -45,12 +40,65 @@ export function policySummary(config) {
45
40
  max_concurrent_runs: config.policy.maxConcurrentRuns,
46
41
  };
47
42
  }
43
+ export function bridgeHealth(journal) {
44
+ const quarantined = journal.quarantinedOutcomes(16);
45
+ const backlog = journal
46
+ .unreportedOutcomes(129)
47
+ .filter((outcome) => outcome.reconciliation?.compatibility !== "legacy_lease");
48
+ const issues = quarantined.map((outcome) => {
49
+ const status = outcome.reconciliation?.status;
50
+ const recovery = `Stop the service, then run \`crewx-bridge outcomes retry ${outcome.id} --yes\` or ` +
51
+ `\`crewx-bridge outcomes resolve ${outcome.id} --accept-server-state --yes\`, then start the service.`;
52
+ return {
53
+ code: status === 404
54
+ ? "outcome_command_missing"
55
+ : status === 409
56
+ ? "outcome_reconciliation_conflict"
57
+ : status === 401 || status === 403
58
+ ? "outcome_auth_rejected"
59
+ : "outcome_reconciliation_rejected",
60
+ message: status === 401 || status === 403
61
+ ? `CrewX rejected this machine credential. Re-enroll the machine from CrewX before retrying outcome ${outcome.id}. ${recovery}`
62
+ : `CrewX could not reconcile local outcome ${outcome.id}${status ? ` (HTTP ${status})` : ""}. New work is paused. ${recovery}`,
63
+ command_id: outcome.id,
64
+ occurred_at: outcome.reconciliation?.quarantinedAt ??
65
+ outcome.reconciliation?.lastAttemptAt ??
66
+ outcome.updatedAt,
67
+ };
68
+ });
69
+ if (backlog.length > 128 && issues.length < 16) {
70
+ issues.push({
71
+ code: "outcome_reconciliation_backlog",
72
+ message: "CrewX Bridge paused new work because too many completed commands are awaiting server confirmation.",
73
+ occurred_at: backlog[0]?.reconciliation?.lastAttemptAt ??
74
+ backlog[0]?.updatedAt ??
75
+ new Date().toISOString(),
76
+ });
77
+ }
78
+ else {
79
+ for (const outcome of backlog.slice(0, 16 - issues.length)) {
80
+ issues.push({
81
+ code: "outcome_reconciliation_pending",
82
+ message: "A completed local command is waiting for CrewX to confirm its outcome.",
83
+ command_id: outcome.id,
84
+ occurred_at: outcome.reconciliation?.lastAttemptAt ?? outcome.updatedAt,
85
+ });
86
+ }
87
+ }
88
+ return {
89
+ status: issues.length > 0 ? "degraded" : "healthy",
90
+ issues,
91
+ };
92
+ }
48
93
  function commandResult(runStatus, pid) {
49
94
  return {
50
95
  run_status: runStatus,
51
96
  ...(pid !== undefined ? { pid } : {}),
52
97
  };
53
98
  }
99
+ function isTerminalRunStatus(status) {
100
+ return status === "stopped" || status === "failed" || status === "lost";
101
+ }
54
102
  export class BridgeDaemon {
55
103
  config;
56
104
  api;
@@ -59,8 +107,16 @@ export class BridgeDaemon {
59
107
  capabilities;
60
108
  log;
61
109
  runLogs;
110
+ capabilityProbe;
111
+ now;
112
+ random;
62
113
  lastHeartbeat = 0;
63
- constructor(config, api, journal, supervisor, capabilities, log = () => undefined, runLogs = new BridgeRunLogs()) {
114
+ lastCapabilityProbe;
115
+ outcomeReconcileFailures = 0;
116
+ nextOutcomeReconcileAt = 0;
117
+ consecutiveEmptyClaims = 0;
118
+ nextClaimAt = 0;
119
+ constructor(config, api, journal, supervisor, capabilities, log = () => undefined, runLogs = new BridgeRunLogs(), capabilityProbe = probeCapabilities, now = Date.now, random = Math.random) {
64
120
  this.config = config;
65
121
  this.api = api;
66
122
  this.journal = journal;
@@ -68,17 +124,36 @@ export class BridgeDaemon {
68
124
  this.capabilities = capabilities;
69
125
  this.log = log;
70
126
  this.runLogs = runLogs;
127
+ this.capabilityProbe = capabilityProbe;
128
+ this.now = now;
129
+ this.random = random;
130
+ this.lastCapabilityProbe = this.now();
71
131
  }
72
132
  async initialize() {
73
133
  await this.journal.load();
74
134
  await this.api.syncFolders({ folders: folderDescriptors(this.config) });
135
+ await this.reconcileOutcomes(true);
75
136
  await this.heartbeat();
76
137
  }
77
138
  async heartbeat() {
139
+ if (this.now() - this.lastCapabilityProbe >=
140
+ CAPABILITY_REFRESH_INTERVAL_MS) {
141
+ this.lastCapabilityProbe = this.now();
142
+ try {
143
+ this.capabilities = await this.capabilityProbe();
144
+ }
145
+ catch (error) {
146
+ this.log(`Runtime capability refresh failed; retaining the last known inventory: ${redactSecrets(errorMessage(error))}.`);
147
+ }
148
+ }
78
149
  const runs = [];
79
150
  const reportedRunIds = new Set();
80
151
  for (const run of this.journal.activeRuns()) {
81
- if (this.supervisor.isRunning(run.id, run.pid)) {
152
+ const supervised = this.supervisor.isRunning(run.id, run.pid) ||
153
+ Boolean(run.pid &&
154
+ run.processNonce &&
155
+ this.supervisor.adopt?.(run.id, run.pid, run.processNonce));
156
+ if (supervised) {
82
157
  runs.push({
83
158
  id: run.id,
84
159
  status: run.status === "starting" ? "starting" : "running",
@@ -143,71 +218,279 @@ export class BridgeDaemon {
143
218
  capabilities: this.capabilities,
144
219
  policy: policySummary(this.config),
145
220
  runs,
221
+ health: bridgeHealth(this.journal),
146
222
  });
147
- this.lastHeartbeat = Date.now();
223
+ this.lastHeartbeat = this.now();
148
224
  }
149
225
  async tick() {
150
- if (Date.now() - this.lastHeartbeat >=
226
+ await this.reconcileOutcomes();
227
+ if (this.now() - this.lastHeartbeat >=
151
228
  this.config.heartbeatInterval * 1_000) {
152
229
  await this.heartbeat();
153
230
  }
231
+ if (this.journal.requiresClaimBackpressure()) {
232
+ return this.delayUntilHeartbeat(5_000);
233
+ }
234
+ const untilClaim = this.nextClaimAt - this.now();
235
+ if (untilClaim > 0) {
236
+ return this.delayUntilHeartbeat(untilClaim);
237
+ }
154
238
  const response = await this.api.claim();
155
- if (!response.command)
156
- return response.retry_after_ms;
239
+ if (!response.command) {
240
+ this.consecutiveEmptyClaims += 1;
241
+ const delay = adaptiveIdleClaimDelay(response.retry_after_ms, this.consecutiveEmptyClaims, this.random);
242
+ this.nextClaimAt = this.now() + delay;
243
+ return this.delayUntilHeartbeat(delay);
244
+ }
245
+ this.consecutiveEmptyClaims = 0;
246
+ this.nextClaimAt = 0;
157
247
  await this.execute(response.command);
158
248
  return 250;
159
249
  }
160
- async execute(command) {
161
- const existing = this.journal.getCommand(command.id);
162
- if (existing?.state === "succeeded" && existing.result) {
163
- await this.api.complete(command.id, {
164
- lease_token: command.lease_token,
165
- result: existing.result,
166
- });
250
+ delayUntilHeartbeat(delay) {
251
+ const untilHeartbeat = this.lastHeartbeat +
252
+ this.config.heartbeatInterval * 1_000 -
253
+ this.now();
254
+ return Math.max(250, Math.min(delay, Math.max(250, untilHeartbeat)));
255
+ }
256
+ async reconcileOutcomes(force = false) {
257
+ if (!force && this.now() < this.nextOutcomeReconcileAt)
258
+ return;
259
+ const pending = this.journal.unreportedOutcomes(32);
260
+ if (pending.length === 0) {
261
+ this.outcomeReconcileFailures = 0;
262
+ this.nextOutcomeReconcileAt = 0;
167
263
  return;
168
264
  }
169
- if (existing?.state === "failed") {
170
- await this.api.fail(command.id, {
171
- lease_token: command.lease_token,
172
- code: existing.errorCode ?? "local_execution_failed",
173
- message: "This command previously failed locally.",
174
- });
265
+ let retainedFailure = false;
266
+ for (const outcome of pending) {
267
+ let request;
268
+ if (outcome.state === "succeeded") {
269
+ if (!outcome.result?.run_status) {
270
+ retainedFailure = true;
271
+ await this.journal.recordReconciliationFailure(outcome.id, {
272
+ quarantined: true,
273
+ errorCode: "invalid_local_outcome",
274
+ errorMessage: "The durable local success is missing its run status and cannot be reconciled automatically.",
275
+ });
276
+ continue;
277
+ }
278
+ request = {
279
+ outcome: "succeeded",
280
+ result: {
281
+ ...outcome.result,
282
+ run_status: outcome.result.run_status,
283
+ },
284
+ };
285
+ }
286
+ else {
287
+ request = {
288
+ outcome: "failed",
289
+ error: {
290
+ code: outcome.errorCode &&
291
+ /^[a-z0-9_]{1,80}$/.test(outcome.errorCode)
292
+ ? outcome.errorCode
293
+ : "local_execution_failed",
294
+ message: publicRunDiagnostic(outcome.errorMessage ??
295
+ "This command previously failed locally.") ||
296
+ "The Bridge command failed locally. Check the protected runner log on the connected machine.",
297
+ },
298
+ };
299
+ }
300
+ try {
301
+ await this.api.reconcileOutcome(outcome.id, request);
302
+ await this.journal.markCommandReported(outcome.id);
303
+ }
304
+ catch (error) {
305
+ const status = error instanceof BridgeError ? error.status : undefined;
306
+ const endpointUnsupported = status === 405 ||
307
+ (error instanceof BridgeError &&
308
+ error.serverCode === "endpoint_unsupported");
309
+ if (endpointUnsupported) {
310
+ await this.journal.recordReconciliationFailure(outcome.id, {
311
+ quarantined: false,
312
+ status,
313
+ errorCode: "outcome_endpoint_unsupported",
314
+ errorMessage: "This CrewX server uses legacy lease-bound outcome reporting. The durable outcome will be sent when the command is redelivered.",
315
+ compatibility: "legacy_lease",
316
+ });
317
+ this.nextOutcomeReconcileAt =
318
+ this.now() + LEGACY_OUTCOME_RETRY_INTERVAL_MS;
319
+ this.log(`CrewX does not expose durable outcome reconciliation yet; retaining ${outcome.id} for legacy lease redelivery.`);
320
+ return;
321
+ }
322
+ retainedFailure = true;
323
+ const previousAttempts = outcome.reconciliation?.attempts ?? 0;
324
+ const quarantined = (error instanceof BridgeError && !error.retryable) ||
325
+ (!(error instanceof BridgeError) && previousAttempts >= 7);
326
+ await this.journal.recordReconciliationFailure(outcome.id, {
327
+ quarantined,
328
+ ...(status !== undefined ? { status } : {}),
329
+ errorCode: error instanceof BridgeError
330
+ ? error.serverCode ?? error.code
331
+ : "outcome_reconciliation_failed",
332
+ errorMessage: publicRunDiagnostic(errorMessage(error)) ||
333
+ "CrewX could not reconcile this command outcome.",
334
+ });
335
+ this.outcomeReconcileFailures += 1;
336
+ const retryCap = Math.min(30_000, 1_000 * 2 ** Math.min(5, this.outcomeReconcileFailures - 1));
337
+ const jitter = Math.floor(Math.random() * (retryCap + 1));
338
+ const retryAfter = error instanceof BridgeError ? error.retryAfterMs : undefined;
339
+ this.nextOutcomeReconcileAt =
340
+ this.now() + Math.max(1_000, jitter, retryAfter ?? 0);
341
+ this.log(`CrewX has not confirmed durable command outcome ${outcome.id}; it remains queued for reconciliation: ${redactSecrets(errorMessage(error))}`);
342
+ if (!quarantined)
343
+ return;
344
+ }
345
+ }
346
+ if (!retainedFailure) {
347
+ this.outcomeReconcileFailures = 0;
348
+ this.nextOutcomeReconcileAt = 0;
349
+ }
350
+ }
351
+ async execute(command) {
352
+ const existing = this.journal.getCommand(command.id);
353
+ if (existing?.state === "succeeded" || existing?.state === "failed") {
354
+ await this.reportOutcome(command);
175
355
  return;
176
356
  }
357
+ let parsedPayload;
177
358
  try {
178
- const payload = BridgeCommandPayloadSchema.parse(command.payload);
179
- if (payload.type === "start_agent") {
180
- await this.start(command, payload);
359
+ parsedPayload = BridgeCommandPayloadSchema.parse(command.payload);
360
+ if (parsedPayload.type === "start_agent") {
361
+ await this.start(command, parsedPayload);
181
362
  }
182
363
  else {
183
- await this.stop(command, payload.run_id);
364
+ await this.stop(command, parsedPayload.run_id);
184
365
  }
185
366
  }
186
367
  catch (error) {
368
+ const recorded = this.journal.getCommand(command.id);
369
+ if (recorded?.state === "succeeded" || recorded?.state === "failed") {
370
+ await this.reportOutcome(command);
371
+ return;
372
+ }
373
+ if (recorded?.state === "prepared") {
374
+ this.log(`Command ${command.id} is prepared locally; acknowledgement delivery is pending and will be retried after redelivery.`);
375
+ return;
376
+ }
377
+ if (parsedPayload &&
378
+ (await this.recoverAppliedOutcome(command, parsedPayload))) {
379
+ return;
380
+ }
187
381
  const code = error instanceof BridgeError ? error.code : "invalid_or_failed_command";
188
- const message = redactSecrets(errorMessage(error), [
382
+ const message = publicRunDiagnostic(redactSecrets(errorMessage(error), [
189
383
  command.lease_token,
190
384
  command.secret?.agent_token ?? "",
191
- ]).slice(0, 1000);
385
+ ])).slice(0, 1000) ||
386
+ "The Bridge command failed locally. Check the protected runner log on the connected machine.";
192
387
  await this.journal.recordCommand(command.id, {
193
388
  state: "failed",
194
389
  runId: command.payload.run_id,
195
390
  type: command.payload.type,
196
391
  errorCode: code,
392
+ errorMessage: message,
197
393
  });
198
- await this.api.fail(command.id, {
199
- lease_token: command.lease_token,
200
- code,
201
- message,
202
- });
394
+ await this.reportOutcome(command);
203
395
  this.log(`Command ${command.id} failed: ${code}.`);
204
396
  }
205
397
  }
398
+ async reportOutcome(command) {
399
+ const outcome = this.journal.getCommand(command.id);
400
+ if (!outcome)
401
+ return;
402
+ try {
403
+ if (outcome.state === "succeeded" && outcome.result) {
404
+ await this.api.complete(command.id, {
405
+ lease_token: command.lease_token,
406
+ result: outcome.result,
407
+ });
408
+ await this.journal.markCommandReported(command.id);
409
+ return;
410
+ }
411
+ if (outcome.state === "failed") {
412
+ const message = publicRunDiagnostic(outcome.errorMessage ??
413
+ "This command previously failed locally.") ||
414
+ "The Bridge command failed locally. Check the protected runner log on the connected machine.";
415
+ await this.api.fail(command.id, {
416
+ lease_token: command.lease_token,
417
+ code: outcome.errorCode ?? "local_execution_failed",
418
+ message,
419
+ });
420
+ await this.journal.markCommandReported(command.id);
421
+ }
422
+ }
423
+ catch (error) {
424
+ this.log(`Command ${command.id} has a durable local outcome, but CrewX has not confirmed it yet: ${redactSecrets(errorMessage(error), [command.lease_token])}`);
425
+ }
426
+ }
427
+ async recoverAppliedOutcome(command, payload) {
428
+ const recorded = this.journal.getCommand(command.id);
429
+ if (recorded?.state !== "accepted")
430
+ return false;
431
+ const run = this.journal.getRun(payload.run_id);
432
+ if (payload.type === "start_agent") {
433
+ const active = this.supervisor
434
+ .activeRuns()
435
+ .find((candidate) => candidate.id === payload.run_id);
436
+ if (!active || !this.supervisor.isRunning(payload.run_id, active.pid)) {
437
+ return false;
438
+ }
439
+ const result = commandResult("running", active.pid);
440
+ try {
441
+ await this.journal.recordRun(payload.run_id, {
442
+ status: "running",
443
+ pid: active.pid,
444
+ ...(run?.processNonce
445
+ ? { processNonce: run.processNonce }
446
+ : {}),
447
+ adapter: payload.adapter,
448
+ folderId: payload.folder_id,
449
+ startedAt: run?.startedAt ?? new Date().toISOString(),
450
+ });
451
+ await this.journal.recordCommand(command.id, {
452
+ state: "succeeded",
453
+ runId: payload.run_id,
454
+ type: "start_agent",
455
+ result,
456
+ });
457
+ await this.reportOutcome(command);
458
+ }
459
+ catch (journalError) {
460
+ this.log(`Started run ${payload.run_id}, but its durable completion could not be recorded: ${redactSecrets(errorMessage(journalError))}`);
461
+ }
462
+ return true;
463
+ }
464
+ if (this.supervisor.isRunning(payload.run_id, run?.pid))
465
+ return false;
466
+ const result = commandResult("stopped", run?.pid);
467
+ try {
468
+ if (run) {
469
+ await this.journal.recordRun(payload.run_id, {
470
+ ...run,
471
+ status: "stopped",
472
+ endedAt: run.endedAt ?? new Date().toISOString(),
473
+ });
474
+ }
475
+ await this.journal.recordCommand(command.id, {
476
+ state: "succeeded",
477
+ runId: payload.run_id,
478
+ type: "stop_agent",
479
+ result,
480
+ });
481
+ await this.reportOutcome(command);
482
+ }
483
+ catch (journalError) {
484
+ this.log(`Stopped run ${payload.run_id}, but its durable completion could not be recorded: ${redactSecrets(errorMessage(journalError))}`);
485
+ }
486
+ return true;
487
+ }
206
488
  async start(command, rawPayload) {
207
489
  const payload = validateStartPolicy(this.config, rawPayload, this.supervisor.activeCount());
208
490
  if (!command.secret) {
209
491
  throw new BridgeError("missing_command_secret", "The start command does not include a run credential.");
210
492
  }
493
+ const folder = await verifyLaunchFolder(this.config, payload.folder_id);
211
494
  const existingRun = this.journal.getRun(payload.run_id);
212
495
  const existingCommand = this.journal.getCommand(command.id);
213
496
  if (existingCommand?.state === "accepted") {
@@ -220,39 +503,62 @@ export class BridgeDaemon {
220
503
  type: "start_agent",
221
504
  result,
222
505
  });
223
- await this.api.complete(command.id, {
224
- lease_token: command.lease_token,
506
+ await this.reportOutcome(command);
507
+ return;
508
+ }
509
+ if (existingRun && isTerminalRunStatus(existingRun.status)) {
510
+ const result = commandResult(existingRun.status, existingRun.pid);
511
+ await this.journal.recordCommand(command.id, {
512
+ state: "succeeded",
513
+ runId: payload.run_id,
514
+ type: "start_agent",
225
515
  result,
226
516
  });
517
+ await this.reportOutcome(command);
227
518
  return;
228
519
  }
229
- throw new BridgeError("ambiguous_previous_start", "A prior start was accepted but its process cannot be verified; refusing to spawn a duplicate.");
520
+ if (!existingRun) {
521
+ // The remote acknowledgement was durable, but the local effect had not
522
+ // begun. It is safe to continue without spawning a duplicate.
523
+ }
524
+ else {
525
+ throw new BridgeError("ambiguous_previous_start", "A prior start was accepted but its process cannot be verified; refusing to spawn a duplicate.");
526
+ }
527
+ }
528
+ else {
529
+ await this.journal.recordCommand(command.id, {
530
+ state: "prepared",
531
+ runId: payload.run_id,
532
+ type: "start_agent",
533
+ });
534
+ await this.api.acknowledge(command.id, {
535
+ lease_token: command.lease_token,
536
+ });
537
+ await this.journal.recordCommand(command.id, {
538
+ state: "accepted",
539
+ runId: payload.run_id,
540
+ type: "start_agent",
541
+ });
230
542
  }
231
- const folder = await verifyLaunchFolder(this.config, payload.folder_id);
232
- await this.journal.recordCommand(command.id, {
233
- state: "accepted",
234
- runId: payload.run_id,
235
- type: "start_agent",
236
- });
237
- await this.api.acknowledge(command.id, {
238
- lease_token: command.lease_token,
239
- });
240
543
  const startedAt = new Date().toISOString();
544
+ const processNonce = randomBytes(32).toString("base64url");
241
545
  await this.journal.recordRun(payload.run_id, {
242
546
  status: "starting",
243
547
  pid: null,
548
+ processNonce,
244
549
  adapter: payload.adapter,
245
550
  folderId: payload.folder_id,
246
551
  startedAt,
247
552
  });
248
553
  let pid;
249
554
  try {
250
- pid = this.supervisor.start(payload, command.secret, folder);
555
+ pid = this.supervisor.start(payload, command.secret, folder, processNonce);
251
556
  }
252
557
  catch (error) {
253
558
  await this.journal.recordRun(payload.run_id, {
254
559
  status: "failed",
255
560
  pid: null,
561
+ processNonce,
256
562
  adapter: payload.adapter,
257
563
  folderId: payload.folder_id,
258
564
  startedAt,
@@ -265,6 +571,7 @@ export class BridgeDaemon {
265
571
  await this.journal.recordRun(payload.run_id, {
266
572
  status: "running",
267
573
  pid,
574
+ processNonce,
268
575
  adapter: payload.adapter,
269
576
  folderId: payload.folder_id,
270
577
  startedAt,
@@ -276,10 +583,7 @@ export class BridgeDaemon {
276
583
  type: "start_agent",
277
584
  result,
278
585
  });
279
- await this.api.complete(command.id, {
280
- lease_token: command.lease_token,
281
- result,
282
- });
586
+ await this.reportOutcome(command);
283
587
  this.log(`Started ${payload.adapter} run ${payload.run_id}.`);
284
588
  }
285
589
  async stop(command, runId) {
@@ -293,23 +597,31 @@ export class BridgeDaemon {
293
597
  type: "stop_agent",
294
598
  result,
295
599
  });
296
- await this.api.complete(command.id, {
600
+ await this.reportOutcome(command);
601
+ return;
602
+ }
603
+ if (existing?.state !== "accepted") {
604
+ await this.journal.recordCommand(command.id, {
605
+ state: "prepared",
606
+ runId,
607
+ type: "stop_agent",
608
+ });
609
+ await this.api.acknowledge(command.id, {
297
610
  lease_token: command.lease_token,
298
- result,
299
611
  });
300
- return;
612
+ await this.journal.recordCommand(command.id, {
613
+ state: "accepted",
614
+ runId,
615
+ type: "stop_agent",
616
+ });
301
617
  }
302
- await this.journal.recordCommand(command.id, {
303
- state: "accepted",
304
- runId,
305
- type: "stop_agent",
306
- });
307
- await this.api.acknowledge(command.id, {
308
- lease_token: command.lease_token,
309
- });
310
618
  await this.supervisor.stop(runId, run?.pid);
311
619
  if (run) {
312
- await this.journal.recordRun(runId, { ...run, status: "stopped" });
620
+ await this.journal.recordRun(runId, {
621
+ ...run,
622
+ status: "stopped",
623
+ endedAt: run.endedAt ?? new Date().toISOString(),
624
+ });
313
625
  }
314
626
  const result = commandResult("stopped", run?.pid);
315
627
  await this.journal.recordCommand(command.id, {
@@ -318,10 +630,7 @@ export class BridgeDaemon {
318
630
  type: "stop_agent",
319
631
  result,
320
632
  });
321
- await this.api.complete(command.id, {
322
- lease_token: command.lease_token,
323
- result,
324
- });
633
+ await this.reportOutcome(command);
325
634
  this.log(`Stopped run ${runId}.`);
326
635
  }
327
636
  }
@@ -332,70 +641,87 @@ export async function runBridge(options) {
332
641
  if (typeof process.getuid === "function" && process.getuid() === 0) {
333
642
  throw new BridgeError("root_refused", "CrewX Bridge refuses to run as root.");
334
643
  }
335
- const api = options.api ?? new BridgeApi(options.config);
336
- const journal = options.journal ?? new BridgeJournal();
337
- const log = options.log ?? (() => undefined);
338
- const runLogs = options.runLogs ?? new BridgeRunLogs();
339
- const supervisor = options.supervisor ??
340
- new ProcessSupervisor(options.config, undefined, undefined, ({ runId, pid, code, signal, expectedStop, error, errorCode, errorMessage: runErrorMessage, logPath, }) => {
341
- const recorded = journal.getRun(runId);
342
- if (recorded) {
343
- void journal
344
- .recordRun(runId, {
345
- ...recorded,
346
- status: expectedStop || (!error && code === 0) ? "stopped" : "failed",
347
- endedAt: new Date().toISOString(),
348
- exitCode: code,
349
- signal,
350
- ...(errorCode ? { errorCode } : {}),
351
- ...(runErrorMessage ? { errorMessage: runErrorMessage } : {}),
352
- })
353
- .catch((journalError) => {
354
- log(`Could not record the exit for agent run ${runId}: ${redactSecrets(errorMessage(journalError))}.`);
355
- });
356
- }
357
- if (error) {
358
- log(`Agent run ${runId} (PID ${pid}) failed: ${redactSecrets(runErrorMessage ?? errorMessage(error))}.${logPath ? ` Local log: ${logPath}` : ""}`);
359
- return;
360
- }
361
- const result = signal
362
- ? `signal ${signal}`
363
- : `exit code ${code ?? "unknown"}`;
364
- log(`Agent run ${runId} (PID ${pid}) exited with ${result}.${runErrorMessage ? ` ${runErrorMessage}` : ""}${logPath ? ` Local log: ${logPath}` : ""}`);
365
- }, runLogs);
366
- const capabilities = options.capabilities ?? (await probeCapabilities());
367
- const daemon = new BridgeDaemon(options.config, api, journal, supervisor, capabilities, log, runLogs);
368
- await daemon.initialize();
369
- if (options.once) {
370
- await daemon.tick();
371
- return;
372
- }
644
+ const daemonLock = options.daemonLock ?? new BridgeDaemonLock();
645
+ await daemonLock.acquire();
373
646
  try {
374
- while (!options.signal?.aborted) {
375
- let delay = DEFAULT_POLL_INTERVAL_MS;
376
- try {
377
- delay = await daemon.tick();
647
+ const api = options.api ?? new BridgeApi(options.config);
648
+ const journal = options.journal ?? new BridgeJournal();
649
+ const log = options.log ?? (() => undefined);
650
+ const runLogs = options.runLogs ?? new BridgeRunLogs();
651
+ const supervisor = options.supervisor ??
652
+ new ProcessSupervisor(options.config, undefined, undefined, ({ runId, pid, code, signal, expectedStop, error, errorCode, errorMessage: runErrorMessage, logPath, }) => {
653
+ const recorded = journal.getRun(runId);
654
+ if (recorded) {
655
+ void journal
656
+ .recordRun(runId, {
657
+ ...recorded,
658
+ status: expectedStop || (!error && code === 0)
659
+ ? "stopped"
660
+ : "failed",
661
+ endedAt: new Date().toISOString(),
662
+ exitCode: code,
663
+ signal,
664
+ ...(errorCode ? { errorCode } : {}),
665
+ ...(runErrorMessage ? { errorMessage: runErrorMessage } : {}),
666
+ })
667
+ .catch((journalError) => {
668
+ log(`Could not record the exit for agent run ${runId}: ${redactSecrets(errorMessage(journalError))}.`);
669
+ });
670
+ }
671
+ if (error) {
672
+ log(`Agent run ${runId} (PID ${pid}) failed: ${redactSecrets(runErrorMessage ?? errorMessage(error))}.${logPath ? ` Local log: ${logPath}` : ""}`);
673
+ return;
674
+ }
675
+ const result = signal
676
+ ? `signal ${signal}`
677
+ : `exit code ${code ?? "unknown"}`;
678
+ log(`Agent run ${runId} (PID ${pid}) exited with ${result}.${runErrorMessage ? ` ${runErrorMessage}` : ""}${logPath ? ` Local log: ${logPath}` : ""}`);
679
+ }, runLogs);
680
+ const capabilities = options.capabilities ?? (await probeCapabilities());
681
+ const daemon = new BridgeDaemon(options.config, api, journal, supervisor, capabilities, log, runLogs);
682
+ await daemon.initialize();
683
+ if (options.once) {
684
+ await daemon.tick();
685
+ return;
686
+ }
687
+ let consecutiveFailures = 0;
688
+ try {
689
+ while (!options.signal?.aborted) {
690
+ let delay = DEFAULT_POLL_INTERVAL_MS;
691
+ try {
692
+ delay = await daemon.tick();
693
+ consecutiveFailures = 0;
694
+ }
695
+ catch (error) {
696
+ options.log?.(`Control loop error: ${redactSecrets(errorMessage(error))}`);
697
+ consecutiveFailures += 1;
698
+ const retryCap = Math.min(30_000, DEFAULT_POLL_INTERVAL_MS * 2 ** Math.min(8, consecutiveFailures));
699
+ const jitter = Math.floor(Math.random() * (retryCap + 1));
700
+ const serverDelay = error instanceof BridgeError ? error.retryAfterMs : undefined;
701
+ delay =
702
+ error instanceof BridgeError && !error.retryable
703
+ ? Math.max(30_000, serverDelay ?? 0)
704
+ : Math.max(250, jitter, serverDelay ?? 0);
705
+ }
706
+ await new Promise((resolve) => {
707
+ const timer = setTimeout(resolve, delay);
708
+ options.signal?.addEventListener("abort", () => {
709
+ clearTimeout(timer);
710
+ resolve();
711
+ }, { once: true });
712
+ });
378
713
  }
379
- catch (error) {
380
- options.log?.(`Control loop error: ${redactSecrets(errorMessage(error))}`);
381
- delay = Math.min(30_000, DEFAULT_POLL_INTERVAL_MS * 2);
714
+ }
715
+ finally {
716
+ const activeRuns = supervisor.activeCount();
717
+ if (activeRuns > 0) {
718
+ log(`Bridge is stopping ${activeRuns} supervised agent ${activeRuns === 1 ? "run" : "runs"}. Relaunch them from CrewX after the service returns.`);
382
719
  }
383
- const jitter = Math.floor(Math.random() * Math.min(500, delay / 4));
384
- await new Promise((resolve) => {
385
- const timer = setTimeout(resolve, delay + jitter);
386
- options.signal?.addEventListener("abort", () => {
387
- clearTimeout(timer);
388
- resolve();
389
- }, { once: true });
390
- });
720
+ await supervisor.stopAll();
391
721
  }
392
722
  }
393
723
  finally {
394
- const activeRuns = supervisor.activeCount();
395
- if (activeRuns > 0) {
396
- log(`Bridge is stopping ${activeRuns} supervised agent ${activeRuns === 1 ? "run" : "runs"}. Relaunch them from CrewX after the service returns.`);
397
- }
398
- await supervisor.stopAll();
724
+ await daemonLock.release();
399
725
  }
400
726
  }
401
727
  export function supportedAdapters() {