@agent-relay/factory 0.1.7 → 0.1.8

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 +118 -28
  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).
@@ -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);
@@ -3012,7 +3020,7 @@ export class FactoryLoop {
3012
3020
  return;
3013
3021
  }
3014
3022
  const root = await this.#slack.postThread({
3015
- channel: this.#config.slack.channel,
3023
+ channel: await this.#slackChannelDir() ?? this.#config.slack.channel,
3016
3024
  text: [
3017
3025
  `${record.issue.key}: factory agents dispatched.`,
3018
3026
  `State: ${result.stateId ?? 'dispatching'}`,
@@ -3073,7 +3081,7 @@ export class FactoryLoop {
3073
3081
  }
3074
3082
  const issue = await this.#readIssue(decision.issue.path);
3075
3083
  const root = await this.#slack.postThread({
3076
- channel: this.#config.slack.channel,
3084
+ channel: await this.#slackChannelDir() ?? this.#config.slack.channel,
3077
3085
  text: [
3078
3086
  `${decision.issue.key}: factory triage escalation for ${issue?.title ?? decision.issue.key}`,
3079
3087
  `Reason: ${reason}`,
@@ -3092,7 +3100,7 @@ export class FactoryLoop {
3092
3100
  if (this.#slackWatchers.has(key)) {
3093
3101
  return;
3094
3102
  }
3095
- const channelDir = this.#config.slack.channel;
3103
+ const channelDir = await this.#slackChannelDir() ?? this.#config.slack.channel;
3096
3104
  const messagesPrefix = slackChannelMessagesPrefix(channelDir);
3097
3105
  const preExistingPaths = new Set();
3098
3106
  const seenReplies = new Set();
@@ -3311,13 +3319,78 @@ export class FactoryLoop {
3311
3319
  #integrationsMountRoot() {
3312
3320
  return resolve(process.cwd(), '.integrations');
3313
3321
  }
3322
+ async #slackChannelDir() {
3323
+ if (!this.#config.slack) {
3324
+ return undefined;
3325
+ }
3326
+ if (this.#resolvedSlackChannelDir) {
3327
+ return this.#resolvedSlackChannelDir;
3328
+ }
3329
+ if (this.#slackChannelDirRefresh) {
3330
+ return this.#slackChannelDirRefresh;
3331
+ }
3332
+ this.#slackChannelDirRefresh = this.#resolveSlackChannelDir()
3333
+ .finally(() => {
3334
+ this.#slackChannelDirRefresh = undefined;
3335
+ });
3336
+ return this.#slackChannelDirRefresh;
3337
+ }
3338
+ async #resolveSlackChannelDir() {
3339
+ const configured = this.#config.slack?.channel.trim();
3340
+ if (!configured) {
3341
+ return undefined;
3342
+ }
3343
+ const configuredSegment = slackChannelSegment(configured);
3344
+ const configuredAliases = slackChannelAliases(configured);
3345
+ if (configuredAliases.size === 0) {
3346
+ return undefined;
3347
+ }
3348
+ let paths;
3349
+ try {
3350
+ paths = await this.#mount.listTree('/slack/channels');
3351
+ }
3352
+ catch (error) {
3353
+ this.#logger.warn?.('[factory] unable to resolve Slack channel from mount; using configured channel value', {
3354
+ channel: configured,
3355
+ error,
3356
+ });
3357
+ this.#resolvedSlackChannelDir = configuredSegment;
3358
+ return this.#resolvedSlackChannelDir;
3359
+ }
3360
+ const channelDirs = [...new Set(paths
3361
+ .map((path) => path.match(/^\/slack\/channels\/([^/]+)/u)?.[1])
3362
+ .filter((channelDir) => Boolean(channelDir)))]
3363
+ .sort();
3364
+ const matches = channelDirs.filter((channelDir) => {
3365
+ const aliases = slackChannelAliases(channelDir);
3366
+ return [...aliases].some((alias) => configuredAliases.has(alias));
3367
+ });
3368
+ if (matches.length === 0) {
3369
+ this.#logger.warn?.('[factory] Slack channel was not found in mount; using configured channel value', {
3370
+ channel: configured,
3371
+ });
3372
+ this.#resolvedSlackChannelDir = configuredSegment;
3373
+ return this.#resolvedSlackChannelDir;
3374
+ }
3375
+ const exact = matches.find((channelDir) => slackChannelSegment(channelDir).toLowerCase() === configuredSegment.toLowerCase());
3376
+ this.#resolvedSlackChannelDir = exact ?? matches[0];
3377
+ if (matches.length > 1 && !exact) {
3378
+ this.#logger.warn?.('[factory] Slack channel name matched multiple mount directories; using first match', {
3379
+ channel: configured,
3380
+ selected: this.#resolvedSlackChannelDir,
3381
+ matches,
3382
+ });
3383
+ }
3384
+ return this.#resolvedSlackChannelDir;
3385
+ }
3314
3386
  async #slackDispatchThreadFor(record) {
3315
3387
  if (!this.#config.slack) {
3316
3388
  return undefined;
3317
3389
  }
3318
3390
  const threadId = await this.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
3391
+ const channel = await this.#slackChannelDir() ?? this.#config.slack.channel;
3319
3392
  return threadId
3320
- ? { channel: this.#config.slack.channel, threadId, mountRoot: this.#integrationsMountRoot() }
3393
+ ? { channel, threadId, mountRoot: this.#integrationsMountRoot() }
3321
3394
  : undefined;
3322
3395
  }
3323
3396
  async #runCompletionMergeGate(issue) {
@@ -3701,8 +3774,25 @@ function githubMirrorRepoForIssue(issue) {
3701
3774
  return githubRepoFromUrl(sourceUrlLine?.slice(GITHUB_MIRROR_SOURCE_PREFIX.length));
3702
3775
  }
3703
3776
  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;
3777
+ for (const candidate of githubUrlCandidates(url)) {
3778
+ const match = candidate.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/\d+(?:[/?#].*)?$/iu);
3779
+ if (match?.[1] && match[2]) {
3780
+ return `${match[1]}/${match[2]}`;
3781
+ }
3782
+ }
3783
+ return undefined;
3784
+ }
3785
+ function githubUrlCandidates(value) {
3786
+ if (!value) {
3787
+ return [];
3788
+ }
3789
+ const candidates = new Set();
3790
+ const trimmed = value.trim();
3791
+ candidates.add(trimmed.replace(/^<|>$/gu, ''));
3792
+ for (const match of trimmed.matchAll(/https:\/\/github\.com\/[^\s<>)\]]+/giu)) {
3793
+ candidates.add(match[0].replace(/[),.;]+$/gu, ''));
3794
+ }
3795
+ return [...candidates];
3706
3796
  }
3707
3797
  function labelRoutesForIssue(issue, config) {
3708
3798
  const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label));