crewx-agent-cli 0.2.3 → 0.2.5

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 (53) hide show
  1. package/README.md +79 -4
  2. package/dist/adapters.d.ts +12 -0
  3. package/dist/adapters.d.ts.map +1 -1
  4. package/dist/adapters.js +112 -40
  5. package/dist/adapters.js.map +1 -1
  6. package/dist/api.d.ts +40 -17
  7. package/dist/api.d.ts.map +1 -1
  8. package/dist/api.js +187 -11
  9. package/dist/api.js.map +1 -1
  10. package/dist/bridge-watchdog.d.ts +10 -0
  11. package/dist/bridge-watchdog.d.ts.map +1 -0
  12. package/dist/bridge-watchdog.js +45 -0
  13. package/dist/bridge-watchdog.js.map +1 -0
  14. package/dist/config.d.ts +9 -1
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +53 -2
  17. package/dist/config.js.map +1 -1
  18. package/dist/constants.d.ts +6 -1
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +11 -1
  21. package/dist/constants.js.map +1 -1
  22. package/dist/daemon.d.ts +34 -1
  23. package/dist/daemon.d.ts.map +1 -1
  24. package/dist/daemon.js +507 -102
  25. package/dist/daemon.js.map +1 -1
  26. package/dist/errors.d.ts +4 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +3 -1
  29. package/dist/errors.js.map +1 -1
  30. package/dist/index.d.ts +3 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +17 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/join.d.ts +1 -0
  35. package/dist/join.d.ts.map +1 -1
  36. package/dist/join.js +13 -0
  37. package/dist/join.js.map +1 -1
  38. package/dist/outbox.d.ts +22 -0
  39. package/dist/outbox.d.ts.map +1 -0
  40. package/dist/outbox.js +189 -0
  41. package/dist/outbox.js.map +1 -0
  42. package/dist/process-lock.d.ts +31 -0
  43. package/dist/process-lock.d.ts.map +1 -0
  44. package/dist/process-lock.js +272 -0
  45. package/dist/process-lock.js.map +1 -0
  46. package/dist/prompt.d.ts.map +1 -1
  47. package/dist/prompt.js +7 -0
  48. package/dist/prompt.js.map +1 -1
  49. package/dist/runtime-proxy.d.ts +18 -0
  50. package/dist/runtime-proxy.d.ts.map +1 -0
  51. package/dist/runtime-proxy.js +221 -0
  52. package/dist/runtime-proxy.js.map +1 -0
  53. package/package.json +6 -2
package/dist/daemon.js CHANGED
@@ -3,13 +3,17 @@ import { realpathSync } from "node:fs";
3
3
  import { hostname, platform, arch } from "node:os";
