@agent-relay/factory 0.1.15 → 0.1.17

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 (51) hide show
  1. package/README.md +9 -4
  2. package/dist/cli/fleet.d.ts.map +1 -1
  3. package/dist/cli/fleet.js +13 -0
  4. package/dist/cli/fleet.js.map +1 -1
  5. package/dist/config/schema.d.ts +1 -0
  6. package/dist/config/schema.d.ts.map +1 -1
  7. package/dist/config/schema.js +1 -0
  8. package/dist/config/schema.js.map +1 -1
  9. package/dist/dispatch/templates.js +3 -3
  10. package/dist/dispatch/templates.js.map +1 -1
  11. package/dist/fleet/internal-fleet-client.d.ts +1 -0
  12. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  13. package/dist/fleet/internal-fleet-client.js +41 -1
  14. package/dist/fleet/internal-fleet-client.js.map +1 -1
  15. package/dist/github/merge-gate.d.ts.map +1 -1
  16. package/dist/github/merge-gate.js +2 -0
  17. package/dist/github/merge-gate.js.map +1 -1
  18. package/dist/github/probe-closer.d.ts +3 -1
  19. package/dist/github/probe-closer.d.ts.map +1 -1
  20. package/dist/github/probe-closer.js +8 -8
  21. package/dist/github/probe-closer.js.map +1 -1
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/mount/relayfile-cloud-mount-client.d.ts +3 -2
  27. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  28. package/dist/mount/relayfile-cloud-mount-client.js +9 -2
  29. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  30. package/dist/mount/relayfile-github-connection-write.d.ts +26 -0
  31. package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -0
  32. package/dist/mount/relayfile-github-connection-write.js +127 -0
  33. package/dist/mount/relayfile-github-connection-write.js.map +1 -0
  34. package/dist/orchestrator/factory.d.ts.map +1 -1
  35. package/dist/orchestrator/factory.js +285 -21
  36. package/dist/orchestrator/factory.js.map +1 -1
  37. package/dist/ports/fleet.d.ts +1 -0
  38. package/dist/ports/fleet.d.ts.map +1 -1
  39. package/dist/ports/index.d.ts +1 -1
  40. package/dist/ports/index.d.ts.map +1 -1
  41. package/dist/ports/mount.d.ts +27 -0
  42. package/dist/ports/mount.d.ts.map +1 -1
  43. package/dist/testing/fakes.d.ts +3 -2
  44. package/dist/testing/fakes.d.ts.map +1 -1
  45. package/dist/testing/fakes.js +3 -1
  46. package/dist/testing/fakes.js.map +1 -1
  47. package/dist/writeback/github.d.ts +2 -0
  48. package/dist/writeback/github.d.ts.map +1 -1
  49. package/dist/writeback/github.js +2 -0
  50. package/dist/writeback/github.js.map +1 -1
  51. package/package.json +1 -1
