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.
- package/README.md +82 -10
- package/dist/api.d.ts +21 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +118 -7
- package/dist/api.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/daemon-lock.d.ts +29 -0
- package/dist/daemon-lock.d.ts.map +1 -0
- package/dist/daemon-lock.js +257 -0
- package/dist/daemon-lock.js.map +1 -0
- package/dist/daemon.d.ts +21 -4
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +506 -145
- package/dist/daemon.js.map +1 -1
- package/dist/errors.d.ts +12 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +19 -5
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +30 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +185 -22
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +51 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +213 -4
- package/dist/journal.js.map +1 -1
- package/dist/outcomes.d.ts +12 -0
- package/dist/outcomes.d.ts.map +1 -0
- package/dist/outcomes.js +52 -0
- package/dist/outcomes.js.map +1 -0
- package/dist/service.d.ts +14 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +80 -1
- package/dist/service.js.map +1 -1
- package/dist/supervisor.d.ts +12 -2
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +105 -14
- package/dist/supervisor.js.map +1 -1
- package/dist/update.d.ts +2 -0
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +184 -8
- package/dist/update.js.map +1 -1
- package/package.json +3 -3
package/dist/daemon.js
CHANGED
|
@@ -1,38 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
|
-
import {
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 =
|
|
252
|
+
this.lastHeartbeat = this.now();
|
|
148
253
|
}
|
|
149
254
|
async tick() {
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
if (
|
|
180
|
-
await this.start(command,
|
|
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,
|
|
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.
|
|
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.
|
|
224
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
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
|
|
336
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|