@parall/claude-agent 1.62.0 → 1.64.0

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.
@@ -1,4 +1,4 @@
1
- import type { CleanupForkOpts, CompactOpts, CompactResult, DispatchAdapter, DispatchInputLifecycle, DispatchOpts, ForkOpts, GatewayLogger, RuntimeActivityEvent, RuntimeBusyState, RuntimeEvent } from '@parall/agent-core';
1
+ import type { CleanupForkOpts, CompactOpts, CompactResult, DiscardedInjectionOutcome, DispatchAdapter, DispatchInputLifecycle, DispatchOpts, ForkOpts, GatewayLogger, RuntimeActivityEvent, RuntimeBusyState, RuntimeEvent } from '@parall/agent-core';
2
2
  import type { ClaudeAgentConfig } from './config.js';
3
3
  import { ClaudeInputRegistry } from './input-lifecycle.js';
4
4
  import { ClaudeProcessPump } from './process-pump.js';
@@ -18,12 +18,8 @@ type ClaudeCodeAdapterOptions = Pick<ClaudeAgentConfig, 'additionalDirs' | 'allo
18
18
  /** @deprecated Use contextFilePathForSession. */
19
19
  stepIdFilePathForSession?: (sessionKey: string) => string;
20
20
  /**
21
- * PRLL_CONTEXT_DIR root for per-lane dispatch context files. Like the
22
- * Parall credentials above, this is bridge-static the long-lived
23
- * subprocess env is built ONCE from the placeholder context, so a value
24
- * only present on per-dispatch DispatchContexts would never reach the
25
- * CLI and every reply would silently lose its lane binding (turns would
26
- * resolve no_action instead of reply-effect dedupe).
21
+ * Bridge-static PRLL_CONTEXT_DIR root: a value only in per-dispatch context
22
+ * never reaches the long-lived CLI, so replies lose their lane binding.
27
23
  */
28
24
  contextDirPath?: string;
29
25
  /**
@@ -64,6 +60,7 @@ export type ProcessState = {
64
60
  export declare class ClaudeCodeAdapter implements DispatchAdapter {
65
61
  private readonly opts;
66
62
  readonly inputLifecycleMode: "explicit";
63
+ readonly inputSettlementMode: "receipt";
67
64
  private readonly processes;
68
65
  /** Retired by abortDispatch while the CLI still had its own work: busy until idle, then terminated. */
69
66
  private readonly retiring;