@@ -156,6 +156,7 @@ export class FactoryLoop {
156
156
  // Issue key -> the open PR the babysitter is shepherding, including the
157
157
  // webhook-fed mount path so readiness can re-read PR meta without a gh call.
158
158
  #babysitterPr = new Map();
159
+ #publishedPullRequests = new Set();
159
160
  #probePrGhBackoffUntilMs = new Map();
160
161
  #probePrResolvedCache = new Map();
161
162
  // GitHub issue mirror-id -> resolved Linear mirror path, so repeat ingestion
@@ -2195,6 +2196,19 @@ export class FactoryLoop {
2195
2196
  }
2196
2197
  const exiting = record.agents.get(name);
2197
2198
  if (isCompletionReason(reason)) {
2199
+ let publishedPr;
2200
+ if (exiting?.spec.role === 'implementer' &&
2201
+ !record.dryRun &&
2202
+ (this.#mount.githubWrite || this.#mount.writebackTransport === 'relayfile-cloud')) {
2203
+ try {
2204
+ publishedPr = await this.#publishImplementerPullRequest(record, exiting);
2205
+ }
2206
+ catch (error) {
2207
+ this.#increment('githubPullRequestPublishFailures');
2208
+ this.#error(error, record.issue);
2209
+ return;
2210
+ }
2211
+ }
2198
2212
  if (this.#config.babysitter.enabled && !record.dryRun) {
2199
2213
  // Babysitter path: an implementer/reviewer finishing does NOT mark the
2200
2214
  // issue done — it hands the open PR to the babysitter. The babysitter
@@ -2203,6 +2217,13 @@ export class FactoryLoop {
2203
2217
  if (exiting?.spec.role === 'babysitter') {
2204
2218
  await this.#maybeAdvanceToHumanReview(record);
2205
2219
  }
2220
+ else if (publishedPr) {
2221
+ await this.#ensureBabysitter(record, {
2222
+ repo: publishedPr.repo,
2223
+ prNumber: publishedPr.number,
2224
+ url: publishedPr.url,
2225
+ });
2226
+ }
2206
2227
  else {
2207
2228
  await this.#ensureBabysitterForIssue(record);
2208
2229
  }
@@ -2224,14 +2245,40 @@ export class FactoryLoop {
2224
2245
  await this.#completeIssue(record);
2225
2246
  return;
2226
2247
  }
2248
+ // The implementer's turn ended without a PR of record. Agents reliably
2249
+ // COMMIT their work to a feature branch but often exit (turn-end / idle)
2250
+ // before running `gh pr create` — the root reason a dispatch never reached
2251
+ // human-review even after the #67 fixes. Rather than respawn a done agent
2252
+ // and hope, factory finalizes the branch into a PR itself (the same
2253
+ // publish path the completion flow uses), then advances to the babysitter
2254
+ // / human-review path. Best-effort: with no publishable branch (no commits
2255
+ // ahead of base, clone gone) it returns undefined and we fall through.
2256
+ if (tracked.spec.role === 'implementer') {
2257
+ const publishedPr = await this.#tryPublishImplementerPr(record, tracked);
2258
+ if (publishedPr) {
2259
+ if (this.#config.babysitter.enabled) {
2260
+ await this.#ensureBabysitter(record, {
2261
+ repo: publishedPr.repo,
2262
+ prNumber: publishedPr.number,
2263
+ url: publishedPr.url,
2264
+ });
2265
+ }
2266
+ else {
2267
+ await this.#completeIssue(record);
2268
+ }
2269
+ return;
2270
+ }
2271
+ }
2227
2272
  if (tracked.sessionRef) {
2228
2273
  const resumeKey = `${issueKey(record.issue)}:${name}:${tracked.sessionRef}`;
2229
2274
  if (await this.#state.isResumed(this.#workspaceId, resumeKey)) {
2230
2275
  // Already resumed once and STILL exiting with no completion PR — the
2231
- // agent isn't making progress. Escalate so a human notices, instead of
2232
- // leaving the issue silently in-flight forever.
2276
+ // agent isn't making progress. Conclude the dispatch so a human
2277
+ // notices AND the reviewer waiting on the implementer's DM is torn
2278
+ // down, instead of leaving the issue silently in-flight forever with
2279
+ // a live reviewer stalling the owned-broker dispose-wait (#67).
2233
2280
  if (tracked.spec.role === 'implementer') {
2234
- await this.#escalateStalledIssue(record, name);
2281
+ await this.#concludeTerminalImplementer(record, name, 'stalled-no-pr');
2235
2282
  }
2236
2283
  return;
2237
2284
  }
@@ -2250,17 +2297,24 @@ export class FactoryLoop {
2250
2297
  if (isAgentAlreadyExistsError(error)) {
2251
2298
  // The broker never released this agent's name on exit
2252
2299
  // (relay#1116-family), so re-registering collides with the stuck
2253
- // name. The error is marked retryable but isn't retrying just
2254
- // re-collides forever. Treat it as terminal for this name: record
2255
- // the resume key so subsequent exit events short-circuit, count it,
2256
- // and warn once instead of spamming a 500 stack trace. The external
2257
- // reaper / a broker restart reclaims the leaked name.
2300
+ // name. Retrying just re-collides forever. Treat it as terminal for
2301
+ // this name: record the resume key so subsequent exit events
2302
+ // short-circuit, count it, and warn once. The external reaper / a
2303
+ // broker restart reclaims the leaked name.
2304
+ this.#fleet.markAgentTerminal?.(name, 'resume-already-exists');
2258
2305
  await this.#state.markResumed(this.#workspaceId, resumeKey);
2259
2306
  this.#increment('resumeNameCollisions');
2260
2307
  this.#logger.warn?.('[factory] resume skipped: broker still holds agent name (relay#1116); not retrying', {
2261
2308
  issue: record.issue.key,
2262
2309
  name,
2263
2310
  });
2311
+ // The implementer is now terminal but never sent the completion DM
2312
+ // the reviewer is blocked on ("Wait for a DM from the
2313
+ // implementer(s)"). Resolve the dispatch so the reviewer does not
2314
+ // stay live forever and stall the owned-broker dispose-wait (#67).
2315
+ if (tracked.spec.role === 'implementer') {
2316
+ await this.#concludeTerminalImplementer(record, name, 'resume-already-exists');
2317
+ }
2264
2318
  }
2265
2319
  else {
2266
2320
  throw error;
@@ -2272,25 +2326,141 @@ export class FactoryLoop {
2272
2326
  }
2273
2327
  else {
2274
2328
  const invocationId = `${batch.invocationIdFor(record.issue, tracked.spec)}:restart:${this.#clock.now()}`;
2275
- const result = await this.#fleet.spawn({
2276
- name: tracked.spec.name,
2277
- capability: tracked.spec.capability,
2278
- node: tracked.spec.node ?? 'self',
2279
- task: tracked.spec.task,
2280
- model: tracked.spec.model,
2281
- cwd: tracked.spec.clonePath,
2282
- sessionRef: tracked.spec.sessionRef,
2283
- invocationId,
2284
- restartPolicy: defaultRestartPolicy(tracked.spec),
2285
- channel: tracked.spec.channel,
2286
- });
2287
- batch.recordSpawn(record, tracked.spec, invocationId, result);
2329
+ try {
2330
+ const result = await this.#fleet.spawn({
2331
+ name: tracked.spec.name,
2332
+ capability: tracked.spec.capability,
2333
+ node: tracked.spec.node ?? 'self',
2334
+ task: tracked.spec.task,
2335
+ model: tracked.spec.model,
2336
+ cwd: tracked.spec.clonePath,
2337
+ sessionRef: tracked.spec.sessionRef,
2338
+ invocationId,
2339
+ restartPolicy: defaultRestartPolicy(tracked.spec),
2340
+ channel: tracked.spec.channel,
2341
+ });
2342
+ batch.recordSpawn(record, tracked.spec, invocationId, result);
2343
+ }
2344
+ catch (error) {
2345
+ if (!isAgentAlreadyExistsError(error)) {
2346
+ throw error;
2347
+ }
2348
+ // Same leaked-broker-name collision as the resume path, on a
2349
+ // no-sessionRef respawn: the broker's own restartPolicy already
2350
+ // re-registered the name (relay#1116-family), so our respawn collides.
2351
+ // Retrying just re-collides forever. Treat it as terminal and resolve
2352
+ // the dispatch so a reviewer blocked on the implementer's DM does not
2353
+ // hang the owned-broker dispose-wait (#67).
2354
+ this.#fleet.markAgentTerminal?.(name, 'respawn-already-exists');
2355
+ this.#increment('resumeNameCollisions');
2356
+ this.#logger.warn?.('[factory] respawn skipped: broker still holds agent name (relay#1116); not retrying', {
2357
+ issue: record.issue.key,
2358
+ name,
2359
+ });
2360
+ if (tracked.spec.role === 'implementer') {
2361
+ await this.#concludeTerminalImplementer(record, name, 'respawn-already-exists');
2362
+ }
2363
+ }
2288
2364
  }
