@agent-relay/factory 0.1.7 → 0.1.9

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 (35) hide show
  1. package/README.md +7 -0
  2. package/dist/cli/fleet.js +2 -2
  3. package/dist/cli/fleet.js.map +1 -1
  4. package/dist/fleet/create-fleet.d.ts +1 -0
  5. package/dist/fleet/create-fleet.d.ts.map +1 -1
  6. package/dist/fleet/create-fleet.js +1 -0
  7. package/dist/fleet/create-fleet.js.map +1 -1
  8. package/dist/fleet/ensure-relay-broker.d.ts +2 -0
  9. package/dist/fleet/ensure-relay-broker.d.ts.map +1 -1
  10. package/dist/fleet/ensure-relay-broker.js +8 -12
  11. package/dist/fleet/ensure-relay-broker.js.map +1 -1
  12. package/dist/fleet/internal-fleet-client.d.ts +2 -1
  13. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  14. package/dist/fleet/internal-fleet-client.js +13 -14
  15. package/dist/fleet/internal-fleet-client.js.map +1 -1
  16. package/dist/fleet/relay-workspace-key.d.ts +8 -0
  17. package/dist/fleet/relay-workspace-key.d.ts.map +1 -0
  18. package/dist/fleet/relay-workspace-key.js +18 -0
  19. package/dist/fleet/relay-workspace-key.js.map +1 -0
  20. package/dist/orchestrator/factory.d.ts.map +1 -1
  21. package/dist/orchestrator/factory.js +211 -35
  22. package/dist/orchestrator/factory.js.map +1 -1
  23. package/dist/writeback/index.d.ts +1 -1
  24. package/dist/writeback/index.d.ts.map +1 -1
  25. package/dist/writeback/index.js +1 -1
  26. package/dist/writeback/index.js.map +1 -1
  27. package/dist/writeback/linear.d.ts +2 -0
  28. package/dist/writeback/linear.d.ts.map +1 -1
  29. package/dist/writeback/linear.js +17 -10
  30. package/dist/writeback/linear.js.map +1 -1
  31. package/dist/writeback/slack.d.ts +2 -0
  32. package/dist/writeback/slack.d.ts.map +1 -1
  33. package/dist/writeback/slack.js +5 -5
  34. package/dist/writeback/slack.js.map +1 -1
  35. package/package.json +1 -1
@@ -10,7 +10,7 @@ import { isInFactoryScope } from '../safety/factory-scope.js';
10
10
  import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
11
11
  import { renderAgentTask } from '../dispatch/templates.js';
12
12
  import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
13
- import { MountGithubRead, MountLinearWriteback, MountSlackWriteback } from '../writeback/index.js';
13
+ import { MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
14
14
  import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
15
15
  import { issueKey } from './batch-tracker.js';
16
16
  import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
@@ -98,6 +98,8 @@ export class FactoryLoop {
98
98
  #resumeInFlight = new Map();
99
99
  #slackWatchers = new Map();
100
100
  #slackWatcherStarts = new Map();
101
+ #resolvedSlackChannelDir;
102
+ #slackChannelDirRefresh;
101
103
  // Agents we've already logged an ambiguous-PID-lookup warning for, so the
102
104
  // reaper doesn't spam the same benign "ambiguous process lookup" line on every
103
105
  // poll (a joined/cloud agent has no local PID to resolve — expected).
@@ -1137,7 +1139,7 @@ export class FactoryLoop {
1137
1139
  const escalationReason = triageEscalationReason(decision);
1138
1140
  if (escalationReason) {
1139
1141
  await this.#escalateTriageToSlack(decision, escalationReason, dryRun);
1140
- this.#error(new Error(`${escalationReason}; escalation required`), decision.issue);
1142
+ this.#recordTriageEscalation(decision, escalationReason);
1141
1143
  return { issue: decision.issue, agents: [], dryRun };
1142
1144
  }
1143
1145
  // TODO(AR-274 follow-up): short-circuit LLM triage once label-derived
@@ -1326,7 +1328,7 @@ export class FactoryLoop {
1326
1328
  const escalationReason = triageEscalationReason(decision);
1327
1329
  if (escalationReason) {
1328
1330
  await this.#escalateTriageToSlack(decision, escalationReason, this.#config.dryRun);
1329
- this.#error(new Error(`${escalationReason}; escalation required`), decision.issue);
1331
+ this.#recordTriageEscalation(decision, escalationReason);
1330
1332
  return;
1331
1333
  }
1332
1334
  if (batch.canStart()) {
@@ -2513,12 +2515,15 @@ export class FactoryLoop {
2513
2515
  });
2514
2516
  if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('merge-done-thread')) {
2515
2517
  try {
2516
- const root = await this.#slack.postThread({
2517
- channel: this.#config.slack.channel,
2518
- text: `${issue.key}: PR merged; Linear state set to Done.`,
2519
- });
2520
- await this.#slack.reply(root.threadId, `${issue.key}: Linear state set to Done.`);
2521
- this.#recordSlackWritebackSuccess('merge-done-thread');
2518
+ const channel = await this.#slackChannelDir();
2519
+ if (channel) {
2520
+ const root = await this.#slack.postThread({
2521
+ channel,
2522
+ text: `${issue.key}: PR merged; Linear state set to Done.`,
2523
+ });
2524
+ await this.#slack.reply(root.threadId, `${issue.key}: Linear state set to Done.`);
2525
+ this.#recordSlackWritebackSuccess('merge-done-thread');
2526
+ }
2522
2527
  }