4
4
  import { isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { runAdapter, verifyOpenClawRuntime, } from "./adapters.js";
6
- import { ApiError } from "./api.js";
7
- import { CLI_VERSION, MAX_EVENT_CONTENT_LENGTH } from "./constants.js";
8
- import { errorMessage, redactSecrets } from "./errors.js";
6
+ import { ApiError, serializeOutboundEventBatch, } from "./api.js";
7
+ import { CLI_VERSION, MAX_BUFFERED_EVENT_BYTES, MAX_BUFFERED_EVENTS, MAX_EVENT_BATCH_BYTES, MAX_EVENT_CONTENT_LENGTH, } from "./constants.js";
8
+ import { CliError, errorMessage, redactSecrets } from "./errors.js";
9
9
  import { assemblePrompt, assignmentFromEvent } from "./prompt.js";
10
+ import { startRuntimeCredentialProxy, } from "./runtime-proxy.js";
11
+ import { DurableEventOutbox, agentEventOutboxPath, eventOutboxLockPath, } from "./outbox.js";
12
+ import { AgentProcessLock, } from "./process-lock.js";
10
13
  const ASSIGNMENT_CONTROL_INTERVAL_MS = 5_000;
11
14
  const EVENT_RETRY_BASE_DELAY_MS = 2_000;
12
15
  const EVENT_RETRY_MAX_DELAY_MS = 30_000;
16
+ const MAX_IDLE_POLL_DELAY_MS = 8_000;
13
17
  const MAX_RUNTIME_DIAGNOSTICS = 20;
14
18
  function timestamp() {
15
19
  return new Date().toISOString();
@@ -22,6 +26,19 @@ function event(type, payload) {
22
26
  occurred_at: timestamp(),
23
27
  };
24
28
  }
29
+ function assignmentEvent(assignment, type, payload) {
30
+ return {
31
+ id: crypto.randomUUID(),
32
+ type,
33
+ payload: {
34
+ ...payload,
35
+ ...(assignment.claim_token
36
+ ? { claim_token: assignment.claim_token }
37
+ : {}),
38
+ },
39
+ occurred_at: timestamp(),
40
+ };
41
+ }
25
42
  function truncate(value) {
26
43
  return value.length > MAX_EVENT_CONTENT_LENGTH
27
44
  ? `${value.slice(0, MAX_EVENT_CONTENT_LENGTH)}… [truncated]`
@@ -43,6 +60,50 @@ function record(value) {
43
60
  function nonEmptyString(value) {
44
61
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
45
62
  }
63
+ function runtimeFailure(adapter, detail) {
64
+ if (adapter !== "openclaw") {
65
+ return { code: "runtime_exit_nonzero", message: detail };
66
+ }
67
+ if (/\b429\b|rate[ -]?limit|quota|too many requests|cool(?:ing)?[ -]?down/i.test(detail)) {
68
+ return {
69
+ code: "openclaw_provider_rate_limited",
70
+ message: `${detail} Check \`openclaw models status --agent <agent-id> --json\` for provider cooldowns, then retry after the provider limit resets.`,
71
+ };
72
+ }
73
+ if (/unauthori[sz]ed|\bauth\b|authentication|credential|oauth|invalid token|expired/i.test(detail)) {
74
+ return {
75
+ code: "openclaw_auth_failed",
76
+ message: `${detail} Run \`openclaw models auth --agent <agent-id> list --json\` and repair the selected provider credentials.`,
77
+ };
78
+ }
79
+ if (/model.+(?:not found|unknown|unavailable|not allowed)/i.test(detail)) {
80
+ return {
81
+ code: "openclaw_model_unavailable",
82
+ message: `${detail} Run \`openclaw models status --agent <agent-id> --json\` and select an available model.`,
83
+ };
84
+ }
85
+ return { code: "openclaw_runtime_failed", message: detail };
86
+ }
87
+ const OPENCLAW_DIAGNOSTIC_NOISE = /^\[openclaw\]\s+(?:Debug|Try|Help):/i;
88
+ export function runtimeFailureDetail(adapter, stderr, exitCode) {
89
+ const lines = stderr.map((line) => line.trim()).filter(Boolean);
90
+ if (adapter === "openclaw") {
91
+ const reason = [...lines]
92
+ .reverse()
93
+ .find((line) => /^\[openclaw\]\s+Reason:/i.test(line));
94
+ if (reason) {
95
+ return reason.replace(/^\[openclaw\]\s+Reason:\s*/i, "").trim();
96
+ }
97
+ const actionable = [...lines]
98
+ .reverse()
99
+ .find((line) => !OPENCLAW_DIAGNOSTIC_NOISE.test(line));
100
+ if (actionable) {
101
+ return actionable.replace(/^\[openclaw\]\s*/i, "").trim();
102
+ }
103
+ }
104
+ return (lines.at(-1) ??
105
+ `${adapter} exited with code ${String(exitCode)}`);
106
+ }
46
107
  function identifier(value) {
47
108
  if (typeof value === "number" && Number.isSafeInteger(value))
48
109
  return String(value);
@@ -130,7 +191,7 @@ export function assignmentWorkingDirectory(assignment, context, fallback, locked
130
191
  if (relativePath === ".." ||
131
192
  relativePath.startsWith(`..${sep}`) ||
132
193
  isAbsolute(relativePath)) {
133
- throw new Error(`CrewX Bridge rejected assignment directory "${requested}" because it is outside the approved folder.`);
194
+ throw new Error("CrewX Bridge rejected the assignment directory because it is outside the approved folder.");
134
195
  }
135
196
  return canonicalCandidate;
136
197
  }
@@ -138,18 +199,65 @@ export class EventBuffer {
138
199
  api;
139
200
  sessionId;
140
201
  queue = [];
202
+ inFlight = new Set();
141
203
  flushing;
142
204
  retryNotBefore = 0;
143
205
  consecutiveFailures = 0;
144
- constructor(api, sessionId) {
206
+ maxEvents;
207
+ maxBytes;
208
+ random;
209
+ durableSnapshot;
210
+ constructor(api, sessionId, options = {}) {
145
211
  this.api = api;
146
212
  this.sessionId = sessionId;
213
+ this.maxEvents = options.maxEvents ?? MAX_BUFFERED_EVENTS;
214
+ this.maxBytes = options.maxBytes ?? MAX_BUFFERED_EVENT_BYTES;
215
+ this.random = options.random ?? Math.random;
216
+ this.outbox = options.outbox;
217
+ if (this.outbox) {
218
+ this.queue.push(...this.outbox.load());
219
+ this.enforceBounds();
220
+ }
221
+ this.durableSnapshot = this.serializeDurableEvents();
147
222
  }
223
+ outbox;
148
224
  push(item) {
149
- this.queue.push(item);
225
+ this.pushMany([item]);
226
+ }
227
+ pushMany(items) {
228
+ if (items.length === 0)
229
+ return;
230
+ const previous = [...this.queue];
231
+ try {
232
+ for (const item of items) {
233
+ if (!this.coalesce(item))
234
+ this.queue.push(item);
235
+ }
236
+ this.enforceBounds();
237
+ this.persistDurableEvents();
238
+ }
239
+ catch (error) {
240
+ this.queue.splice(0, this.queue.length, ...previous);
241
+ throw error;
242
+ }
150
243
  if (this.queue.length >= 25)
151
244
  void this.flush().catch(() => undefined);
152
245
  }
246
+ pendingCount() {
247
+ return this.queue.length;
248
+ }
249
+ pendingBytes() {
250
+ return this.queue.reduce((total, item) => total + this.eventBytes(item), 0);
251
+ }
252
+ hasPendingTerminalOutcome(assignmentId) {
253
+ const isTerminalOutcome = (item) => item.type === "lifecycle" &&
254
+ item.payload.assignment_id === assignmentId &&
255
+ (item.payload.stage === "completed" ||
256
+ item.payload.stage === "failed" ||
257
+ item.payload.stage === "cancelled");
258
+ return (this.queue.some(isTerminalOutcome) ||
259
+ [...this.inFlight].some(isTerminalOutcome));
260
+ }
153
261
  async flush(signal) {
154
262
  if (this.flushing) {
155
263
  await this.flushing;
@@ -171,21 +279,143 @@ export class EventBuffer {
171
279
  await sleep(retryDelay, signal);
172
280
  if (signal?.aborted)
173
281
  return;
174
- const batch = this.queue.slice(0, 100);
282
+ const batch = [];
283
+ for (const item of this.queue) {
284
+ if (batch.length >= 100) {
285
+ break;
286
+ }
287
+ const candidate = [...batch, item];
288
+ const candidateBytes = Buffer.byteLength(serializeOutboundEventBatch(this.sessionId, candidate), "utf8");
289
+ if (candidateBytes > MAX_EVENT_BATCH_BYTES) {
290
+ if (batch.length === 0) {
291
+ throw new CliError(`CrewX cannot send event ${item.id ?? "(without an ID)"} because its exact serialized batch exceeds ${String(MAX_EVENT_BATCH_BYTES)} bytes.`, 1, { code: "event_batch_too_large" });
292
+ }
293
+ break;
294
+ }
295
+ batch.push(item);
296
+ }
297
+ for (const item of batch)
298
+ this.inFlight.add(item);
175
299
  try {
176
300
  await this.api.sendEvents(this.sessionId, batch, signal);
177
- this.queue.splice(0, batch.length);
301
+ const delivered = this.queue.splice(0, batch.length);
302
+ try {
303
+ this.persistDurableEvents();
304
+ }
305
+ catch (error) {
306
+ this.queue.unshift(...delivered);
307
+ throw error;
308
+ }
178
309
  this.consecutiveFailures = 0;
179
310
  this.retryNotBefore = 0;
180
311
  }
181
312
  catch (error) {
182
313
  this.consecutiveFailures += 1;
183
314
  const exponentialDelay = Math.min(EVENT_RETRY_MAX_DELAY_MS, EVENT_RETRY_BASE_DELAY_MS * 2 ** (this.consecutiveFailures - 1));
315
+ const jitterDelay = Math.floor(this.random() * (exponentialDelay + 1));
184
316
  const serverDelay = error instanceof ApiError ? error.retryAfterMs : undefined;
185
317
  this.retryNotBefore =
186
- Date.now() + Math.max(exponentialDelay, serverDelay ?? 0);
318
+ Date.now() + Math.max(jitterDelay, serverDelay ?? 0);
187
319
  throw error;
188
320
  }
321
+ finally {
322
+ for (const item of batch)
323
+ this.inFlight.delete(item);
324
+ }
325
+ }
326
+ }
327
+ coalesce(item) {
328
+ if (item.id) {
329
+ const duplicateIndex = this.findNewestIndex((candidate) => candidate.id === item.id);
330
+ if (duplicateIndex >= 0) {
331
+ this.queue[duplicateIndex] = item;
332
+ return true;
333
+ }
334
+ }
335
+ if (item.type === "heartbeat") {
336
+ const existingIndex = this.findNewestIndex((candidate) => candidate.type === "heartbeat");
337
+ if (existingIndex >= 0) {
338
+ this.queue[existingIndex] = item;
339
+ return true;
340
+ }
341
+ }
342
+ if (item.type === "output") {
343
+ const existingIndex = this.findNewestIndex((candidate) => candidate.type === "output" &&
344
+ candidate.payload.assignment_id === item.payload.assignment_id &&
345
+ candidate.payload.stream === item.payload.stream);
346
+ const existing = this.queue[existingIndex];
347
+ if (existingIndex >= 0 && existing?.type === "output") {
348
+ const combined = `${existing.payload.content}\n${item.payload.content}`;
349
+ const content = combined.length <= MAX_EVENT_CONTENT_LENGTH
350
+ ? combined
351
+ : `[earlier output omitted during backpressure]\n${combined.slice(-(MAX_EVENT_CONTENT_LENGTH - 47))}`;
352
+ this.queue[existingIndex] = {
353
+ ...item,
354
+ payload: {
355
+ ...item.payload,
356
+ content,
357
+ },
358
+ };
359
+ return true;
360
+ }
361
+ }
362
+ return false;
363
+ }
364
+ findNewestIndex(predicate) {
365
+ for (let index = this.queue.length - 1; index >= 0; index -= 1) {
366
+ const candidate = this.queue[index];
367
+ if (candidate &&
368
+ !this.inFlight.has(candidate) &&
369
+ predicate(candidate)) {
370
+ return index;
371
+ }
372
+ }
373
+ return -1;
374
+ }
375
+ eventPriority(item) {
376
+ if (item.type === "heartbeat" || item.type === "output")
377
+ return 0;
378
+ if (item.type === "error" ||
379
+ (item.type === "message" && item.payload.role === "assistant") ||
380
+ (item.type === "lifecycle" &&
381
+ ["completed", "failed", "cancelled", "stopped"].includes(item.payload.stage))) {
382
+ return 2;
383
+ }
384
+ return 1;
385
+ }
386
+ eventBytes(item) {
387
+ return Buffer.byteLength(JSON.stringify(item));
388
+ }
389
+ persistDurableEvents() {
390
+ if (!this.outbox)
391
+ return;
392
+ const durable = this.queue.filter((item) => this.eventPriority(item) === 2);
393
+ const snapshot = JSON.stringify(durable);
394
+ if (snapshot === this.durableSnapshot)
395
+ return;
396
+ this.outbox.replace(durable);
397
+ this.durableSnapshot = snapshot;
398
+ }
399
+ serializeDurableEvents() {
400
+ return JSON.stringify(this.queue.filter((item) => this.eventPriority(item) === 2));
401
+ }
402
+ enforceBounds() {
403
+ let bytes = this.pendingBytes();
404
+ while (this.queue.length > this.maxEvents ||
405
+ bytes > this.maxBytes) {
406
+ let removeIndex = -1;
407
+ for (const priority of [0, 1]) {
408
+ removeIndex = this.queue.findIndex((item) => !this.inFlight.has(item) &&
409
+ this.eventPriority(item) === priority);
410
+ if (removeIndex >= 0)
411
+ break;
412
+ }
413
+ if (removeIndex < 0) {
414
+ throw new CliError("CrewX cannot buffer another final agent outcome while event delivery is unavailable. Existing final outcomes were retained; restore connectivity before accepting more work.", 1, { code: "event_buffer_capacity_exceeded" });
415
+ }
416
+ const [removed] = this.queue.splice(removeIndex, 1);
417
+ if (removed)
418
+ bytes -= this.eventBytes(removed);
189
419
  }
190
420
  }
191
421
  }
@@ -202,6 +432,18 @@ function sleep(milliseconds, signal) {
202
432
  signal?.addEventListener("abort", finish, { once: true });
203
433
  });
204
434
  }
435
+ export function adaptiveIdlePollDelay(baseDelayMs, consecutiveEmptyPolls, options = {}) {
436
+ const base = Math.max(250, Math.floor(baseDelayMs));
437
+ const maximum = Math.max(base, MAX_IDLE_POLL_DELAY_MS);
438
+ const exponent = Math.max(0, Math.min(8, consecutiveEmptyPolls - 1));
439
+ const cap = Math.min(maximum, base * 2 ** exponent);
440
+ const random = options.random ?? Math.random;
441
+ const jittered = cap <= base
442
+ ? base
443
+ : base +
444
+ Math.round(Math.max(0, Math.min(1, random())) * (cap - base));
445
+ return Math.max(jittered, options.retryAfterMs ?? 0);
446
+ }
205
447
  function linkedAbortController(parent) {
206
448
  const controller = new AbortController();
207
449
  const abort = () => controller.abort(parent?.reason);
@@ -222,17 +464,24 @@ async function monitorAssignmentControl(options) {
222
464
  while (!options.isFinished() && !options.controller.signal.aborted) {
223
465
  let nextCheckDelay = ASSIGNMENT_CONTROL_INTERVAL_MS;
224
466
  try {
225
- const control = await options.api.control(options.sessionId, options.assignmentId, options.controller.signal);
467
+ const control = await options.api.control(options.sessionId, options.assignmentId, options.controller.signal, options.assignmentEventCursor);
226
468
  if (control.cancelled) {
227
469
  options.log(`CrewX cancelled ${options.assignmentId}; stopping the local agent…`);
228
470
  options.controller.abort(new Error("Assignment cancelled by CrewX"));
229
- return;
471
+ return undefined;
230
472
  }
231
473
  lastLoggedError = undefined;
232
474
  }
233
475
  catch (error) {
234
- if (options.controller.signal.aborted || options.isFinished())
235
- return;
476
+ if (options.controller.signal.aborted || options.isFinished()) {
477
+ return undefined;
478
+ }
479
+ if (error instanceof ApiError &&
480
+ (error.status === 401 || error.status === 403)) {
481
+ options.log("CrewX revoked this agent connection; stopping the local agent and daemon…");
482
+ options.controller.abort(error);
483
+ return error;
484
+ }
236
485
  const message = errorMessage(error);
237
486
  const now = Date.now();
238
487
  if (message !== lastLoggedError || now - lastErrorLoggedAt >= 30_000) {
@@ -246,38 +495,68 @@ async function monitorAssignmentControl(options) {
246
495
  }
247
496
  await sleep(nextCheckDelay, options.controller.signal);
248
497
  }
498
+ return undefined;
249
499
  }
250
500
  export async function executeAssignment(options) {
251
501
  const { assignment, events } = options;
252
- const secrets = options.environment?.CREWX_TOKEN
253
- ? [options.environment.CREWX_TOKEN]
254
- : [];
502
+ const secrets = [
503
+ ...(options.environment?.CREWX_TOKEN
504
+ ? [options.environment.CREWX_TOKEN]
505
+ : []),
506
+ ...(assignment.claim_token ? [assignment.claim_token] : []),
507
+ ];
255
508
  const nativeJsonRuntime = !options.codingCommand &&
256
509
  (options.adapter === "codex" ||
257
510
  options.adapter === "claude" ||
258
511
  options.adapter === "pi");
259
512
  const runtimeDiagnostics = new Set();
260
- events.push(event("lifecycle", {
513
+ const pendingRuntimeMessages = [];
514
+ let terminalizationStarted = false;
515
+ events.push(assignmentEvent(assignment, "lifecycle", {
261
516
  stage: "claimed",
262
517
  assignment_id: assignment.id,
263
518
  adapter: options.adapter,
264
519
  }));
265
- events.push(event("message", {
520
+ events.push(assignmentEvent(assignment, "message", {
266
521
  assignment_id: assignment.id,
267
522
  role: "user",
268
523
  content: assignment.prompt,
269
524
  }));
270
- events.push(event("lifecycle", {
525
+ events.push(assignmentEvent(assignment, "lifecycle", {
271
526
  stage: "started",
272
527
  assignment_id: assignment.id,
273
528
  adapter: options.adapter,
274
529
  }));
275
530
  await events.flush(options.signal);
531
+ let runtimeProxy;
276
532
  try {
277
533
  const agent = record(options.context.agent);
278
534
  const permissionPreset = agent?.permission_preset;
279
535
  const configuredModel = nonEmptyString(agent?.model);
280
536
  const runtimeAgentId = nonEmptyString(agent?.runtime_agent_id);
537
+ const upstreamToken = options.environment?.CREWX_TOKEN?.trim();
538
+ const cliPath = options.environment?.CREWX_CLI_PATH?.trim();
539
+ if (upstreamToken && cliPath) {
540
+ runtimeProxy = await startRuntimeCredentialProxy({
541
+ upstreamUrl: options.serverUrl,
542
+ upstreamToken,
543
+ cliPath,
544
+ assignmentId: assignment.id,
545
+ ...(assignment.claim_token
546
+ ? { claimToken: assignment.claim_token }
547
+ : {}),
548
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
549
+ permissionPreset: permissionPreset === "read_only" ||
550
+ permissionPreset === "standard" ||
551
+ permissionPreset === "full_access"
552
+ ? permissionPreset
553
+ : "read_only",
554
+ chatOnly: agent?.chat_only === true,
555
+ });
556
+ const proxyToken = runtimeProxy.environment.CREWX_TOKEN;
557
+ if (proxyToken)
558
+ secrets.push(proxyToken);
559
+ }
281
560
  const result = await (options.runAgent ?? runAdapter)({
282
561
  adapter: options.adapter,
283
562
  prompt: assemblePrompt(assignment, options.context),
@@ -297,6 +576,9 @@ export async function executeAssignment(options) {
297
576
  },
298
577
  ...(options.codingCommand ? { codingCommand: options.codingCommand } : {}),
299
578
  ...(options.environment ? { environment: options.environment } : {}),
579
+ ...(runtimeProxy
580
+ ? { controlEnvironment: runtimeProxy.environment }
581
+ : {}),
300
582
  ...(options.signal ? { signal: options.signal } : {}),
301
583
  onStdout(line, parsed) {
302
584
  if (nativeJsonRuntime) {
@@ -309,7 +591,7 @@ export async function executeAssignment(options) {
309
591
  return;
310
592
  }
311
593
  runtimeDiagnostics.add(content);
312
- events.push(event("output", {
594
+ events.push(assignmentEvent(assignment, "output", {
313
595
  assignment_id: assignment.id,
314
596
  stream: "stdout",
315
597
  content,
@@ -318,7 +600,7 @@ export async function executeAssignment(options) {
318
600
  }
319
601
  const content = truncate(redactSecrets(line, secrets));
320
602
  const raw = sanitizedRaw(parsed.raw, secrets);
321
- events.push(event("output", {
603
+ events.push(assignmentEvent(assignment, "output", {
322
604
  assignment_id: assignment.id,
323
605
  stream: "stdout",
324
606
  content,
@@ -330,14 +612,20 @@ export async function executeAssignment(options) {
330
612
  }));
331
613
  },
332
614
  onMessage(message, role) {
333
- events.push(event("message", {
615
+ const runtimeMessage = assignmentEvent(assignment, "message", {
334
616
  assignment_id: assignment.id,
335
617
  role,
336
618
  content: truncate(redactSecrets(message, secrets)),
337
- }));
619
+ });
620
+ if (role === "assistant" || role === "tool") {
621
+ pendingRuntimeMessages.push(runtimeMessage);
622
+ }
623
+ else {
624
+ events.push(runtimeMessage);
625
+ }
338
626
  },
339
627
  onStderr(line) {
340
- events.push(event("output", {
628
+ events.push(assignmentEvent(assignment, "output", {
341
629
  assignment_id: assignment.id,
342
630
  stream: "stderr",
343
631
  content: truncate(redactSecrets(line, secrets)),
@@ -345,55 +633,111 @@ export async function executeAssignment(options) {
345
633
  },
346
634
  });
347
635
  if (options.signal?.aborted) {
348
- events.push(event("lifecycle", {
349
- stage: "cancelled",
350
- assignment_id: assignment.id,
351
- adapter: options.adapter,
352
- }));
636
+ terminalizationStarted = true;
637
+ events.pushMany([
638
+ ...pendingRuntimeMessages,
639
+ assignmentEvent(assignment, "lifecycle", {
640
+ stage: "cancelled",
641
+ assignment_id: assignment.id,
642
+ adapter: options.adapter,
643
+ }),
644
+ ]);
353
645
  }
354
646
  else if (result.exitCode === 0) {
355
- events.push(event("lifecycle", {
356
- stage: "completed",
357
- assignment_id: assignment.id,
358
- adapter: options.adapter,
359
- exit_code: result.exitCode,
360
- }));
647
+ terminalizationStarted = true;
648
+ events.pushMany([
649
+ ...pendingRuntimeMessages,
650
+ assignmentEvent(assignment, "lifecycle", {
651
+ stage: "completed",
652
+ assignment_id: assignment.id,
653
+ adapter: options.adapter,
654
+ exit_code: result.exitCode,
655
+ }),
656
+ ]);
361
657
  }
362
658
  else {
363
- const detail = result.stderr.at(-1) ??
364
- `${options.adapter} exited with code ${String(result.exitCode)}`;
365
- const safeDetail = redactSecrets(detail, secrets);
366
- events.push(event("error", {
659
+ const detail = runtimeFailureDetail(options.adapter, result.stderr, result.exitCode);
660
+ const failure = runtimeFailure(options.adapter, detail);
661
+ const safeDetail = redactSecrets(failure.message, secrets);
662
+ if (options.adapter === "openclaw") {
663
+ options.log?.(`OpenClaw task ${assignment.id} failed: ${truncate(safeDetail)}`);
664
+ }
665
+ terminalizationStarted = true;
666
+ events.pushMany([
667
+ ...pendingRuntimeMessages,
668
+ assignmentEvent(assignment, "error", {
669
+ assignment_id: assignment.id,
670
+ message: truncate(safeDetail),
671
+ code: failure.code,
672
+ retryable: false,
673
+ }),
674
+ assignmentEvent(assignment, "lifecycle", {
675
+ stage: "failed",
676
+ assignment_id: assignment.id,
677
+ adapter: options.adapter,
678
+ exit_code: result.exitCode,
679
+ detail: truncate(safeDetail),
680
+ }),
681
+ ]);
682
+ }
683
+ }
684
+ catch (error) {
685
+ if (terminalizationStarted)
686
+ throw error;
687
+ const message = truncate(redactSecrets(errorMessage(error), secrets));
688
+ if (options.adapter === "openclaw") {
689
+ options.log?.(`OpenClaw task ${assignment.id} failed: ${message}`);
690
+ }
691
+ terminalizationStarted = true;
692
+ events.pushMany([
693
+ ...pendingRuntimeMessages,
694
+ assignmentEvent(assignment, "error", {
367
695
  assignment_id: assignment.id,
368
- message: truncate(safeDetail),
696
+ message,
697
+ ...(error instanceof CliError && error.code
698
+ ? { code: error.code }
699
+ : {}),
369
700
  retryable: false,
370
- }));
371
- events.push(event("lifecycle", {
372
- stage: "failed",
701
+ }),
702
+ assignmentEvent(assignment, "lifecycle", {
703
+ stage: options.signal?.aborted ? "cancelled" : "failed",
373
704
  assignment_id: assignment.id,
374
705
  adapter: options.adapter,
375
- exit_code: result.exitCode,
376
- detail: truncate(safeDetail),
377
- }));
378
- }
706
+ detail: message,
707
+ }),
708
+ ]);
379
709
  }
380
- catch (error) {
381
- const message = truncate(redactSecrets(errorMessage(error), secrets));
382
- events.push(event("error", {
383
- assignment_id: assignment.id,
384
- message,
385
- retryable: false,
386
- }));
387
- events.push(event("lifecycle", {
388
- stage: options.signal?.aborted ? "cancelled" : "failed",
389
- assignment_id: assignment.id,
390
- adapter: options.adapter,
391
- detail: message,
392
- }));
710
+ finally {
711
+ await runtimeProxy?.close();
393
712
  }
394
713
  await events.flush(options.signal?.aborted ? undefined : options.signal);
395
714
  }
396
715
  export async function runDaemon(options) {
716
+ const agentToken = options.environment?.CREWX_TOKEN?.trim();
717
+ const automaticOutboxPath = agentToken
718
+ ? agentEventOutboxPath(options.api.baseUrl, agentToken, options.adapter, options.environment)
719
+ : undefined;
720
+ const durablePath = options.outbox?.path ?? automaticOutboxPath;
721
+ const processLock = options.processLock ??
722
+ (durablePath
723
+ ? new AgentProcessLock(eventOutboxLockPath(durablePath))
724
+ : undefined);
725
+ // The lock precedes both session registration and outbox loading. This is
726
+ // intentionally wider than the network loop: two same-profile daemons must
727
+ // never hold independent cached snapshots of the same durable outcomes.
728
+ await processLock?.acquire();
729
+ try {
730
+ const durableOutbox = options.outbox ??
731
+ (automaticOutboxPath
732
+ ? new DurableEventOutbox(automaticOutboxPath)
733
+ : undefined);
734
+ await runOwnedDaemon(options, durableOutbox);
735
+ }
736
+ finally {
737
+ await processLock?.release();
738
+ }
739
+ }
740
+ async function runOwnedDaemon(options, durableOutbox) {
397
741
  const log = options.log ?? (() => undefined);
398
742
  const startedAt = Date.now();
399
743
  const session = await options.api.createSession({
@@ -409,14 +753,20 @@ export async function runDaemon(options) {
409
753
  cwd: options.cwd,
410
754
  },
411
755
  }, options.signal);
412
- const events = new EventBuffer(options.api, session.session_id);
413
756
  let cursor = session.cursor;
414
757
  let activeAssignmentId;
758
+ let activeAssignmentClaimToken;
415
759
  let stopping = false;
416
760
  const seenAssignmentEvents = new Set();
417
761
  let workspaceContext = await options.api.context(session.session_id, options.signal);
762
+ const agent = record(workspaceContext.agent);
763
+ const events = new EventBuffer(options.api, session.session_id, {
764
+ ...(durableOutbox ? { outbox: durableOutbox } : {}),
765
+ });
766
+ // Reuse original event UUIDs against the new session so the server can
767
+ // idempotently acknowledge outcomes persisted before a crash or restart.
768
+ await events.flush(options.signal);
418
769
  if (options.adapter === "openclaw") {
419
- const agent = record(workspaceContext.agent);
420
770
  const runtimeAgentId = nonEmptyString(agent?.runtime_agent_id);
421
771
  if (!runtimeAgentId) {
422
772
  throw new Error("CrewX did not provide an OpenClaw runtime agent ID for this profile.");
@@ -439,6 +789,9 @@ export async function runDaemon(options) {
439
789
  events.push(event("heartbeat", {
440
790
  status: activeAssignmentId ? "running" : stopping ? "stopping" : "idle",
441
791
  ...(activeAssignmentId ? { assignment_id: activeAssignmentId } : {}),
792
+ ...(activeAssignmentClaimToken
793
+ ? { claim_token: activeAssignmentClaimToken }
794
+ : {}),
442
795
  uptime_seconds: Math.floor((Date.now() - startedAt) / 1_000),
443
796
  }));
444
797
  void events
@@ -448,6 +801,8 @@ export async function runDaemon(options) {
448
801
  heartbeatTimer.unref();
449
802
  try {
450
803
  let polls = 0;
804
+ let consecutivePollFailures = 0;
805
+ let consecutiveEmptyPolls = 0;
451
806
  while (!options.signal?.aborted) {
452
807
  try {
453
808
  // Never acknowledge a claimed page while its lifecycle/output batch is
@@ -455,7 +810,7 @@ export async function runDaemon(options) {
455
810
  await events.flush(options.signal);
456
811
  const requestedCursor = cursor;
457
812
  const response = await options.api.poll(session.session_id, cursor, options.signal);
458
- cursor = response.cursor;
813
+ consecutivePollFailures = 0;
459
814
  polls += 1;
460
815
  for (const inbound of response.events) {
461
816
  const assignment = assignmentFromEvent(inbound);
@@ -469,49 +824,85 @@ export async function runDaemon(options) {
469
824
  continue;
470
825
  seenAssignmentEvents.add(executionKey);
471
826
  activeAssignmentId = assignment.id;
827
+ activeAssignmentClaimToken = assignment.claim_token;
472
828
  log(`Running ${assignment.title ?? assignment.id}…`);
473
829
  try {
474
- workspaceContext = await options.api.context(session.session_id, options.signal);
830
+ try {
831
+ workspaceContext = await options.api.context(session.session_id, options.signal);
832
+ }
833
+ catch (error) {
834
+ log(`Context refresh failed; using the previous snapshot: ${errorMessage(error)}`);
835
+ }
836
+ const linked = linkedAbortController(options.signal);
837
+ let assignmentFinished = false;
838
+ const assignmentEventCursor = typeof assignment.metadata?.assignment_event_cursor === "number"
839
+ ? assignment.metadata.assignment_event_cursor
840
+ : undefined;
841
+ const canMonitor = typeof options.api.control === "function" &&
842
+ /^\d+$/.test(assignment.id);
843
+ const monitor = canMonitor
844
+ ? monitorAssignmentControl({
845
+ api: options.api,
846
+ sessionId: session.session_id,
847
+ assignmentId: assignment.id,
848
+ assignmentEventCursor,
849
+ controller: linked.controller,
850
+ isFinished: () => assignmentFinished,
851
+ log,
852
+ })
853
+ : Promise.resolve(undefined);
854
+ let fatalControlError;
855
+ try {
856
+ await (options.executeAssignment ?? executeAssignment)({
857
+ assignment,
858
+ adapter: options.adapter,
859
+ cwd: assignmentWorkingDirectory(assignment, workspaceContext, options.cwd, options.lockedRoot ?? process.env.CREWX_BRIDGE_ROOT),
860
+ context: workspaceContext,
861
+ events,
862
+ serverUrl: options.api.baseUrl,
863
+ sessionId: session.session_id,
864
+ ...(options.codingCommand
865
+ ? { codingCommand: options.codingCommand }
866
+ : {}),
867
+ ...(options.environment
868
+ ? { environment: options.environment }
869
+ : {}),
870
+ log,
871
+ signal: linked.controller.signal,
872
+ });
873
+ }
874
+ finally {
875
+ assignmentFinished = true;
876
+ linked.controller.abort();
877
+ linked.detach();
878
+ fatalControlError = await monitor;
879
+ }
880
+ if (fatalControlError)
881
+ throw fatalControlError;
882
+ await events.flush(options.signal);
475
883
  }
476
884
  catch (error) {
477
- log(`Context refresh failed; using the previous snapshot: ${errorMessage(error)}`);
478
- }
479
- const linked = linkedAbortController(options.signal);
480
- let assignmentFinished = false;
481
- const canMonitor = typeof options.api.control === "function" &&
482
- /^\d+$/.test(assignment.id);
483
- const monitor = canMonitor
484
- ? monitorAssignmentControl({
485
- api: options.api,
486
- sessionId: session.session_id,
487
- assignmentId: assignment.id,
488
- controller: linked.controller,
489
- isFinished: () => assignmentFinished,
490
- log,
491
- })
492
- : Promise.resolve();
493
- try {
494
- await (options.executeAssignment ?? executeAssignment)({
495
- assignment,
496
- adapter: options.adapter,
497
- cwd: assignmentWorkingDirectory(assignment, workspaceContext, options.cwd, options.lockedRoot ?? process.env.CREWX_BRIDGE_ROOT),
498
- context: workspaceContext,
499
- events,
500
- serverUrl: options.api.baseUrl,
501
- ...(options.codingCommand ? { codingCommand: options.codingCommand } : {}),
502
- ...(options.environment ? { environment: options.environment } : {}),
503
- signal: linked.controller.signal,
504
- });
885
+ // A failed pre-run lifecycle flush means the runtime never
886
+ // started. Leave the page unacknowledged and allow that event to
887
+ // execute after delivery recovers. If a terminal outcome is
888
+ // queued, retain the seen fence: the next loop first delivers
889
+ // that durable outcome, then acknowledges the page without
890
+ // executing the runtime twice.
891
+ if (!events.hasPendingTerminalOutcome(assignment.id)) {
892
+ seenAssignmentEvents.delete(executionKey);
893
+ }
894
+ throw error;
505
895
  }
506
896
  finally {
507
- assignmentFinished = true;
508
- linked.controller.abort();
509
- linked.detach();
510
- await monitor;
897
+ activeAssignmentId = undefined;
898
+ activeAssignmentClaimToken = undefined;
511
899
  }
512
- await events.flush(options.signal);
513
- activeAssignmentId = undefined;
514
900
  }
901
+ // Advancing the local cursor is the acknowledgement boundary. Do it
902
+ // only after every assignment on the page has a delivered terminal
903
+ // outcome (or was previously terminalized and fenced).
904
+ cursor = response.cursor;
905
+ seenAssignmentEvents.clear();
515
906
  if (options.once && polls >= 1) {
516
907
  if (String(cursor) !== String(requestedCursor)) {
517
908
  // A final poll acknowledges the page that was just processed. Any
@@ -520,10 +911,21 @@ export async function runDaemon(options) {
520
911
  }
521
912
  break;
522
913
  }
523
- if (response.has_more)
914
+ if (response.has_more) {
915
+ consecutiveEmptyPolls = 0;
524
916
  continue;
917
+ }
918
+ consecutiveEmptyPolls =
919
+ response.events.length === 0 ? consecutiveEmptyPolls + 1 : 0;
525
920
  const serverDelay = response.retry_after_ms;
526
- await sleep(serverDelay ?? options.pollIntervalMs, options.signal);
921
+ const pollDelay = consecutiveEmptyPolls > 0
922
+ ? adaptiveIdlePollDelay(options.pollIntervalMs, consecutiveEmptyPolls, {
923
+ ...(serverDelay !== undefined
924
+ ? { retryAfterMs: serverDelay }
925
+ : {}),
926
+ })
927
+ : Math.max(options.pollIntervalMs, serverDelay ?? 0);
928
+ await sleep(pollDelay, options.signal);
527
929
  }
528
930
  catch (error) {
529
931
  if (options.signal?.aborted)
@@ -532,9 +934,12 @@ export async function runDaemon(options) {
532
934
  throw error;
533
935
  const message = redactSecrets(errorMessage(error), [options.api.token]);
534
936
  log(`Polling failed: ${message}. Retrying…`);
535
- events.push(event("error", { message, retryable: true }));
937
+ consecutivePollFailures += 1;
536
938
  const serverDelay = error instanceof ApiError ? error.retryAfterMs : undefined;
537
- await sleep(Math.max(options.pollIntervalMs, 2_000, serverDelay ?? 0), options.signal);
939
+ const retryCap = Math.min(EVENT_RETRY_MAX_DELAY_MS, Math.max(options.pollIntervalMs, EVENT_RETRY_BASE_DELAY_MS) *
940
+ 2 ** Math.min(8, consecutivePollFailures - 1));
941
+ const jitterDelay = Math.floor(Math.random() * (retryCap + 1));
942
+ await sleep(Math.max(250, jitterDelay, serverDelay ?? 0), options.signal);
538
943
  }
539
944
  }
540
945
  }