2289
2365
  }
2290
2366
  catch (error) {
2291
2367
  this.#error(error, record.issue);
2292
2368
  }
2293
2369
  }
2370
+ // Publish a PR from the implementer's committed branch when it exited without
2371
+ // opening one. Best-effort and idempotent: returns undefined when there is no
2372
+ // GitHub write path, no clone, or nothing publishable (no branch / no commits
2373
+ // ahead of base — `#publishImplementerPullRequest` refuses head==base), so the
2374
+ // caller falls back to its normal restart/conclude handling.
2375
+ async #tryPublishImplementerPr(record, implementer) {
2376
+ if (record.dryRun || !implementer.spec.clonePath || !this.#mount.githubWrite) {
2377
+ return undefined;
2378
+ }
2379
+ try {
2380
+ const published = await this.#publishImplementerPullRequest(record, implementer);
2381
+ if (published) {
2382
+ this.#increment('implementerPrsPublishedOnExit');
2383
+ this.#logger.info?.('[factory] published PR from implementer clone after it exited without opening one', {
2384
+ issue: record.issue.key,
2385
+ repo: published.repo,
2386
+ prNumber: published.number,
2387
+ });
2388
+ }
2389
+ return published;
2390
+ }
2391
+ catch (error) {
2392
+ this.#increment('exitPrPublishSkipped');
2393
+ this.#logger.warn?.('[factory] could not publish implementer PR on exit; falling back', {
2394
+ issue: record.issue.key,
2395
+ name: implementer.result?.name ?? implementer.spec.name,
2396
+ error: describeError(error).errorMessage,
2397
+ });
2398
+ return undefined;
2399
+ }
2400
+ }
2401
+ async #publishImplementerPullRequest(record, implementer) {
2402
+ const key = `${issueKey(record.issue)}:${implementer.spec.repo}`;
2403
+ if (this.#publishedPullRequests.has(key))
2404
+ return undefined;
2405
+ const githubWrite = this.#mount.githubWrite;
2406
+ if (!githubWrite) {
2407
+ throw new Error('GitHub write path not available on this mount — connect GitHub to your workspace');
2408
+ }
2409
+ if (!implementer.spec.clonePath) {
2410
+ throw new Error(`GitHub PR publication requires a configured clone path for ${implementer.spec.repo}`);
2411
+ }
2412
+ const issue = await this.#readIssue(record.issue.path);
2413
+ if (!issue) {
2414
+ throw new Error(`Unable to publish GitHub PR: issue ${record.issue.key} is no longer readable`);
2415
+ }
2416
+ const sourceRepo = githubMirrorRepoForIssue(issue);
2417
+ const sourceRepoParts = sourceRepo ? githubRepoParts(sourceRepo) : undefined;
2418
+ const bareRepoName = implementer.spec.repo.includes('/') ? undefined : implementer.spec.repo;
2419
+ const sourceOwner = bareRepoName && sourceRepoParts?.repo === bareRepoName
2420
+ ? sourceRepoParts.owner
2421
+ : undefined;
2422
+ const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
2423
+ const baseRef = await this.#githubDefaultBranch(repo);
2424
+ const result = await githubWrite.publishPullRequest({
2425
+ repo,
2426
+ clonePath: implementer.spec.clonePath,
2427
+ baseRef,
2428
+ title: `${issue.key}: ${issue.title}`,
2429
+ body: githubPullRequestBody(issue),
2430
+ });
2431
+ this.#publishedPullRequests.add(key);
2432
+ this.#increment('githubPullRequestsPublished');
2433
+ this.#logger.info?.('[factory] published PR through workspace GitHub connection', {
2434
+ issue: issue.key,
2435
+ repo: result.repo,
2436
+ prNumber: result.number,
2437
+ url: result.url,
2438
+ });
2439
+ return result;
2440
+ }
2441
+ async #githubDefaultBranch(repo) {
2442
+ const parts = githubRepoParts(repo);
2443
+ if (!parts) {
2444
+ throw new Error(`GitHub repo must be owner/repo before resolving its default branch: ${repo}`);
2445
+ }
2446
+ const path = `/github/repos/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}/meta.json`;
2447
+ let payload;
2448
+ try {
2449
+ payload = wrappedPayload((await this.#mount.readFile(path)).content);
2450
+ }
2451
+ catch (error) {
2452
+ throw new Error(`Unable to resolve the default branch for ${repo} from ${path}: ${describeError(error).errorMessage}`);
2453
+ }
2454
+ const repository = asRecord(payload.repository);
2455
+ const defaultBranch = (stringValue(payload.defaultBranch) ??
2456
+ stringValue(payload.default_branch) ??
2457
+ stringValue(repository?.defaultBranch) ??
2458
+ stringValue(repository?.default_branch))?.trim();
2459
+ if (!defaultBranch) {
2460
+ throw new Error(`GitHub repository metadata for ${repo} does not include a default branch`);
2461
+ }
2462
+ return defaultBranch;
2463
+ }
2294
2464
  // An implementer that exited, was resumed once, and STILL produced no PR is
2295
2465
  // not making progress. Surface it (counter + a best-effort Slack note to the
2296
2466
  // dispatch thread) so a human can step in, instead of the issue sitting
@@ -2312,6 +2482,75 @@ export class FactoryLoop {
2312
2482
  this.#logger.warn?.('[factory] failed to post stalled-issue escalation to Slack', error);
2313
2483
  }
2314
2484
  }