2523
2528
  catch (error) {
2524
2529
  this.#markSlackWritebackFailure('merge-done-thread', error);
@@ -2752,21 +2757,24 @@ export class FactoryLoop {
2752
2757
  }
2753
2758
  if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('completion-thread')) {
2754
2759
  try {
2755
- const merged = opts.completionReason === 'pr-merged';
2756
- const completionText = merged
2757
- ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
2758
- : `${record.issue.key}: factory agents completed${humanReview ? '; awaiting human review' : ''}.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}`;
2759
- const stateText = merged
2760
- ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
2761
- : humanReview
2762
- ? `${record.issue.key}: awaiting human review; Linear state set to ${statusLabel}.`
2763
- : `${record.issue.key}: Linear state set to ${statusLabel}.`;
2764
- const root = await this.#slack.postThread({
2765
- channel: this.#config.slack.channel,
2766
- text: completionText,
2767
- });
2768
- await this.#slack.reply(root.threadId, stateText);
2769
- this.#recordSlackWritebackSuccess('completion-thread');
2760
+ const channel = await this.#slackChannelDir();
2761
+ if (channel) {
2762
+ const merged = opts.completionReason === 'pr-merged';
2763
+ const completionText = merged
2764
+ ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
2765
+ : `${record.issue.key}: factory agents completed${humanReview ? '; awaiting human review' : ''}.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}`;
2766
+ const stateText = merged
2767
+ ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
2768
+ : humanReview
2769
+ ? `${record.issue.key}: awaiting human review; Linear state set to ${statusLabel}.`
2770
+ : `${record.issue.key}: Linear state set to ${statusLabel}.`;
2771
+ const root = await this.#slack.postThread({
2772
+ channel,
2773
+ text: completionText,
2774
+ });
2775
+ await this.#slack.reply(root.threadId, stateText);
2776
+ this.#recordSlackWritebackSuccess('completion-thread');
2777
+ }
2770
2778
  }
2771
2779
  catch (error) {
2772
2780
  this.#markSlackWritebackFailure('completion-thread', error);
@@ -2813,6 +2821,13 @@ export class FactoryLoop {
2813
2821
  #increment(name) {
2814
2822
  this.#counters[name] = (this.#counters[name] ?? 0) + 1;
2815
2823
  }
2824
+ #recordTriageEscalation(decision, reason) {
2825
+ this.#increment('triageEscalations');
2826
+ this.#logger.warn?.('[factory] triage escalation required', {
2827
+ issue: decision.issue,
2828
+ reason,
2829
+ });
2830
+ }
2816
2831
  async #shouldSkipSlackWriteback(context) {
