@agent-relay/factory 0.1.16 → 0.1.18

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 (38) hide show
  1. package/README.md +36 -10
  2. package/dist/cli/fleet.d.ts +2 -0
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +1 -1
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/fleet/create-fleet.d.ts +2 -0
  7. package/dist/fleet/create-fleet.d.ts.map +1 -1
  8. package/dist/fleet/create-fleet.js +5 -1
  9. package/dist/fleet/create-fleet.js.map +1 -1
  10. package/dist/fleet/internal-fleet-client.d.ts +1 -0
  11. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  12. package/dist/fleet/internal-fleet-client.js +41 -1
  13. package/dist/fleet/internal-fleet-client.js.map +1 -1
  14. package/dist/fleet/relay-fleet-client.d.ts +52 -63
  15. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  16. package/dist/fleet/relay-fleet-client.js +316 -323
  17. package/dist/fleet/relay-fleet-client.js.map +1 -1
  18. package/dist/fleet/relay-workspace-key.d.ts +5 -0
  19. package/dist/fleet/relay-workspace-key.d.ts.map +1 -1
  20. package/dist/fleet/relay-workspace-key.js +7 -0
  21. package/dist/fleet/relay-workspace-key.js.map +1 -1
  22. package/dist/mount/relayfile-cloud-mount-client.js +3 -1
  23. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  24. package/dist/node/factory-node.d.ts.map +1 -1
  25. package/dist/node/factory-node.js +14 -0
  26. package/dist/node/factory-node.js.map +1 -1
  27. package/dist/orchestrator/factory.d.ts.map +1 -1
  28. package/dist/orchestrator/factory.js +203 -22
  29. package/dist/orchestrator/factory.js.map +1 -1
  30. package/dist/ports/fleet.d.ts +12 -0
  31. package/dist/ports/fleet.d.ts.map +1 -1
  32. package/dist/testing/fakes.d.ts +16 -0
  33. package/dist/testing/fakes.d.ts.map +1 -1
  34. package/dist/testing/fakes.js +22 -0
  35. package/dist/testing/fakes.js.map +1 -1
  36. package/dist/types.d.ts +2 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +8 -7
@@ -15,7 +15,7 @@ import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlack
15
15
  import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
16
16
  import { issueKey } from './batch-tracker.js';
17
17
  import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
18
- import { terminatePids } from './reaper.js';
18
+ import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
19
19
  const ISSUE_ROOT = '/linear/issues';
20
20
  const GITHUB_ISSUE_ROOT = '/github/repos';
21
21
  const READY_EVENTS_LIMIT = 100;
@@ -315,6 +315,7 @@ export class FactoryLoop {
315
315
  return;
316
316
  }
317
317
  this.#wireFleetEvents();
318
+ await this.#adoptInFlightAgents();
318
319
  if ((opts.mode ?? 'live') === 'live') {
319
320
  this.#started = true;
320
321
  try {
@@ -1384,6 +1385,28 @@ export class FactoryLoop {
1384
1385
  });
1385
1386
  }
1386
1387
  }
1388
+ // Remote backends survive orchestrator restarts: re-adopt the agents recorded
1389
+ // in the in-flight registry, then reconcile once so exits that happened while
1390
+ // this process was down are handled before any new dispatch.
1391
+ async #adoptInFlightAgents() {
1392
+ if (!this.#fleet.hydrateTracked)
1393
+ return;
1394
+ try {
1395
+ const registry = await readFactoryInFlightRegistry(this.#config.loop.registryPath);
1396
+ const agents = (registry?.agents ?? []).filter((agent) => agent.invocationId || agent.node);
1397
+ if (agents.length > 0) {
1398
+ this.#fleet.hydrateTracked(agents.map((agent) => ({
1399
+ name: agent.name,
1400
+ invocationId: agent.invocationId,
1401
+ node: agent.node,
1402
+ })));
1403
+ }
1404
+ await this.#fleet.reconcileTrackedAgents?.();
1405
+ }
1406
+ catch (error) {
1407
+ this.#logger.warn?.('[factory] failed to re-adopt in-flight agents from the registry', { error });
1408
+ }
1409
+ }
1387
1410
  async #backfillReadyIssues() {
1388
1411
  const page = await this.#mount.getEvents({ limit: READY_EVENTS_LIMIT });
1389
1412
  const allPaths = page.events.map((event) => changeEventPath(event)).filter((p) => Boolean(p));
@@ -2106,6 +2129,7 @@ export class FactoryLoop {
2106
2129
  processes.push({ ...identity, agentName });
2107
2130
  }
2108
2131
  }
