crewx-bridge 0.2.7 → 0.2.9

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