2485
+ // An implementer that collided on its leaked broker name, or that exited a
2486
+ // second time with no PR, is terminal: it will never send the completion DM
2487
+ // the reviewer is blocked on (its prompt is literally "Wait for a DM from the
2488
+ // implementer(s)"). Left alone, the reviewer stays live forever and the
2489
+ // owned-broker dispose-wait stalls until FACTORY_AGENT_EXIT_TIMEOUT_MS
2490
+ // (default 30 min), so the issue never reaches human-review (#67). Signal any
2491
+ // waiting reviewer(s) so they can proceed, then resolve the dispatch
2492
+ // deterministically: complete to the terminal state when a PR is now visible
2493
+ // (mount lag at exit time is why we fell through to resume in the first
2494
+ // place), otherwise escalate and tear down the stuck agents so dispose drains.
2495
+ async #concludeTerminalImplementer(record, implementerName, reason) {
2496
+ await this.#signalReviewersImplementerDone(record, implementerName, reason);
2497
+ if (await this.#issueHasCompletionPr(record)) {
2498
+ await this.#completeIssue(record);
2499
+ return;
2500
+ }
2501
+ await this.#escalateStalledIssue(record, implementerName);
2502
+ await this.#abandonStuckDispatch(record, reason);
2503
+ }
2504
+ // Deliver a synthetic "implementer done" DM so a reviewer blocked on the
2505
+ // implementer's message unblocks and proceeds (review the PR if one is open,
2506
+ // otherwise conclude). Best-effort: the deterministic teardown below is the
2507
+ // backstop when the reviewer cannot act on it.
2508
+ async #signalReviewersImplementerDone(record, implementerName, reason) {
2509
+ const reviewers = new Set([...record.agents.values()]
2510
+ .filter((agent) => agent.spec.role === 'reviewer')
2511
+ .map((agent) => agent.result?.name ?? agent.spec.name)
2512
+ .filter((name) => Boolean(name)));
2513
+ for (const reviewer of reviewers) {
2514
+ try {
2515
+ await this.#fleet.sendMessage({
2516
+ to: reviewer,
2517
+ from: implementerName,
2518
+ text: `[factory] implementer ${implementerName} has terminated (${reason}) and will send no further messages. If a pull request is open for this issue, review it now; otherwise conclude your review and DM \`broker\` that you are done.`,
2519
+ });
2520
+ this.#increment('reviewerImplementerTerminatedSignals');
2521
+ }
2522
+ catch (error) {
2523
+ this.#logger.warn?.('[factory] failed to signal reviewer of implementer termination', {
2524
+ issue: record.issue.key,
2525
+ reviewer,
2526
+ error,
2527
+ });
2528
+ }
2529
+ }
2530
+ }
2531
+ // Tear down agents still live after a terminal implementer produced no PR —
2532
+ // chiefly the reviewer, blocked on a DM that will never arrive — and free the
2533
+ // batch slot, so the owned-broker dispose-wait drains instead of stalling for
2534
+ // the full agent-exit timeout. Marking each agent terminal first suppresses
2535
+ // the release-driven exit event so it cannot re-trigger a resume before the
2536
+ // record leaves the batch.
2537
+ async #abandonStuckDispatch(record, reason) {
2538
+ const remaining = [...record.agents].filter(([, tracked]) => tracked.spec.role !== 'implementer');
2539
+ for (const [agentName] of remaining) {
2540
+ this.#fleet.markAgentTerminal?.(agentName, `implementer-terminal:${reason}`);
2541
+ }
2542
+ if (remaining.length > 0) {
2543
+ await this.#releaseAndTerminateAgents(remaining, 'issue-abandoned', 'completion');
2544
+ }
2545
+ await this.#recordDispatchTerminal(record.issue);
2546
+ const next = (await this.#batch()).complete(record.issue);
2547
+ await this.#stopSlackWatcher(record.issue);
2548
+ await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
2549
+ await this.#writeInFlightRegistry();
2550
+ if (next) {
2551
+ await this.dispatch(next.decision, { dryRun: next.dryRun });
2552
+ }
2553
+ }
2315
2554
  async #issueHasCompletionPr(record) {