2817
2832
  if (!this.#config.slack)
2818
2833
  return false;
@@ -3012,7 +3027,7 @@ export class FactoryLoop {
3012
3027
  return;
3013
3028
  }
3014
3029
  const root = await this.#slack.postThread({
3015
- channel: this.#config.slack.channel,
3030
+ channel: await this.#slackChannelDir() ?? this.#config.slack.channel,
3016
3031
  text: [
3017
3032
  `${record.issue.key}: factory agents dispatched.`,
3018
3033
  `State: ${result.stateId ?? 'dispatching'}`,
@@ -3073,11 +3088,11 @@ export class FactoryLoop {
3073
3088
  }
3074
3089
  const issue = await this.#readIssue(decision.issue.path);
3075
3090
  const root = await this.#slack.postThread({
3076
- channel: this.#config.slack.channel,
3091
+ channel: await this.#slackChannelDir() ?? this.#config.slack.channel,
3077
3092
  text: [
3078
3093
  `${decision.issue.key}: factory triage escalation for ${issue?.title ?? decision.issue.key}`,
3079
3094
  `Reason: ${reason}`,
3080
- `Question: Please clarify the intended repo/approach or add enough acceptance detail for the factory agent to proceed.`,
3095
+ `Question: ${triageEscalationQuestion(decision)}`,
3081
3096
  ].join('\n'),
3082
3097
  });
3083
3098
  await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
@@ -3092,7 +3107,7 @@ export class FactoryLoop {
3092
3107
  if (this.#slackWatchers.has(key)) {
3093
3108
  return;
3094
3109
  }
3095
- const channelDir = this.#config.slack.channel;
3110
+ const channelDir = await this.#slackChannelDir() ?? this.#config.slack.channel;
3096
3111
  const messagesPrefix = slackChannelMessagesPrefix(channelDir);
3097
3112
  const preExistingPaths = new Set();
3098
3113
  const seenReplies = new Set();
@@ -3268,17 +3283,24 @@ export class FactoryLoop {
3268
3283
  }
3269
3284
  }
3270
3285
  async #routeSlackAnswerToImplementers(record, reply) {