@@ -92,7 +89,19 @@ export declare class ClaudeCodeAdapter implements DispatchAdapter {
92
89
  abortDispatch(sessionKey: string): void;
93
90
  hasPendingInjections(sessionKey: string): boolean;
94
91
  hasUnsettledInjections(sessionKey: string): boolean;
95
- acknowledgeDiscardedInjection(sessionKey: string, deliveryKey: string): void;
92
+ /**
93
+ * The gateway discarded the buffered copy of an injection without a
94
+ * dispatch(). A settled injection only owes bookkeeping. One that has NOT
95
+ * settled is an orphan: nothing will ever drain it, and the CLI does not
96
+ * start a queued input on its own once the turn it was steered into has
97
+ * ended — it works its queue only when the next stdin frame arrives, which
98
+ * would be the batch re-delivering this very member (prod 2026-09-03: lane
99
+ * dethroned after 10 idle minutes, then "refusing duplicate stdin write"
100
+ * on every retry). Withdraw it: kill the process (the session survives via
101
+ * --resume), report the input failed so the server releases the member,
102
+ * and let the gateway run it as a real turn.
103
+ */
104
+ acknowledgeDiscardedInjection(sessionKey: string, deliveryKey: string): Promise<DiscardedInjectionOutcome>;
96
105
  dispatch({ event, bodyForAgent, sessionKey, context, inputLifecycle, noteActivity, }: DispatchOpts): AsyncIterable<RuntimeEvent>;
97
106
  getBranchPoint(_sessionKey: string): string | undefined;
98
107
  getSessionHistoryPath(sessionKey: string): string | undefined;
@@ -129,6 +138,7 @@ export declare class ClaudeCodeAdapter implements DispatchAdapter {
129
138
  private maybeApplyRestart;
130
139
  private ensureProcess;
131
140
  private spawnProcess;
141
+ /** Resolves once every live input's failed report has settled (best effort). */
132
142
  private killProcess;
133
143
  private endStdin;
134
144
  private terminateHandle;
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
package/dist/dispatch.js CHANGED
@@ -33,6 +33,7 @@ function killWin32Tree(pid) {
33
33
  export class ClaudeCodeAdapter {
34
34
  opts;
35
35
  inputLifecycleMode = 'explicit';
36
+ inputSettlementMode = 'receipt';
36
37
  processes = new Map();
37
38
  /** Retired by abortDispatch while the CLI still had its own work: busy until idle, then terminated. */
38
39
  retiring = new Set();
@@ -169,16 +170,44 @@ export class ClaudeCodeAdapter {
169
170
  return false;
170
171
  return state.inputs.hasUnsettledInjections();
171
172
  }
172
- acknowledgeDiscardedInjection(sessionKey, deliveryKey) {
173
+ /**
174
+ * The gateway discarded the buffered copy of an injection without a
175
+ * dispatch(). A settled injection only owes bookkeeping. One that has NOT
176
+ * settled is an orphan: nothing will ever drain it, and the CLI does not
177
+ * start a queued input on its own once the turn it was steered into has
178
+ * ended — it works its queue only when the next stdin frame arrives, which
179
+ * would be the batch re-delivering this very member (prod 2026-09-03: lane
180
+ * dethroned after 10 idle minutes, then "refusing duplicate stdin write"
181
+ * on every retry). Withdraw it: kill the process (the session survives via
182
+ * --resume), report the input failed so the server releases the member,
183
+ * and let the gateway run it as a real turn.
184
+ */
185
+ async acknowledgeDiscardedInjection(sessionKey, deliveryKey) {
173
186
  const state = this.processes.get(sessionKey);
174
187
  if (!state)
175
- return;
188
+ return 'discarded';
189
+ const delivery = state.inputs.getByKey(deliveryKey);
190
+ if (delivery && !delivery.terminal) {
191
+ state.log?.warn?.(`withdrawing orphaned Claude input ${delivery.commandUuid} (${delivery.reportedState ?? 'queued'}) on ${sessionKey}: its buffered copy was discarded before the CLI settled it${state.pump.hasOwnWork() ? '; the runtime-initiated turn dies with the process' : ''}`);
192
+ await this.killProcess(sessionKey, state);
193
+ state.inputs.discardBookkeeping(deliveryKey);
194
+ // 'cancelled' only when the server confirmed the failed receipt: the
195
+ // member is released and un-folded, so the gateway can re-fold and run
196
+ // it locally. A lost receipt (reportedState never reached 'failed') or
197
+ // a 'settled' answer (a reply effect already resolved the member)
198
+ // leaves nothing to re-run here — the lane complete releases an unread
199
+ // member for re-drive instead.
200
+ const confirmed = delivery.reportedState === 'failed' && delivery.terminal === 'failed';
201
+ if (!confirmed) {
202
+ state.log?.warn?.(`withdrawal of Claude input ${delivery.commandUuid} was not confirmed by the server (${delivery.terminal}); leaving the member to the lane complete`);
203
+ }
204
+ return confirmed ? 'cancelled' : 'discarded';
205
+ }
176
206
  state.inputs.discardBookkeeping(deliveryKey);
177
207
  // The discarded copy may have been the last thing a lazy restart was
178
- // waiting on. Unlike the consume path's tail, the discarded delivery may
179
- // still be RUNNING (non-terminal): killing the process now would fail a
180
- // live input, so the restart also waits for every injection to settle.
208
+ // waiting on.
181
209
  this.maybeApplyRestart(sessionKey, state);
210
+ return 'discarded';
182
211
  }
183
212
  async *dispatch({ event, bodyForAgent, sessionKey, context, inputLifecycle, noteActivity, }) {
184
213
  const deliveryKey = inputLifecycle?.deliveryKey ?? event.dispatchEventId ?? event.messageId;
@@ -246,13 +275,13 @@ export class ClaudeCodeAdapter {
246
275
  cleanupFork({ fork }) {
247
276
  const state = this.processes.get(fork.sessionKey);
248
277
  if (state) {
249
- this.killProcess(fork.sessionKey, state);
278
+ void this.killProcess(fork.sessionKey, state);
250
279
  }
251
280
  this.opts.sessionManager.cleanupFork(fork.sessionKey);
252
281
  }
253
282
  resetProcesses() {
254
283
  for (const [sessionKey, state] of this.processes) {
255
- this.killProcess(sessionKey, state);
284
+ void this.killProcess(sessionKey, state);
256
285
  }
257
286
  this.processes.clear();
258
287
  const retired = [...this.retiring];
@@ -282,7 +311,10 @@ export class ClaudeCodeAdapter {
282
311
  }
283
312
  catch (err) {
284
313
  try {
285
- await lifecycle?.update('failed');
314
+ await lifecycle?.update('failed', {
315
+ outcomeClass: 'runtime_crash',
316
+ detail: `Claude spawn failed: ${String(err)}`,
317
+ });
286
318
  }
287
319
  catch (reportErr) {
288
320
  log?.warn?.(`failed to report rejected Claude input as failed: ${String(reportErr)}`);
@@ -297,10 +329,10 @@ export class ClaudeCodeAdapter {
297
329
  this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
298
330
  }
299
331
  catch (err) {
300
- await state.inputs.failBestEffort(delivery, log);
332
+ await state.inputs.failBestEffort(delivery, log, `Claude stdin write failed: ${String(err)}`);
301
333
  state.inputs.remove(delivery);
302
334
  yield { type: 'error', message: `Claude stdin write failed: ${String(err)}` };
303
- this.killProcess(sessionKey, state);
335
+ void this.killProcess(sessionKey, state);
304
336
  return;
305
337
  }
306
338
  try {
@@ -315,7 +347,9 @@ export class ClaudeCodeAdapter {
315
347
  return runClaudeCompact({
316
348
  ensureRuntimeCapability: (log) => this.ensureRuntimeCapability(log),
317
349
  ensureProcess: (sessionKey, log) => this.ensureProcess(sessionKey, log),
318
- killProcess: (sessionKey, state) => this.killProcess(sessionKey, state),
350
+ killProcess: (sessionKey, state) => {
351
+ void this.killProcess(sessionKey, state);
352
+ },
319
353
  writeUserMessage: (handle, text, uuid) => this.writeUserMessage(handle, text, uuid),
320
354
  applyPendingRestart: (sessionKey, state) => this.maybeApplyRestart(sessionKey, state),
321
355
  }, opts);
@@ -448,7 +482,7 @@ export class ClaudeCodeAdapter {
448
482
  if (outstanding.total > 0) {
449
483
  state.log?.warn?.(`restarting ${sessionKey} with ${outstanding.total} live background task(s); they die with the process`);
450
484
  }
451
- this.killProcess(sessionKey, state);
485
+ void this.killProcess(sessionKey, state);
452
486
  }
453
487
  ensureProcess(sessionKey, log) {
454
488
  if (this.shuttingDown) {
@@ -463,7 +497,7 @@ export class ClaudeCodeAdapter {
463
497
  log?.info?.(`deferring lazy restart of ${sessionKey}: runtime-initiated turn in progress`);
464
498
  return existing;
465
499
  }
466
- this.killProcess(sessionKey, existing);
500
+ void this.killProcess(sessionKey, existing);
467
501
  }
468
502
  else if (alive) {
469
503
  return existing;
@@ -498,7 +532,7 @@ export class ClaudeCodeAdapter {
498
532
  onFatal: () => {
499
533
  const current = this.processes.get(sessionKey);
500
534
  if (current === state)
501
- this.killProcess(sessionKey, state);
535
+ void this.killProcess(sessionKey, state);
502
536
  },
503
537
  onEof: () => {
504
538
  state.done = true;
@@ -546,7 +580,8 @@ export class ClaudeCodeAdapter {
546
580
  proc.stderr.on('data', (chunk) => {
547
581
  stderrChunks.push(chunk.toString());
548
582
  });
549
- proc.stdin.on('error', () => {
583
+ proc.stdin.on('error', (err) => {
584
+ stderrChunks.push(`Claude stdin failed: ${String(err)}\n`);
550
585
  proc.stdin.destroy();
551
586
  if (proc.exitCode === null && proc.signalCode === null) {
552
587
  if (!IS_WIN32 || !proc.pid || !killWin32Tree(proc.pid)) {
@@ -560,10 +595,12 @@ export class ClaudeCodeAdapter {
560
595
  });
561
596
  return { proc, exitPromise, stderrChunks };
562
597
  }
598
+ /** Resolves once every live input's failed report has settled (best effort). */
563
599
  killProcess(sessionKey, state) {
600
+ const reports = [];
564
601
  for (const delivery of state.inputs.values()) {
565
602
  if (!delivery.terminal)
566
- void state.inputs.failBestEffort(delivery);
603
+ reports.push(state.inputs.failBestEffort(delivery, state.log));
567
604
  }
568
605
  state.done = true;
569
606
  const current = this.processes.get(sessionKey);
@@ -572,6 +609,7 @@ export class ClaudeCodeAdapter {
572
609
  }
573
610
  state.pump.close('killed', 'Claude process terminated by the bridge');
574
611
  this.terminateHandle(state.handle);
612
+ return Promise.all(reports).then(() => undefined);
575
613
  }
576
614
  endStdin(handle) {
577
615
  try {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import * as os from 'node:os';
3
- import { capabilityBinDir, childLogger, clearAllProviderCreds, configureHttpKeepAlive, createLogger, createOtelLogger, createPlatformConfigManager, deriveModelIsPin, identityFromMe, initAgentTelemetry, llmSource, resolveServiceVersion, materializeChannelCapabilities, ParallAgentGateway, parseDispatchDeadlineMs, parseForkDeadlineMs, parseProviderConfig, parseShutdownDeadlineMs, resolveRuntimeContextWindow, resolveRuntimeMaxTokens, resolveRuntimeModel, traceContextHeaders, } from '@parall/agent-core';
3
+ import { classifyStartupFailure, capabilityBinDir, childLogger, clearAllProviderCreds, configureHttpKeepAlive, createLogger, createOtelLogger, createPlatformConfigManager, deriveModelIsPin, identityFromMe, initAgentTelemetry, llmSource, resolveServiceVersion, materializeChannelCapabilities, ParallAgentGateway, parseDispatchDeadlineMs, parseForkDeadlineMs, parseProviderConfig, parseShutdownDeadlineMs, resolveRuntimeContextWindow, resolveRuntimeMaxTokens, resolveRuntimeModel, traceContextHeaders, newRuntimeInstanceId, } from '@parall/agent-core';
4
4
  import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
5
5
  import { buildClaudeRuntimeKey, contextFilePathForSession, dispatchContextDirPath, resolveClaudeAgentConfig, resolveWsUrl, sessionStateFilePathForRuntime, stepIdFilePathForSession, } from './config.js';
6
6
  import { ClaudeCodeAdapter } from './dispatch.js';
@@ -84,8 +84,12 @@ async function main() {
84
84
  const sessionStateFilePath = sessionStateFilePathForRuntime(config.stateDir, runtimeKey);
85
85
  const sessionManager = new ClaudeSessionManager(runtimeKey, sessionStateFilePath, agentLog);
86
86
  const resolvedWsUrl = resolveWsUrl(config.apiUrl, config.wsUrl, config.swimlaneName);
87
+ // This process's runtime instance: rides the WS ticket and every claim so
88
+ // the server owns turns by process identity instead of a lease clock
89
+ // (agent-dispatch-idempotency-design.md#instance-ownership).
90
+ const instanceId = newRuntimeInstanceId();
87
91
  const ws = new ParallWs({
88
- getTicket: () => client.getWsTicket(),
92
+ getTicket: () => client.getWsTicket({ instance_id: instanceId }),
89
93
  wsUrl: resolvedWsUrl,
90
94
  });
91
95
  const configMgr = createPlatformConfigManager({
@@ -237,6 +241,7 @@ async function main() {
237
241
  accountId: agentUserId,
238
242
  client,
239
243
  ws,
244
+ instanceId,
240
245
  connectionLabel: resolvedWsUrl,
241
246
  config: {
242
247
  parall_url: config.apiUrl,
@@ -300,6 +305,19 @@ async function main() {
300
305
  }
301
306
  }
302
307
  main().catch((err) => {
303
- activeLog.error(`fatal: ${String(err)}`);
304
- process.exitCode = 1;
308
+ // A credential the server rejects during bootstrap is the same terminal
309
+ // failure the gateway reports as auth-invalid: exit with the re-mint code
310
+ // so the daemon mints a fresh launch credential instead of restarting
311
+ // this process with the same dead key.
312
+ const { exitCode, authRejection } = classifyStartupFailure(err);
313
+ if (authRejection) {
314
+ activeLog.error(`AUTH-INVALID at startup status=${authRejection.status}` +
315
+ `${authRejection.code ? ` code=${authRejection.code}` : ''} — ` +
316
+ `the server rejected this runtime's credential (${authRejection.message}); ` +
317
+ `exiting with code ${exitCode} so the supervisor can re-mint the launch credential.`);
318
+ }
319
+ else {
320
+ activeLog.error(`fatal: ${String(err)}`);
321
+ }
322
+ process.exitCode = exitCode;
305
323
  });
@@ -63,8 +63,8 @@ export declare class ClaudeInputRegistry {
63
63
  */
64
64
  discardBookkeeping(deliveryKey: string): void;
65
65
  apply(delivery: ClaudeInputDelivery, state: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded'): Promise<void>;
66
- fail(delivery: ClaudeInputDelivery): Promise<void>;
67
- failBestEffort(delivery: ClaudeInputDelivery, log?: GatewayLogger): Promise<void>;
68
- failAllBestEffort(log?: GatewayLogger): Promise<void>;
66
+ fail(delivery: ClaudeInputDelivery, detail?: string): Promise<void>;
67
+ failBestEffort(delivery: ClaudeInputDelivery, log?: GatewayLogger, detail?: string): Promise<void>;
68
+ failAllBestEffort(log?: GatewayLogger, detail?: string): Promise<void>;
69
69
  }
70
70
  //# sourceMappingURL=input-lifecycle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"input-lifecycle.d.ts","sourceRoot":"","sources":["../src/input-lifecycle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACnG,OAAO,EACL,KAAK,cAAc,EAGnB,KAAK,YAAY,EAClB,MAAM,gBAAgB,CAAC;AAExB,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,YAAY,EAAE,OAAO,CAAC;IACtB,kFAAkF;IAClF,IAAI,EAAE,cAAc,CAAC;IACrB,uEAAuE;IACvE,QAAQ,EAAE,YAAY,CAAC;IACvB,6FAA6F;IAC7F,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,8DAA8D;IAC9D,gBAAgB,EAAE,OAAO,CAAC;IAC1B;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF;;;;GAIG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0C;IAChE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0C;IAEpE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAI9D,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAIlE,eAAe,CAAC,gBAAgB,EAAE,MAAM,EAAE,GAAG,mBAAmB,GAAG,SAAS;IAU5E,MAAM,IAAI,gBAAgB,CAAC,mBAAmB,CAAC;IAI/C,oBAAoB,IAAI,OAAO;IAI/B;;;;;;;;;OASG;IACH,sBAAsB,IAAI,OAAO;IAIjC,QAAQ,CACN,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,sBAAsB,GAAG,SAAS,EAC7C,QAAQ,EAAE,OAAO,EACjB,YAAY,CAAC,EAAE,MAAM,IAAI,EACzB,GAAG,CAAC,EAAE,aAAa,GAClB,mBAAmB;IAsBtB,MAAM,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI;IAY3C;;;;;;;;OAQG;IACH,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAOvC,KAAK,CACT,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACpE,OAAO,CAAC,IAAI,CAAC;IA0BV,IAAI,CAAC,QAAQ,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBlD,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,GAAG,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAUjF,iBAAiB,CAAC,GAAG,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;CAO5D"}
1
+ {"version":3,"file":"input-lifecycle.d.ts","sourceRoot":"","sources":["../src/input-lifecycle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEnG,OAAO,EACL,KAAK,cAAc,EAGnB,KAAK,YAAY,EAClB,MAAM,gBAAgB,CAAC;AAExB,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,YAAY,EAAE,OAAO,CAAC;IACtB,kFAAkF;IAClF,IAAI,EAAE,cAAc,CAAC;IACrB,uEAAuE;IACvE,QAAQ,EAAE,YAAY,CAAC;IACvB,6FAA6F;IAC7F,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,8DAA8D;IAC9D,gBAAgB,EAAE,OAAO,CAAC;IAC1B;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF;;;;GAIG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0C;IAChE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0C;IAEpE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAI9D,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAIlE,eAAe,CAAC,gBAAgB,EAAE,MAAM,EAAE,GAAG,mBAAmB,GAAG,SAAS;IAU5E,MAAM,IAAI,gBAAgB,CAAC,mBAAmB,CAAC;IAI/C,oBAAoB,IAAI,OAAO;IAI/B;;;;;;;;;OASG;IACH,sBAAsB,IAAI,OAAO;IAIjC,QAAQ,CACN,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,sBAAsB,GAAG,SAAS,EAC7C,QAAQ,EAAE,OAAO,EACjB,YAAY,CAAC,EAAE,MAAM,IAAI,EACzB,GAAG,CAAC,EAAE,aAAa,GAClB,mBAAmB;IAsBtB,MAAM,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI;IAY3C;;;;;;;;OAQG;IACH,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAOvC,KAAK,CACT,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACpE,OAAO,CAAC,IAAI,CAAC;IA0BV,IAAI,CAAC,QAAQ,EAAE,mBAAmB,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6BnE,cAAc,CAClB,QAAQ,EAAE,mBAAmB,EAC7B,GAAG,CAAC,EAAE,aAAa,EACnB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC;IAUV,iBAAiB,CAAC,GAAG,CAAC,EAAE,aAAa,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAO7E"}
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { classifyClaudeTurn } from './turn-outcome.js';
2
3
  import { newTurnEvidence, newTurnSink, } from './turn-sink.js';
3
4
  /**
4
5
  * Per-process registry that maps Parall WorkItem batches to Claude stdin
@@ -116,7 +117,7 @@ export class ClaudeInputRegistry {
116
117
  if (delivery.drained)
117
118
  this.remove(delivery);
118
119
  }
119
- async fail(delivery) {
120
+ async fail(delivery, detail) {
120
121
  if (delivery.terminal)
121
122
  return;
122
123
  if (delivery.suppressFailReport) {
@@ -127,7 +128,13 @@ export class ClaudeInputRegistry {
127
128
  }
128
129
  try {
129
130
  if (delivery.reportedState !== 'failed') {
130
- const result = await delivery.lifecycle?.update('failed');
131
+ const classified = classifyClaudeTurn(delivery.evidence.lastResultMeta, delivery.evidence.noticeTexts);
132
+ const result = await delivery.lifecycle?.update('failed', {
133
+ outcomeClass: classified.outcome === 'ok' || classified.outcome === 'usage_limit'
134
+ ? 'runtime_crash'
135
+ : classified.outcome,
136
+ detail: detail ?? classified.detail ?? 'Claude input failed',
137
+ });
131
138
  delivery.reportedState = 'failed';
132
139
  delivery.terminal = result?.retry === false ? 'settled' : 'failed';
133
140
  }
@@ -138,17 +145,17 @@ export class ClaudeInputRegistry {
138
145
  this.remove(delivery);
139
146
  }
140
147
  }
141
- async failBestEffort(delivery, log) {
148
+ async failBestEffort(delivery, log, detail) {
142
149
  try {
143
- await this.fail(delivery);
150
+ await this.fail(delivery, detail);
144
151
  }
145
152
  catch (err) {
146
153
  log?.warn?.(`failed to report Claude input ${delivery.commandUuid} as failed: ${String(err)}`);
147
154
  }
148
155
  }
149
- async failAllBestEffort(log) {
156
+ async failAllBestEffort(log, detail) {
150
157
  await Promise.all([...this.byKey.values()]
151
158
  .filter((delivery) => !delivery.terminal)
152
- .map((delivery) => this.failBestEffort(delivery, log)));
159
+ .map((delivery) => this.failBestEffort(delivery, log, detail)));
153
160
  }
154
161
  }
@@ -1 +1 @@
1
- {"version":3,"file":"process-pump.d.ts","sourceRoot":"","sources":["../src/process-pump.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,gBAAgB,EAEtB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AACrF,OAAO,KAAK,EAAE,iBAAiB,EAA0B,MAAM,oBAAoB,CAAC;AACpF,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAEhE,OAAO,KAAK,EAAE,mBAAmB,EAAgC,MAAM,gBAAgB,CAAC;AAKxF,MAAM,MAAM,sBAAsB,GAAG;IACnC,oFAAoF;IACpF,aAAa,CAAC,IAAI,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,EAAE,CAAA;KAAE,GAAG,MAAM,GAAG,SAAS,CAAC;IACxF,mEAAmE;IACnE,OAAO,IAAI,IAAI,CAAC;IAChB,yCAAyC;IACzC,KAAK,IAAI,IAAI,CAAC;IACd,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACnD,mBAAmB,IAAI,IAAI,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,cAAc,CAAC,iBAAiB,CAAC,CAAC;IAC1C,MAAM,EAAE,mBAAmB,CAAC;IAC5B,MAAM,EAAE,mBAAmB,CAAC;IAC5B,KAAK,EAAE,sBAAsB,CAAC;IAC9B,GAAG,CAAC,EAAE,aAAa,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,iBAAiB;IAWhB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAVjC,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,YAAY,cAAyC;IACrD,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAAQ;IAC7C,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,WAAW,CAAyB;IAC5C,OAAO,CAAC,SAAS,CAA+B;IAChD,OAAO,CAAC,WAAW,CAAoC;IACvD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;gBAEN,IAAI,EAAE,wBAAwB;IAK3D,KAAK,IAAI,IAAI;IAIb,+EAA+E;IAC/E,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,YAAY,CAAC,EAAE,MAAM,IAAI,GAAG,IAAI;IAK9E,gBAAgB,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI;IAIrD,kBAAkB,IAAI,OAAO;IAK7B;;;;;OAKG;IACH,UAAU,CAAC,GAAG,SAAa,GAAG,OAAO;IAIrC,SAAS,CAAC,GAAG,SAAa,GAAG,gBAAgB;IAU7C,sEAAsE;IACtE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAQtC;;;OAGG;IACH,KAAK,CAAC,MAAM,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAYzD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,EAAE,MAAM,IAAI,GAAG,IAAI;IAK9B,OAAO,CAAC,SAAS;IA0BjB,gBAAgB,CAAC,MAAM,EAAE,mBAAmB,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI;YAevD,GAAG;YAeH,KAAK;IAkDnB,OAAO,CAAC,QAAQ;IAOhB,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,QAAQ;IAOhB,OAAO,CAAC,eAAe;IAkCvB,OAAO,CAAC,UAAU;YAsBJ,eAAe;IA2C7B,OAAO,CAAC,UAAU;YA6BJ,aAAa;IA8C3B,OAAO,CAAC,qBAAqB;YAOf,SAAS;CAuBxB"}
1
+ {"version":3,"file":"process-pump.d.ts","sourceRoot":"","sources":["../src/process-pump.ts"],"names":[],"mappings":"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"}
@@ -321,6 +321,9 @@ export class ClaudeProcessPump {
321
321
  kind: 'runtime',
322
322
  event: { type: 'error', message: `Claude input lifecycle update failed: ${String(err)}` },
323
323
  });
324
+ // failBestEffort may unregister this delivery before onFatal closes
325
+ // the process's remaining sinks. Its waiting drain still needs EOF.
326
+ delivery.sink.close();
324
327
  this.opts.hooks.onFatal();
325
328
  return;
326
329
  }
@@ -417,8 +420,11 @@ export class ClaudeProcessPump {
417
420
  if (!delivery.terminal && settledAsLimit(delivery.evidence))
418
421
  delivery.suppressFailReport = true;
419
422
  }
420
- await inputs.failAllBestEffort(log);
421
- for (const delivery of inputs.values()) {
423
+ // Failing removes drained deliveries from the registry. Their sinks must
424
+ // still receive EOF, or a startup failure waits for the dispatch timeout.
425
+ const deliveries = [...inputs.values()];
426
+ await inputs.failAllBestEffort(log, message);
427
+ for (const delivery of deliveries) {
422
428
  delivery.sink.push({ kind: 'ended', reason: 'eof', message });
423
429
  delivery.sink.close();
424
430
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/claude-agent",
3
- "version": "1.62.0",
3
+ "version": "1.64.0",
4
4
  "description": "Claude Code bridge runtime for self-hosted Parall agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,9 +25,9 @@
25
25
  "src"
26
26
  ],
27
27
  "dependencies": {
28
- "@parall/agent-core": "1.62.0",
29
- "@parall/cli": "1.62.0",
30
- "@parall/sdk": "1.62.0"
28
+ "@parall/agent-core": "1.64.0",
29
+ "@parall/cli": "1.64.0",
30
+ "@parall/sdk": "1.64.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.0.0",
package/src/dispatch.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  CleanupForkOpts,
7
7
  CompactOpts,
8
8
  CompactResult,
9
+ DiscardedInjectionOutcome,
9
10
  DispatchAdapter,
10
11
  DispatchInputLifecycle,
11
12
  DispatchOpts,
@@ -57,12 +58,8 @@ type ClaudeCodeAdapterOptions = Pick<
57
58
  /** @deprecated Use contextFilePathForSession. */
58
59
  stepIdFilePathForSession?: (sessionKey: string) => string;
59
60
  /**
60
- * PRLL_CONTEXT_DIR root for per-lane dispatch context files. Like the
61
- * Parall credentials above, this is bridge-static the long-lived
62
- * subprocess env is built ONCE from the placeholder context, so a value
63
- * only present on per-dispatch DispatchContexts would never reach the
64
- * CLI and every reply would silently lose its lane binding (turns would
65
- * resolve no_action instead of reply-effect dedupe).
61
+ * Bridge-static PRLL_CONTEXT_DIR root: a value only in per-dispatch context
62
+ * never reaches the long-lived CLI, so replies lose their lane binding.
66
63
  */
67
64
  contextDirPath?: string;
68
65
  /**
@@ -123,6 +120,7 @@ export type ProcessState = {
123
120
 
124
121
  export class ClaudeCodeAdapter implements DispatchAdapter {
125
122
  readonly inputLifecycleMode = 'explicit' as const;
123
+ readonly inputSettlementMode = 'receipt' as const;
126
124
  private readonly processes = new Map<string, ProcessState>();
127
125
  /** Retired by abortDispatch while the CLI still had its own work: busy until idle, then terminated. */
128
126
  private readonly retiring = new Set<ProcessState>();
@@ -264,15 +262,50 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
264
262
  return state.inputs.hasUnsettledInjections();
265
263
  }
266
264
 
267
- acknowledgeDiscardedInjection(sessionKey: string, deliveryKey: string): void {
265
+ /**
266
+ * The gateway discarded the buffered copy of an injection without a
267
+ * dispatch(). A settled injection only owes bookkeeping. One that has NOT
268
+ * settled is an orphan: nothing will ever drain it, and the CLI does not
269
+ * start a queued input on its own once the turn it was steered into has
270
+ * ended — it works its queue only when the next stdin frame arrives, which
271
+ * would be the batch re-delivering this very member (prod 2026-09-03: lane
272
+ * dethroned after 10 idle minutes, then "refusing duplicate stdin write"
273
+ * on every retry). Withdraw it: kill the process (the session survives via
274
+ * --resume), report the input failed so the server releases the member,
275
+ * and let the gateway run it as a real turn.
276
+ */
277
+ async acknowledgeDiscardedInjection(
278
+ sessionKey: string,
279
+ deliveryKey: string,
280
+ ): Promise<DiscardedInjectionOutcome> {
268
281
  const state = this.processes.get(sessionKey);
269
- if (!state) return;
282
+ if (!state) return 'discarded';
283
+ const delivery = state.inputs.getByKey(deliveryKey);
284
+ if (delivery && !delivery.terminal) {
285
+ state.log?.warn?.(
286
+ `withdrawing orphaned Claude input ${delivery.commandUuid} (${delivery.reportedState ?? 'queued'}) on ${sessionKey}: its buffered copy was discarded before the CLI settled it${state.pump.hasOwnWork() ? '; the runtime-initiated turn dies with the process' : ''}`,
287
+ );
288
+ await this.killProcess(sessionKey, state);
289
+ state.inputs.discardBookkeeping(deliveryKey);
290
+ // 'cancelled' only when the server confirmed the failed receipt: the
291
+ // member is released and un-folded, so the gateway can re-fold and run
292
+ // it locally. A lost receipt (reportedState never reached 'failed') or
293
+ // a 'settled' answer (a reply effect already resolved the member)
294
+ // leaves nothing to re-run here — the lane complete releases an unread
295
+ // member for re-drive instead.
296
+ const confirmed = delivery.reportedState === 'failed' && delivery.terminal === 'failed';
297
+ if (!confirmed) {
298
+ state.log?.warn?.(
299
+ `withdrawal of Claude input ${delivery.commandUuid} was not confirmed by the server (${delivery.terminal}); leaving the member to the lane complete`,
300
+ );
301
+ }
302
+ return confirmed ? 'cancelled' : 'discarded';
303
+ }
270
304
  state.inputs.discardBookkeeping(deliveryKey);
271
305
  // The discarded copy may have been the last thing a lazy restart was
272
- // waiting on. Unlike the consume path's tail, the discarded delivery may
273
- // still be RUNNING (non-terminal): killing the process now would fail a
274
- // live input, so the restart also waits for every injection to settle.
306
+ // waiting on.
275
307
  this.maybeApplyRestart(sessionKey, state);
308
+ return 'discarded';
276
309
  }
277
310
 
278
311
  async *dispatch({
@@ -364,14 +397,14 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
364
397
  cleanupFork({ fork }: CleanupForkOpts) {
365
398
  const state = this.processes.get(fork.sessionKey);
366
399
  if (state) {
367
- this.killProcess(fork.sessionKey, state);
400
+ void this.killProcess(fork.sessionKey, state);
368
401
  }
369
402
  this.opts.sessionManager.cleanupFork(fork.sessionKey);
370
403
  }
371
404
 
372
405
  resetProcesses(): void {
373
406
  for (const [sessionKey, state] of this.processes) {
374
- this.killProcess(sessionKey, state);
407
+ void this.killProcess(sessionKey, state);
375
408
  }
376
409
  this.processes.clear();
377
410
  const retired = [...this.retiring];
@@ -410,7 +443,10 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
410
443
  state = this.ensureProcess(sessionKey, log);
411
444
  } catch (err) {
412
445
  try {
413
- await lifecycle?.update('failed');
446
+ await lifecycle?.update('failed', {
447
+ outcomeClass: 'runtime_crash',
448
+ detail: `Claude spawn failed: ${String(err)}`,
449
+ });
414
450
  } catch (reportErr) {
415
451
  log?.warn?.(`failed to report rejected Claude input as failed: ${String(reportErr)}`);
416
452
  }
@@ -423,10 +459,10 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
423
459
  try {
424
460
  this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
425
461
  } catch (err) {
426
- await state.inputs.failBestEffort(delivery, log);
462
+ await state.inputs.failBestEffort(delivery, log, `Claude stdin write failed: ${String(err)}`);
427
463
  state.inputs.remove(delivery);
428
464
  yield { type: 'error', message: `Claude stdin write failed: ${String(err)}` };
429
- this.killProcess(sessionKey, state);
465
+ void this.killProcess(sessionKey, state);
430
466
  return;
431
467
  }
432
468
 
@@ -443,7 +479,9 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
443
479
  {
444
480
  ensureRuntimeCapability: (log) => this.ensureRuntimeCapability(log),
445
481
  ensureProcess: (sessionKey, log) => this.ensureProcess(sessionKey, log),
446
- killProcess: (sessionKey, state) => this.killProcess(sessionKey, state),
482
+ killProcess: (sessionKey, state) => {
483
+ void this.killProcess(sessionKey, state);
484
+ },
447
485
  writeUserMessage: (handle, text, uuid) => this.writeUserMessage(handle, text, uuid),
448
486
  applyPendingRestart: (sessionKey, state) => this.maybeApplyRestart(sessionKey, state),
449
487
  },
@@ -584,7 +622,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
584
622
  `restarting ${sessionKey} with ${outstanding.total} live background task(s); they die with the process`,
585
623
  );
586
624
  }
587
- this.killProcess(sessionKey, state);
625
+ void this.killProcess(sessionKey, state);
588
626
  }
589
627
 
590
628
  private ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState {
@@ -603,7 +641,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
603
641
  );
604
642
  return existing;
605
643
  }
606
- this.killProcess(sessionKey, existing);
644
+ void this.killProcess(sessionKey, existing);
607
645
  } else if (alive) {
608
646
  return existing;
609
647
  } else {
@@ -636,7 +674,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
636
674
  },
637
675
  onFatal: () => {
638
676
  const current = this.processes.get(sessionKey);
639
- if (current === state) this.killProcess(sessionKey, state);
677
+ if (current === state) void this.killProcess(sessionKey, state);
640
678
  },
641
679
  onEof: () => {
642
680
  state.done = true;
@@ -704,7 +742,8 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
704
742
  proc.stderr.on('data', (chunk: Buffer) => {
705
743
  stderrChunks.push(chunk.toString());
706
744
  });
707
- proc.stdin.on('error', () => {
745
+ proc.stdin.on('error', (err) => {
746
+ stderrChunks.push(`Claude stdin failed: ${String(err)}\n`);
708
747
  proc.stdin.destroy();
709
748
  if (proc.exitCode === null && proc.signalCode === null) {
710
749
  if (!IS_WIN32 || !proc.pid || !killWin32Tree(proc.pid)) {
@@ -723,9 +762,11 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
723
762
  return { proc, exitPromise, stderrChunks };
724
763
  }
725
764
 
726
- private killProcess(sessionKey: string, state: ProcessState) {
765
+ /** Resolves once every live input's failed report has settled (best effort). */
766
+ private killProcess(sessionKey: string, state: ProcessState): Promise<void> {
767
+ const reports: Promise<void>[] = [];
727
768
  for (const delivery of state.inputs.values()) {
728
- if (!delivery.terminal) void state.inputs.failBestEffort(delivery);
769
+ if (!delivery.terminal) reports.push(state.inputs.failBestEffort(delivery, state.log));
729
770
  }
730
771
  state.done = true;
731
772
  const current = this.processes.get(sessionKey);
@@ -734,6 +775,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
734
775
  }
735
776
  state.pump.close('killed', 'Claude process terminated by the bridge');
736
777
  this.terminateHandle(state.handle);
778
+ return Promise.all(reports).then(() => undefined);
737
779
  }
738
780
 
739
781
  private endStdin(handle: ClaudeProcessHandle): void {
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as os from 'node:os';
4
4
  import {
5
+ classifyStartupFailure,
5
6
  capabilityBinDir,
6
7
  childLogger,
7
8
  clearAllProviderCreds,
@@ -24,6 +25,7 @@ import {
24
25
  resolveRuntimeMaxTokens,
25
26
  resolveRuntimeModel,
26
27
  traceContextHeaders,
28
+ newRuntimeInstanceId,
27
29
  } from '@parall/agent-core';
28
30
  import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
29
31
  import {
@@ -124,8 +126,12 @@ async function main() {
124
126
  const sessionManager = new ClaudeSessionManager(runtimeKey, sessionStateFilePath, agentLog);
125
127
 
126
128
  const resolvedWsUrl = resolveWsUrl(config.apiUrl, config.wsUrl, config.swimlaneName);
129
+ // This process's runtime instance: rides the WS ticket and every claim so
130
+ // the server owns turns by process identity instead of a lease clock
131
+ // (agent-dispatch-idempotency-design.md#instance-ownership).
132
+ const instanceId = newRuntimeInstanceId();
127
133
  const ws = new ParallWs({
128
- getTicket: () => client.getWsTicket(),
134
+ getTicket: () => client.getWsTicket({ instance_id: instanceId }),
129
135
  wsUrl: resolvedWsUrl,
130
136
  });
131
137
 
@@ -299,6 +305,7 @@ async function main() {
299
305
  accountId: agentUserId,
300
306
  client,
301
307
  ws,
308
+ instanceId,
302
309
  connectionLabel: resolvedWsUrl,
303
310
  config: {
304
311
  parall_url: config.apiUrl,
@@ -365,6 +372,20 @@ async function main() {
365
372
  }
366
373
 
367
374
  main().catch((err) => {
368
- activeLog.error(`fatal: ${String(err)}`);
369
- process.exitCode = 1;
375
+ // A credential the server rejects during bootstrap is the same terminal
376
+ // failure the gateway reports as auth-invalid: exit with the re-mint code
377
+ // so the daemon mints a fresh launch credential instead of restarting
378
+ // this process with the same dead key.
379
+ const { exitCode, authRejection } = classifyStartupFailure(err);
380
+ if (authRejection) {
381
+ activeLog.error(
382
+ `AUTH-INVALID at startup status=${authRejection.status}` +
383
+ `${authRejection.code ? ` code=${authRejection.code}` : ''} — ` +
384
+ `the server rejected this runtime's credential (${authRejection.message}); ` +
385
+ `exiting with code ${exitCode} so the supervisor can re-mint the launch credential.`,
386
+ );
387
+ } else {
388
+ activeLog.error(`fatal: ${String(err)}`);
389
+ }
390
+ process.exitCode = exitCode;
370
391
  });
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { DispatchInputLifecycle, GatewayLogger, RuntimeInputState } from '@parall/agent-core';
3
+ import { classifyClaudeTurn } from './turn-outcome.js';
3
4
  import {
4
5
  type ClaudeTurnSink,
5
6
  newTurnEvidence,
@@ -168,7 +169,7 @@ export class ClaudeInputRegistry {
168
169
  if (delivery.drained) this.remove(delivery);
169
170
  }
170
171
 
171
- async fail(delivery: ClaudeInputDelivery): Promise<void> {
172
+ async fail(delivery: ClaudeInputDelivery, detail?: string): Promise<void> {
172
173
  if (delivery.terminal) return;
173
174
  if (delivery.suppressFailReport) {
174
175
  delivery.terminal = 'settled';
@@ -177,7 +178,17 @@ export class ClaudeInputRegistry {
177
178
  }
178
179
  try {
179
180
  if (delivery.reportedState !== 'failed') {
180
- const result = await delivery.lifecycle?.update('failed');
181
+ const classified = classifyClaudeTurn(
182
+ delivery.evidence.lastResultMeta,
183
+ delivery.evidence.noticeTexts,
184
+ );
185
+ const result = await delivery.lifecycle?.update('failed', {
186
+ outcomeClass:
187
+ classified.outcome === 'ok' || classified.outcome === 'usage_limit'
188
+ ? 'runtime_crash'
189
+ : classified.outcome,
190
+ detail: detail ?? classified.detail ?? 'Claude input failed',
191
+ });
181
192
  delivery.reportedState = 'failed';
182
193
  delivery.terminal = result?.retry === false ? 'settled' : 'failed';
183
194
  }
@@ -187,9 +198,13 @@ export class ClaudeInputRegistry {
187
198
  }
188
199
  }
189
200
 
190
- async failBestEffort(delivery: ClaudeInputDelivery, log?: GatewayLogger): Promise<void> {
201
+ async failBestEffort(
202
+ delivery: ClaudeInputDelivery,
203
+ log?: GatewayLogger,
204
+ detail?: string,
205
+ ): Promise<void> {
191
206
  try {
192
- await this.fail(delivery);
207
+ await this.fail(delivery, detail);
193
208
  } catch (err) {
194
209
  log?.warn?.(
195
210
  `failed to report Claude input ${delivery.commandUuid} as failed: ${String(err)}`,
@@ -197,11 +212,11 @@ export class ClaudeInputRegistry {
197
212
  }
198
213
  }
199
214
 
200
- async failAllBestEffort(log?: GatewayLogger): Promise<void> {
215
+ async failAllBestEffort(log?: GatewayLogger, detail?: string): Promise<void> {
201
216
  await Promise.all(
202
217
  [...this.byKey.values()]
203
218
  .filter((delivery) => !delivery.terminal)
204
- .map((delivery) => this.failBestEffort(delivery, log)),
219
+ .map((delivery) => this.failBestEffort(delivery, log, detail)),
205
220
  );
206
221
  }
207
222
  }
@@ -361,6 +361,9 @@ export class ClaudeProcessPump {
361
361
  kind: 'runtime',
362
362
  event: { type: 'error', message: `Claude input lifecycle update failed: ${String(err)}` },
363
363
  });
364
+ // failBestEffort may unregister this delivery before onFatal closes
365
+ // the process's remaining sinks. Its waiting drain still needs EOF.
366
+ delivery.sink.close();
364
367
  this.opts.hooks.onFatal();
365
368
  return;
366
369
  }
@@ -470,8 +473,11 @@ export class ClaudeProcessPump {
470
473
  if (!delivery.terminal && settledAsLimit(delivery.evidence))
471
474
  delivery.suppressFailReport = true;
472
475
  }
473
- await inputs.failAllBestEffort(log);
474
- for (const delivery of inputs.values()) {
476
+ // Failing removes drained deliveries from the registry. Their sinks must
477
+ // still receive EOF, or a startup failure waits for the dispatch timeout.
478
+ const deliveries = [...inputs.values()];
479
+ await inputs.failAllBestEffort(log, message);
480
+ for (const delivery of deliveries) {
475
481
  delivery.sink.push({ kind: 'ended', reason: 'eof', message });
476
482
  delivery.sink.close();
477
483
  }