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/README.md +78 -8
- package/dist/api.d.ts +10 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +102 -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 +466 -140
- 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 +16 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -19
- 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/package.json +3 -3
package/dist/daemon.js
CHANGED
|
@@ -1,34 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
|
-
import {
|
|
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
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
223
|
+
this.lastHeartbeat = this.now();
|
|
148
224
|
}
|
|
149
225
|
async tick() {
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
if (
|
|
180
|
-
await this.start(command,
|
|
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,
|
|
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.
|
|
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.
|
|
224
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
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
|
|
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
|
-
}
|
|
644
|
+
const daemonLock = options.daemonLock ?? new BridgeDaemonLock();
|
|
645
|
+
await daemonLock.acquire();
|
|
373
646
|
try {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|