2316
2555
  try {
2317
2556
  const issue = await this.#readIssue(record.issue.path);
@@ -3410,6 +3649,10 @@ export class FactoryLoop {
3410
3649
  this.#probePrResolvedCache.delete(completionKey);
3411
3650
  this.#babysitterSpawned.delete(record.issue.key);
3412
3651
  this.#babysitterPr.delete(record.issue.key);
3652
+ for (const publishedKey of this.#publishedPullRequests) {
3653
+ if (publishedKey.startsWith(`${record.issue.key}:`))
3654
+ this.#publishedPullRequests.delete(publishedKey);
3655
+ }
3413
3656
  }
3414
3657
  }
3415
3658
  #emit(event, payload) {
@@ -4261,6 +4504,7 @@ export class FactoryLoop {
4261
4504
  prNumber: probe.prNumber,
4262
4505
  expectedIssueKey: issue.key,
4263
4506
  requireTitleMarker: false,
4507
+ ...(this.#mount.githubWrite ? { githubWrite: this.#mount.githubWrite } : {}),
4264
4508
  });
4265
4509
  this.#increment('mergeGateSyntheticClosed');
4266
4510
  }
@@ -5310,8 +5554,12 @@ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
5310
5554
  if (/^\/slack\/channels\/[^/]+\/messages\/.+/u.test(path)) {
5311
5555
  return true;
5312
5556
  }