2132
+ const fleetTracked = this.#fleet.trackedAgents?.().get(agentName);
2109
2133
  agents.push({
2110
2134
  name: agentName,
2111
2135
  role: tracked.spec.role,
@@ -2113,6 +2137,8 @@ export class FactoryLoop {
2113
2137
  sessionRef: tracked.sessionRef,
2114
2138
  pids,
2115
2139
  processes,
2140
+ ...(fleetTracked?.invocationId ? { invocationId: fleetTracked.invocationId } : {}),
2141
+ ...(fleetTracked?.node ? { node: fleetTracked.node } : {}),
2116
2142
  });
2117
2143
  };
2118
2144
  if (!empty) {
@@ -2245,14 +2271,40 @@ export class FactoryLoop {
2245
2271
  await this.#completeIssue(record);
2246
2272
  return;
2247
2273
  }
2274
+ // The implementer's turn ended without a PR of record. Agents reliably
2275
+ // COMMIT their work to a feature branch but often exit (turn-end / idle)
2276
+ // before running `gh pr create` — the root reason a dispatch never reached
2277
+ // human-review even after the #67 fixes. Rather than respawn a done agent
2278
+ // and hope, factory finalizes the branch into a PR itself (the same
2279
+ // publish path the completion flow uses), then advances to the babysitter
2280
+ // / human-review path. Best-effort: with no publishable branch (no commits
2281
+ // ahead of base, clone gone) it returns undefined and we fall through.
2282
+ if (tracked.spec.role === 'implementer') {
2283
+ const publishedPr = await this.#tryPublishImplementerPr(record, tracked);
2284
+ if (publishedPr) {
2285
+ if (this.#config.babysitter.enabled) {
2286
+ await this.#ensureBabysitter(record, {
2287
+ repo: publishedPr.repo,
2288
+ prNumber: publishedPr.number,
2289
+ url: publishedPr.url,
2290
+ });
2291
+ }
2292
+ else {
2293
+ await this.#completeIssue(record);
2294
+ }
2295
+ return;
2296
+ }
2297
+ }
2248
2298
  if (tracked.sessionRef) {
2249
2299
  const resumeKey = `${issueKey(record.issue)}:${name}:${tracked.sessionRef}`;
2250
2300
  if (await this.#state.isResumed(this.#workspaceId, resumeKey)) {
2251
2301
  // Already resumed once and STILL exiting with no completion PR — the
2252
- // agent isn't making progress. Escalate so a human notices, instead of
2253
- // leaving the issue silently in-flight forever.
2302
+ // agent isn't making progress. Conclude the dispatch so a human
2303
+ // notices AND the reviewer waiting on the implementer's DM is torn
2304
+ // down, instead of leaving the issue silently in-flight forever with
2305
+ // a live reviewer stalling the owned-broker dispose-wait (#67).
2254
2306
  if (tracked.spec.role === 'implementer') {
2255
- await this.#escalateStalledIssue(record, name);
2307
+ await this.#concludeTerminalImplementer(record, name, 'stalled-no-pr');
2256
2308
  }
2257
2309
  return;
2258
2310
  }
@@ -2271,17 +2323,24 @@ export class FactoryLoop {
2271
2323
  if (isAgentAlreadyExistsError(error)) {
2272
2324
  // The broker never released this agent's name on exit
2273
2325
  // (relay#1116-family), so re-registering collides with the stuck
2274
- // name. The error is marked retryable but isn't retrying just
2275
- // re-collides forever. Treat it as terminal for this name: record
2276
- // the resume key so subsequent exit events short-circuit, count it,
2277
- // and warn once instead of spamming a 500 stack trace. The external
2278
- // reaper / a broker restart reclaims the leaked name.
2326
+ // name. Retrying just re-collides forever. Treat it as terminal for
2327
+ // this name: record the resume key so subsequent exit events
2328
+ // short-circuit, count it, and warn once. The external reaper / a
2329
+ // broker restart reclaims the leaked name.
2330
+ this.#fleet.markAgentTerminal?.(name, 'resume-already-exists');
2279
2331
  await this.#state.markResumed(this.#workspaceId, resumeKey);
2280
2332
  this.#increment('resumeNameCollisions');
2281
2333
  this.#logger.warn?.('[factory] resume skipped: broker still holds agent name (relay#1116); not retrying', {
2282
2334
  issue: record.issue.key,
2283
2335
  name,
2284
2336
  });
2337
+ // The implementer is now terminal but never sent the completion DM
2338
+ // the reviewer is blocked on ("Wait for a DM from the
2339
+ // implementer(s)"). Resolve the dispatch so the reviewer does not
2340
+ // stay live forever and stall the owned-broker dispose-wait (#67).
2341
+ if (tracked.spec.role === 'implementer') {
2342
+ await this.#concludeTerminalImplementer(record, name, 'resume-already-exists');
2343
+ }
2285
2344
  }