3271
- if (!this.#config.slack || !this.#fleet.sendInput) {
3286
+ if (!this.#config.slack) {
3287
+ return;
3288
+ }
3289
+ const text = reply.text.trim();
3290
+ if (!text) {
3291
+ this.#increment('slackAnswersIgnoredEmpty');
3272
3292
  return;
3273
3293
  }
3274
3294
  const liveRecord = (await this.#batch()).getIssue(record.issue);
3275
3295
  if (!liveRecord || liveRecord.dryRun) {
3296
+ if (isTriageEscalationWatchRecord(record)) {
3297
+ await this.#handleTriageEscalationSlackAnswer(record, text);
3298
+ return;
3299
+ }
3276
3300
  this.#increment('slackAnswersIgnoredNoInFlight');
3277
3301
  return;
3278
3302
  }
3279
- const text = reply.text.trim();
3280
- if (!text) {
3281
- this.#increment('slackAnswersIgnoredEmpty');
3303
+ if (!this.#fleet.sendInput) {
3282
3304
  return;
3283
3305
  }
3284
3306
  // Route human replies to the implementers AND the babysitter — once a PR is
@@ -3296,6 +3318,49 @@ export class FactoryLoop {
3296
3318
  this.#increment('slackAnswersInjected');
3297
3319
  }
3298
3320
  }
3321
+ async #handleTriageEscalationSlackAnswer(record, text) {
3322
+ const issue = await this.#readIssue(record.issue.path);
3323
+ if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isRealLinearIssue(issue)) {
3324
+ this.#increment('slackTriageAnswersIgnoredIssueUnavailable');
3325
+ return;
3326
+ }
3327
+ if (!this.#states.isRole(issue.stateId, 'readyForAgent')) {
3328
+ this.#increment('slackTriageAnswersIgnoredIssueNotReady');
3329
+ return;
3330
+ }
3331
+ const batch = await this.#batch();
3332
+ if (batch.isInFlight(record.issue) || batch.isQueued(record.issue)) {
3333
+ this.#increment('slackTriageAnswersIgnoredAlreadyActive');
3334
+ return;
3335
+ }
3336
+ if (await this.#dispatchBlockReason(record.issue)) {
3337
+ this.#increment('slackTriageAnswersIgnoredBlocked');
3338
+ return;
3339
+ }
3340
+ const clarifiedIssue = issueWithSlackClarification(issue, text);
3341
+ const decision = await this.#triage.triage(clarifiedIssue, {
3342
+ config: this.#config,
3343
+ repoMap: repoMapFromConfig(this.#config),
3344
+ });
3345
+ const escalationReason = triageEscalationReason(decision);
3346
+ if (escalationReason) {
3347
+ this.#increment('slackTriageAnswersStillEscalated');
3348
+ this.#logger.warn?.('[factory] Slack triage answer still leaves issue escalated', {
3349
+ issue: record.issue,
3350
+ reason: escalationReason,
3351
+ });
3352
+ return;
3353
+ }
3354
+ if (batch.canStart()) {
3355
+ await this.dispatch(decision, { dryRun: this.#config.dryRun });
3356
+ this.#increment('slackTriageAnswersDispatched');
3357
+ return;
3358
+ }
3359
+ if (batch.queue(decision, this.#config.dryRun)) {
3360
+ this.#increment('slackTriageAnswersQueued');
3361
+ this.#emit('issue-queued', { issue: decision.issue });
3362
+ }
3363
+ }
3299
3364
  // Inject the human's Slack reply into the agent framed as the
3300
3365
  // <integration-event> the spawn prompt tells it to expect (not an ambiguous
3301
3366
  // "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
@@ -3311,13 +3376,78 @@ export class FactoryLoop {
3311
3376
  #integrationsMountRoot() {
3312
3377
  return resolve(process.cwd(), '.integrations');
3313
3378
  }
3379
+ async #slackChannelDir() {
3380
+ if (!this.#config.slack) {
3381
+ return undefined;
3382
+ }
3383
+ if (this.#resolvedSlackChannelDir) {
3384
+ return this.#resolvedSlackChannelDir;
3385
+ }
3386
+ if (this.#slackChannelDirRefresh) {
3387
+ return this.#slackChannelDirRefresh;
3388
+ }
3389
+ this.#slackChannelDirRefresh = this.#resolveSlackChannelDir()
3390
+ .finally(() => {
3391
+ this.#slackChannelDirRefresh = undefined;
3392
+ });
3393
+ return this.#slackChannelDirRefresh;
3394
+ }
3395
+ async #resolveSlackChannelDir() {
3396
+ const configured = this.#config.slack?.channel.trim();
3397
+ if (!configured) {
3398
+ return undefined;
3399
+ }
3400
+ const configuredSegment = slackChannelSegment(configured);
3401
+ const configuredAliases = slackChannelAliases(configured);
3402
+ if (configuredAliases.size === 0) {
3403
+ return undefined;
3404
+ }
3405
+ let paths;
3406
+ try {
3407
+ paths = await this.#mount.listTree('/slack/channels');
3408
+ }
3409
+ catch (error) {
3410
+ this.#logger.warn?.('[factory] unable to resolve Slack channel from mount; using configured channel value', {
3411
+ channel: configured,
3412
+ error,
3413
+ });
3414
+ this.#resolvedSlackChannelDir = configuredSegment;
3415
+ return this.#resolvedSlackChannelDir;
3416
+ }
3417
+ const channelDirs = [...new Set(paths
3418
+ .map((path) => path.match(/^\/slack\/channels\/([^/]+)/u)?.[1])
3419
+ .filter((channelDir) => Boolean(channelDir)))]
3420
+ .sort();
3421
+ const matches = channelDirs.filter((channelDir) => {
3422
+ const aliases = slackChannelAliases(channelDir);
3423
+ return [...aliases].some((alias) => configuredAliases.has(alias));
3424
+ });
3425
+ if (matches.length === 0) {
3426
+ this.#logger.warn?.('[factory] Slack channel was not found in mount; using configured channel value', {
3427
+ channel: configured,
3428
+ });
3429
+ this.#resolvedSlackChannelDir = configuredSegment;
3430
+ return this.#resolvedSlackChannelDir;
3431
+ }
3432
+ const exact = matches.find((channelDir) => slackChannelSegment(channelDir).toLowerCase() === configuredSegment.toLowerCase());
3433
+ this.#resolvedSlackChannelDir = exact ?? matches[0];
3434
+ if (matches.length > 1 && !exact) {
3435
+ this.#logger.warn?.('[factory] Slack channel name matched multiple mount directories; using first match', {
3436
+ channel: configured,
3437
+ selected: this.#resolvedSlackChannelDir,
3438
+ matches,
3439
+ });
3440
+ }
3441
+ return this.#resolvedSlackChannelDir;
3442
+ }
3314
3443
  async #slackDispatchThreadFor(record) {