5557
+ if (isFactoryGithubWritebackPath(path)) {
5558
+ return true;
5559
+ }
5313
5560
  return false;
5314
5561
  };
5562
+ const isFactoryGithubWritebackPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/refs%2Fheads%2F[^/]+\.json|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
5315
5563
  const isIssuePathInFactoryScope = async (mount, path, config) => {
5316
5564
  try {
5317
5565
  return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
@@ -5469,6 +5717,22 @@ const labelName = (value) => {
5469
5717
  return stringValue(record?.name);
5470
5718
  };
5471
5719
  const isCompletionReason = (reason) => reason === 'issue-done' || reason === 'done' || reason === 'completed';
5720
+ const normalizeGithubRepo = (repo, defaultOwner) => {
5721
+ if (repo.includes('/'))
5722
+ return repo;
5723
+ const owner = defaultOwner?.trim();
5724
+ if (!owner) {
5725
+ throw new Error(`GitHub repository owner is required for bare repo route: ${repo}; set repos.org or use owner/repo`);
5726
+ }
5727
+ return `${owner}/${repo}`;
5728
+ };
5729
+ const githubPullRequestBody = (issue) => [
5730
+ issue.description,
5731
+ '',
5732
+ isGithubIssue(issue) && /^\d+$/u.test(issue.key)
5733
+ ? `Fixes #${issue.key}`
5734
+ : `Factory issue ${issue.key}`,
5735
+ ].join('\n').trim();
5472
5736
  // The broker rejects re-registering a name it never released on exit
5473
5737
  // (relay#1116-family) with a 500 "agent '<name>' already exists". Detect it from
5474
5738
  // the structured payload or the message so resume can treat it as terminal