2286
2345
  else {
2287
2346
  throw error;
@@ -2293,25 +2352,78 @@ export class FactoryLoop {
2293
2352
  }
2294
2353
  else {
2295
2354
  const invocationId = `${batch.invocationIdFor(record.issue, tracked.spec)}:restart:${this.#clock.now()}`;
2296
- const result = await this.#fleet.spawn({
2297
- name: tracked.spec.name,
2298
- capability: tracked.spec.capability,
2299
- node: tracked.spec.node ?? 'self',
2300
- task: tracked.spec.task,
2301
- model: tracked.spec.model,
2302
- cwd: tracked.spec.clonePath,
2303
- sessionRef: tracked.spec.sessionRef,
2304
- invocationId,
2305
- restartPolicy: defaultRestartPolicy(tracked.spec),
2306
- channel: tracked.spec.channel,
2307
- });
2308
- batch.recordSpawn(record, tracked.spec, invocationId, result);
2355
+ try {
2356
+ const result = await this.#fleet.spawn({
2357
+ name: tracked.spec.name,
2358
+ capability: tracked.spec.capability,
2359
+ node: tracked.spec.node ?? 'self',
2360
+ task: tracked.spec.task,
2361
+ model: tracked.spec.model,
2362
+ cwd: tracked.spec.clonePath,
2363
+ sessionRef: tracked.spec.sessionRef,
2364
+ invocationId,
2365
+ restartPolicy: defaultRestartPolicy(tracked.spec),
2366
+ channel: tracked.spec.channel,
2367
+ });
2368
+ batch.recordSpawn(record, tracked.spec, invocationId, result);
2369
+ }
2370
+ catch (error) {
2371
+ if (!isAgentAlreadyExistsError(error)) {
2372
+ throw error;
2373
+ }
2374
+ // Same leaked-broker-name collision as the resume path, on a
2375
+ // no-sessionRef respawn: the broker's own restartPolicy already
2376
+ // re-registered the name (relay#1116-family), so our respawn collides.
2377
+ // Retrying just re-collides forever. Treat it as terminal and resolve
2378
+ // the dispatch so a reviewer blocked on the implementer's DM does not
2379
+ // hang the owned-broker dispose-wait (#67).
2380
+ this.#fleet.markAgentTerminal?.(name, 'respawn-already-exists');
2381
+ this.#increment('resumeNameCollisions');
2382
+ this.#logger.warn?.('[factory] respawn skipped: broker still holds agent name (relay#1116); not retrying', {
2383
+ issue: record.issue.key,
2384
+ name,
2385
+ });
2386
+ if (tracked.spec.role === 'implementer') {
2387
+ await this.#concludeTerminalImplementer(record, name, 'respawn-already-exists');
2388
+ }
2389
+ }
2309
2390
  }
2310
2391
  }
2311
2392
  catch (error) {
2312
2393
  this.#error(error, record.issue);
2313
2394
  }
2314
2395
  }
2396
+ // Publish a PR from the implementer's committed branch when it exited without
2397
+ // opening one. Best-effort and idempotent: returns undefined when there is no
2398
+ // GitHub write path, no clone, or nothing publishable (no branch / no commits
2399
+ // ahead of base — `#publishImplementerPullRequest` refuses head==base), so the
2400
+ // caller falls back to its normal restart/conclude handling.
2401
+ async #tryPublishImplementerPr(record, implementer) {
2402
+ if (record.dryRun || !implementer.spec.clonePath || !this.#mount.githubWrite) {
2403
+ return undefined;
2404
+ }
2405
+ try {
2406
+ const published = await this.#publishImplementerPullRequest(record, implementer);
2407
+ if (published) {
2408
+ this.#increment('implementerPrsPublishedOnExit');
2409
+ this.#logger.info?.('[factory] published PR from implementer clone after it exited without opening one', {
2410
+ issue: record.issue.key,
2411
+ repo: published.repo,
2412
+ prNumber: published.number,
2413
+ });
2414
+ }
2415
+ return published;
2416
+ }
2417
+ catch (error) {
2418
+ this.#increment('exitPrPublishSkipped');
2419
+ this.#logger.warn?.('[factory] could not publish implementer PR on exit; falling back', {
2420
+ issue: record.issue.key,
2421
+ name: implementer.result?.name ?? implementer.spec.name,
2422
+ error: describeError(error).errorMessage,
2423
+ });
2424
+ return undefined;
2425
+ }
2426
+ }
2315
2427
  async #publishImplementerPullRequest(record, implementer) {