3315
3444
  if (!this.#config.slack) {
3316
3445
  return undefined;
3317
3446
  }
3318
3447
  const threadId = await this.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
3448
+ const channel = await this.#slackChannelDir() ?? this.#config.slack.channel;
3319
3449
  return threadId
3320
- ? { channel: this.#config.slack.channel, threadId, mountRoot: this.#integrationsMountRoot() }
3450
+ ? { channel, threadId, mountRoot: this.#integrationsMountRoot() }
3321
3451
  : undefined;
3322
3452
  }
3323
3453
  async #runCompletionMergeGate(issue) {
@@ -3701,8 +3831,25 @@ function githubMirrorRepoForIssue(issue) {
3701
3831
  return githubRepoFromUrl(sourceUrlLine?.slice(GITHUB_MIRROR_SOURCE_PREFIX.length));
3702
3832
  }
3703
3833
  function githubRepoFromUrl(url) {
3704
- const match = url?.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+(?:[/?#].*)?$/iu);
3705
- return match?.[1] && match[2] ? `${match[1]}/${match[2]}` : undefined;
3834
+ for (const candidate of githubUrlCandidates(url)) {
3835
+ const match = candidate.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+(?:[/?#].*)?$/iu);
3836
+ if (match?.[1] && match[2]) {
3837
+ return `${match[1]}/${match[2]}`;
3838
+ }
3839
+ }
3840
+ return undefined;
3841
+ }
3842
+ function githubUrlCandidates(value) {
3843
+ if (!value) {
3844
+ return [];
3845
+ }
3846
+ const candidates = new Set();
3847
+ const trimmed = value.trim();
3848
+ candidates.add(trimmed.replace(/^<|>$/gu, ''));
3849
+ for (const match of trimmed.matchAll(/https:\/\/github\.com\/[^\s<>)\]]+/giu)) {
3850
+ candidates.add(match[0].replace(/[),.;]+$/gu, ''));
3851
+ }
3852
+ return [...candidates];
3706
3853
  }
3707
3854
  function labelRoutesForIssue(issue, config) {
3708
3855
  const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label));
@@ -4558,6 +4705,35 @@ const triageEscalationReason = (decision) => {
4558
4705
  }
4559
4706
  return `${reasons.join(' and ')}${decision.rationale ? `: ${decision.rationale}` : ''}`;
4560
4707
  };
4708
+ const triageEscalationQuestion = (decision) => {
4709
+ const routedRepos = decision.routes.map((route) => route.repo).filter(Boolean);
4710
+ if (routedRepos.length === 0) {
4711
+ return [
4712
+ 'Which repository or repositories should handle this issue?',
4713
+ 'Please include the intended approach and the acceptance criteria/tests the agent should satisfy.',
4714
+ ].join(' ');
4715
+ }
4716
+ if (decision.thin) {
4717
+ return [
4718
+ `Factory matched ${routedRepos.join(', ')}.`,
4719
+ 'Please clarify the concrete expected behavior, constraints, and acceptance criteria/tests before dispatch.',
4720
+ ].join(' ');
4721
+ }
4722
+ return [
4723
+ `Factory matched ${routedRepos.join(', ')}, but triage confidence is low.`,
4724
+ 'Please confirm the intended repo/approach or correct the route before dispatch.',
4725
+ ].join(' ');
4726
+ };
4727
+ const isTriageEscalationWatchRecord = (record) => record.agents.size === 0 && record.invocationIds.size === 0 && triageEscalationReason(record.decision) !== undefined;
4728
+ const issueWithSlackClarification = (issue, text) => ({
4729
+ ...issue,
4730
+ description: [
4731
+ issue.description,
4732
+ '',
4733
+ 'Human clarification from Slack:',
4734
+ text,
4735
+ ].join('\n'),
4736
+ });
4561
4737
  const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
4562
4738
  // The human's Slack-thread reply, framed as an <integration-event> the agent is
4563
4739
  // told (at spawn) to expect — a recognizable injected event, not an ambiguous