2316
2428
  const key = `${issueKey(record.issue)}:${implementer.spec.repo}`;
2317
2429
  if (this.#publishedPullRequests.has(key))
@@ -2396,6 +2508,75 @@ export class FactoryLoop {
2396
2508
  this.#logger.warn?.('[factory] failed to post stalled-issue escalation to Slack', error);
2397
2509
  }
2398
2510
  }
2511
+ // An implementer that collided on its leaked broker name, or that exited a
2512
+ // second time with no PR, is terminal: it will never send the completion DM
2513
+ // the reviewer is blocked on (its prompt is literally "Wait for a DM from the
2514
+ // implementer(s)"). Left alone, the reviewer stays live forever and the
2515
+ // owned-broker dispose-wait stalls until FACTORY_AGENT_EXIT_TIMEOUT_MS
2516
+ // (default 30 min), so the issue never reaches human-review (#67). Signal any
2517
+ // waiting reviewer(s) so they can proceed, then resolve the dispatch
2518
+ // deterministically: complete to the terminal state when a PR is now visible
2519
+ // (mount lag at exit time is why we fell through to resume in the first
2520
+ // place), otherwise escalate and tear down the stuck agents so dispose drains.
2521
+ async #concludeTerminalImplementer(record, implementerName, reason) {
2522
+ await this.#signalReviewersImplementerDone(record, implementerName, reason);
2523
+ if (await this.#issueHasCompletionPr(record)) {
2524
+ await this.#completeIssue(record);
2525
+ return;
2526
+ }
2527
+ await this.#escalateStalledIssue(record, implementerName);
2528
+ await this.#abandonStuckDispatch(record, reason);
2529
+ }
2530
+ // Deliver a synthetic "implementer done" DM so a reviewer blocked on the
2531
+ // implementer's message unblocks and proceeds (review the PR if one is open,
2532
+ // otherwise conclude). Best-effort: the deterministic teardown below is the
2533
+ // backstop when the reviewer cannot act on it.
2534
+ async #signalReviewersImplementerDone(record, implementerName, reason) {
2535
+ const reviewers = new Set([...record.agents.values()]
2536
+ .filter((agent) => agent.spec.role === 'reviewer')
2537
+ .map((agent) => agent.result?.name ?? agent.spec.name)
2538
+ .filter((name) => Boolean(name)));
2539
+ for (const reviewer of reviewers) {
2540
+ try {
2541
+ await this.#fleet.sendMessage({
2542
+ to: reviewer,
2543
+ from: implementerName,
2544
+ 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.`,
2545
+ });
2546
+ this.#increment('reviewerImplementerTerminatedSignals');
2547
+ }
2548
+ catch (error) {
2549
+ this.#logger.warn?.('[factory] failed to signal reviewer of implementer termination', {
2550
+ issue: record.issue.key,
2551
+ reviewer,
2552
+ error,
2553
+ });
2554
+ }
2555
+ }
2556
+ }
2557
+ // Tear down agents still live after a terminal implementer produced no PR —
2558
+ // chiefly the reviewer, blocked on a DM that will never arrive — and free the
2559
+ // batch slot, so the owned-broker dispose-wait drains instead of stalling for
2560
+ // the full agent-exit timeout. Marking each agent terminal first suppresses
2561
+ // the release-driven exit event so it cannot re-trigger a resume before the
2562
+ // record leaves the batch.
2563
+ async #abandonStuckDispatch(record, reason) {
2564
+ const remaining = [...record.agents].filter(([, tracked]) => tracked.spec.role !== 'implementer');
2565
+ for (const [agentName] of remaining) {
2566
+ this.#fleet.markAgentTerminal?.(agentName, `implementer-terminal:${reason}`);
2567
+ }
2568
+ if (remaining.length > 0) {
2569
+ await this.#releaseAndTerminateAgents(remaining, 'issue-abandoned', 'completion');
2570
+ }
2571
+ await this.#recordDispatchTerminal(record.issue);
2572
+ const next = (await this.#batch()).complete(record.issue);
2573
+ await this.#stopSlackWatcher(record.issue);
2574
+ await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
2575
+ await this.#writeInFlightRegistry();
2576
+ if (next) {
2577
+ await this.dispatch(next.decision, { dryRun: next.dryRun });
2578
+ }
2579
+ }
2399
2580
  async #issueHasCompletionPr(record) {
2400
2581
  try {
2401
2582
  const issue = await this.#readIssue(record.issue.path);