@agent-relay/factory 0.1.1 → 0.1.3
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.
- package/README.md +145 -205
- package/dist/cli/fleet.d.ts +5 -1
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +152 -27
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +4 -4
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +13 -2
- package/dist/config/schema.js.map +1 -1
- package/dist/constants/linear.d.ts +2 -0
- package/dist/constants/linear.d.ts.map +1 -1
- package/dist/constants/linear.js +6 -0
- package/dist/constants/linear.js.map +1 -1
- package/dist/dispatch/templates.d.ts +18 -2
- package/dist/dispatch/templates.d.ts.map +1 -1
- package/dist/dispatch/templates.js +18 -10
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/fleet/create-fleet.d.ts +3 -2
- package/dist/fleet/create-fleet.d.ts.map +1 -1
- package/dist/fleet/create-fleet.js +3 -2
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/ensure-relay-broker.d.ts +24 -0
- package/dist/fleet/ensure-relay-broker.d.ts.map +1 -0
- package/dist/fleet/ensure-relay-broker.js +56 -0
- package/dist/fleet/ensure-relay-broker.js.map +1 -0
- package/dist/fleet/internal-fleet-client.d.ts +8 -3
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +32 -5
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +90 -13
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +496 -20
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/github/index.d.ts +4 -4
- package/dist/github/index.js +2 -2
- package/dist/github/probe-closer.d.ts +1 -1
- package/dist/github/probe-closer.js +1 -1
- package/dist/index.d.ts +33 -29
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -15
- package/dist/index.js.map +1 -1
- package/dist/linear/state-resolver.d.ts +3 -2
- package/dist/linear/state-resolver.d.ts.map +1 -1
- package/dist/linear/state-resolver.js +74 -10
- package/dist/linear/state-resolver.js.map +1 -1
- package/dist/mount/local-mount-preflight.d.ts +1 -0
- package/dist/mount/local-mount-preflight.d.ts.map +1 -1
- package/dist/mount/local-mount-preflight.js +64 -12
- package/dist/mount/local-mount-preflight.js.map +1 -1
- package/dist/mount/relayfile-binary.d.ts +2 -0
- package/dist/mount/relayfile-binary.d.ts.map +1 -1
- package/dist/mount/relayfile-binary.js +39 -5
- package/dist/mount/relayfile-binary.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +2 -2
- package/dist/mount/relayfile-cloud-mount-client.js +1 -1
- package/dist/node/factory-node.d.ts +59 -0
- package/dist/node/factory-node.d.ts.map +1 -0
- package/dist/node/factory-node.js +351 -0
- package/dist/node/factory-node.js.map +1 -0
- package/dist/node/factory.node.d.ts +3 -0
- package/dist/node/factory.node.d.ts.map +1 -0
- package/dist/node/factory.node.js +5 -0
- package/dist/node/factory.node.js.map +1 -0
- package/dist/orchestrator/batch-tracker.d.ts +2 -2
- package/dist/orchestrator/factory.d.ts +5 -2
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +413 -59
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/index.d.ts +4 -4
- package/dist/orchestrator/index.d.ts.map +1 -1
- package/dist/orchestrator/index.js +3 -3
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/process-identity.d.ts +1 -1
- package/dist/orchestrator/reaper.d.ts +3 -3
- package/dist/orchestrator/reaper.js +3 -3
- package/dist/ports/fleet.d.ts +8 -2
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/index.d.ts +4 -4
- package/dist/ports/state.d.ts +3 -3
- package/dist/ports/writeback.d.ts +1 -1
- package/dist/safety/factory-scope.d.ts +2 -2
- package/dist/state/in-memory-state-store.d.ts +1 -1
- package/dist/state/in-memory-state-store.js +1 -1
- package/dist/subscriptions/event-client.d.ts +1 -1
- package/dist/subscriptions/event-client.js +2 -2
- package/dist/subscriptions/globs.js +1 -1
- package/dist/subscriptions/index.d.ts +7 -7
- package/dist/subscriptions/index.js +4 -4
- package/dist/subscriptions/linear-filter.d.ts +1 -1
- package/dist/subscriptions/linear-filter.js +1 -1
- package/dist/subscriptions/specs.d.ts +1 -1
- package/dist/subscriptions/specs.js +2 -2
- package/dist/testing/fakes.d.ts +1 -1
- package/dist/testing/fakes.js +1 -1
- package/dist/testing/fakes.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/triage/heuristic.d.ts +8 -4
- package/dist/triage/heuristic.d.ts.map +1 -1
- package/dist/triage/heuristic.js +70 -6
- package/dist/triage/heuristic.js.map +1 -1
- package/dist/triage/index.d.ts +6 -6
- package/dist/triage/index.d.ts.map +1 -1
- package/dist/triage/index.js +4 -4
- package/dist/triage/index.js.map +1 -1
- package/dist/triage/llm.d.ts +1 -1
- package/dist/triage/llm.d.ts.map +1 -1
- package/dist/triage/llm.js +5 -3
- package/dist/triage/llm.js.map +1 -1
- package/dist/triage/schema.d.ts +133 -29
- package/dist/triage/schema.d.ts.map +1 -1
- package/dist/triage/schema.js +6 -3
- package/dist/triage/schema.js.map +1 -1
- package/dist/triage/tiered.d.ts +1 -1
- package/dist/triage/tiered.d.ts.map +1 -1
- package/dist/triage/tiered.js +9 -2
- package/dist/triage/tiered.js.map +1 -1
- package/dist/types.d.ts +10 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts +2 -2
- package/dist/writeback/github.js +1 -1
- package/dist/writeback/index.d.ts +5 -5
- package/dist/writeback/index.js +3 -3
- package/dist/writeback/linear.d.ts +3 -3
- package/dist/writeback/linear.d.ts.map +1 -1
- package/dist/writeback/linear.js +57 -14
- package/dist/writeback/linear.js.map +1 -1
- package/dist/writeback/slack.d.ts +1 -1
- package/dist/writeback/slack.js +2 -2
- package/package.json +10 -2
- package/scripts/com.agentrelay.factory-canary.plist.example +53 -0
- package/scripts/factory-canary.sh +89 -0
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
3
|
-
import { FactoryConfigSchema } from '../config/schema';
|
|
4
|
-
import { linearByStatePath } from '../constants/linear';
|
|
5
|
-
import { stateResolutionFromIds } from '../linear/state-resolver';
|
|
6
|
-
import { GithubMergeGate, closeProbePr } from '../github';
|
|
7
|
-
import { InMemoryStateStore } from '../state/in-memory-state-store';
|
|
8
|
-
import { containsExplicitIssueReference, containsIssueKey } from '../issue-key-match';
|
|
9
|
-
import { isInFactoryScope } from '../safety/factory-scope';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { FactoryConfigSchema } from '../config/schema.js';
|
|
4
|
+
import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
|
|
5
|
+
import { stateResolutionFromIds } from '../linear/state-resolver.js';
|
|
6
|
+
import { GithubMergeGate, closeProbePr } from '../github/index.js';
|
|
7
|
+
import { InMemoryStateStore } from '../state/in-memory-state-store.js';
|
|
8
|
+
import { containsExplicitIssueReference, containsIssueKey } from '../issue-key-match.js';
|
|
9
|
+
import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
10
|
+
import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
|
|
11
|
+
import { renderAgentTask } from '../dispatch/templates.js';
|
|
12
|
+
import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
|
|
13
|
+
import { MountGithubRead, MountLinearWriteback, MountSlackWriteback } from '../writeback/index.js';
|
|
14
|
+
import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
|
|
15
|
+
import { issueKey } from './batch-tracker.js';
|
|
16
|
+
import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
|
|
17
|
+
import { terminatePids } from './reaper.js';
|
|
17
18
|
const ISSUE_ROOT = '/linear/issues';
|
|
18
19
|
const GITHUB_ISSUE_ROOT = '/github/repos';
|
|
19
20
|
const READY_EVENTS_LIMIT = 100;
|
|
@@ -95,6 +96,10 @@ export class FactoryLoop {
|
|
|
95
96
|
#resumeInFlight = new Map();
|
|
96
97
|
#slackWatchers = new Map();
|
|
97
98
|
#slackWatcherStarts = new Map();
|
|
99
|
+
// Agents we've already logged an ambiguous-PID-lookup warning for, so the
|
|
100
|
+
// reaper doesn't spam the same benign "ambiguous process lookup" line on every
|
|
101
|
+
// poll (a joined/cloud agent has no local PID to resolve — expected).
|
|
102
|
+
#ambiguousLookupWarned = new Set();
|
|
98
103
|
// Last invalid-label failure signature we posted per issue, so a stuck Ready
|
|
99
104
|
// issue (or the comment writeback's own change event) does not re-post the
|
|
100
105
|
// same notice every cycle. Cleared once the issue dispatches successfully.
|
|
@@ -147,6 +152,8 @@ export class FactoryLoop {
|
|
|
147
152
|
// undefined = readiness not yet probed; resolved lazily so the standalone
|
|
148
153
|
// runOnce() path (which skips #start) still ingests when the mount is present.
|
|
149
154
|
#githubIngestionEnabled;
|
|
155
|
+
#integrationInstructions;
|
|
156
|
+
#integrationInstructionsRefresh;
|
|
150
157
|
#starting;
|
|
151
158
|
#started = false;
|
|
152
159
|
#stopping = false;
|
|
@@ -154,8 +161,10 @@ export class FactoryLoop {
|
|
|
154
161
|
this.#config = config;
|
|
155
162
|
this.#mount = ports.mount;
|
|
156
163
|
// Resolved role<->state mapping. The CLI injects a name-resolved, per-team
|
|
157
|
-
// resolution via ports; fall back to one built from explicit stateIds
|
|
158
|
-
|
|
164
|
+
// resolution via ports; fall back to one built from explicit stateIds plus
|
|
165
|
+
// the configured role NAMES so name->UUID backfill still works for sparse
|
|
166
|
+
// synced records (state.name but no state.id) without the states catalog.
|
|
167
|
+
this.#states = ports.stateResolution ?? stateResolutionFromIds(config.stateIds, config.linear.states);
|
|
159
168
|
installFactoryDraftPredicate(this.#mount, config);
|
|
160
169
|
this.#fleet = ports.fleet;
|
|
161
170
|
this.#triage = ports.triage ?? new TieredTriage(new HeuristicTriage());
|
|
@@ -190,6 +199,63 @@ export class FactoryLoop {
|
|
|
190
199
|
});
|
|
191
200
|
this.#wireFleetEvents();
|
|
192
201
|
}
|
|
202
|
+
async #resolveIntegrationInstructions() {
|
|
203
|
+
if (this.#integrationInstructionsRefresh) {
|
|
204
|
+
return this.#integrationInstructionsRefresh;
|
|
205
|
+
}
|
|
206
|
+
this.#integrationInstructionsRefresh = this.#doResolveIntegrationInstructions();
|
|
207
|
+
try {
|
|
208
|
+
return await this.#integrationInstructionsRefresh;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
this.#integrationInstructionsRefresh = undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async #doResolveIntegrationInstructions() {
|
|
215
|
+
if (this.#integrationInstructions !== undefined) {
|
|
216
|
+
return this.#integrationInstructions;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const reader = {
|
|
220
|
+
readFile: async (path) => {
|
|
221
|
+
try {
|
|
222
|
+
const { content } = await this.#mount.readFile(path);
|
|
223
|
+
return typeof content === 'string' ? content : undefined;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
listPaths: async (prefix) => {
|
|
230
|
+
return this.#mount.listTree(prefix);
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
const descriptors = await deriveDescriptorsFromMount(reader);
|
|
234
|
+
// The package emits paths relative to the daemon's .integrations mount,
|
|
235
|
+
// but the agent runs in its repo clonePath — a relative `.integrations/...`
|
|
236
|
+
// path is unreachable from there. Absolutize every writeback path to the
|
|
237
|
+
// daemon's mount root so the prescriptive instructions are actionable.
|
|
238
|
+
const root = this.#integrationsMountRoot();
|
|
239
|
+
const abs = (p) => (isAbsolute(p) ? p : resolve(root, '..', p));
|
|
240
|
+
const absoluteDescriptors = descriptors.map((descriptor) => ({
|
|
241
|
+
...descriptor,
|
|
242
|
+
mountRoot: abs(descriptor.mountRoot),
|
|
243
|
+
...(descriptor.discoveryRoot ? { discoveryRoot: abs(descriptor.discoveryRoot) } : {}),
|
|
244
|
+
writableResources: descriptor.writableResources.map((res) => ({
|
|
245
|
+
...res,
|
|
246
|
+
path: abs(res.path),
|
|
247
|
+
...(res.createExamplePath ? { createExamplePath: abs(res.createExamplePath) } : {}),
|
|
248
|
+
...(res.schemaPath ? { schemaPath: abs(res.schemaPath) } : {}),
|
|
249
|
+
})),
|
|
250
|
+
}));
|
|
251
|
+
this.#integrationInstructions = prescriptiveInstructions(absoluteDescriptors);
|
|
252
|
+
return this.#integrationInstructions;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
this.#logger.warn?.('[factory] failed to resolve integration instructions from mount');
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
193
259
|
async #batch() {
|
|
194
260
|
if (this.#batchView) {
|
|
195
261
|
return this.#batchView;
|
|
@@ -226,6 +292,7 @@ export class FactoryLoop {
|
|
|
226
292
|
this.#started = true;
|
|
227
293
|
try {
|
|
228
294
|
await this.#startLiveSubscription(opts.liveSubscription);
|
|
295
|
+
await this.#rearmSlackReplyWatchers();
|
|
229
296
|
this.#scheduleCompletionSweep(0);
|
|
230
297
|
return;
|
|
231
298
|
}
|
|
@@ -237,17 +304,25 @@ export class FactoryLoop {
|
|
|
237
304
|
}
|
|
238
305
|
await this.#backfillReadyIssues();
|
|
239
306
|
this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => {
|
|
240
|
-
|
|
241
|
-
|
|
307
|
+
// The SDK types `resource` as always-present, but the polling fallback and
|
|
308
|
+
// degraded-sync paths can deliver events without it. Skip those rather
|
|
309
|
+
// than throwing (which would otherwise crash the subscription handler).
|
|
310
|
+
const path = changeEventPath(event);
|
|
311
|
+
if (!path) {
|
|
242
312
|
return;
|
|
243
313
|
}
|
|
244
|
-
if (
|
|
245
|
-
void this.#
|
|
314
|
+
if (isGithubPullFilePath(path)) {
|
|
315
|
+
void this.#handlePrChange(path);
|
|
246
316
|
return;
|
|
247
317
|
}
|
|
248
|
-
|
|
318
|
+
if (isGithubIssueFilePath(path)) {
|
|
319
|
+
void this.#handleGithubIssueChange(path, { dryRun: this.#config.dryRun });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
void this.#handleChange(path);
|
|
249
323
|
});
|
|
250
324
|
this.#started = true;
|
|
325
|
+
await this.#rearmSlackReplyWatchers();
|
|
251
326
|
this.#scheduleCompletionSweep(0);
|
|
252
327
|
}
|
|
253
328
|
async stop() {
|
|
@@ -572,7 +647,10 @@ export class FactoryLoop {
|
|
|
572
647
|
await this.#refreshLiveHeartbeat();
|
|
573
648
|
}
|
|
574
649
|
async #prepareLiveEventForDrain(event, seenIssueKeys) {
|
|
575
|
-
const path = event
|
|
650
|
+
const path = changeEventPath(event);
|
|
651
|
+
if (!path) {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
576
654
|
const isPullPath = isGithubPullFilePath(path);
|
|
577
655
|
if (!isIssueFilePath(path) && !isGithubIssueFilePath(path) && !isPullPath) {
|
|
578
656
|
return undefined;
|
|
@@ -855,6 +933,11 @@ export class FactoryLoop {
|
|
|
855
933
|
let consecutiveFailures = 0;
|
|
856
934
|
let completed = false;
|
|
857
935
|
try {
|
|
936
|
+
// Re-arm Slack reply watchers for any issue that is already in-flight with
|
|
937
|
+
// a persisted dispatch thread before the first iteration runs. The loop
|
|
938
|
+
// path never calls #start(), so without this a watcher only lives for the
|
|
939
|
+
// process that originally dispatched — replies after a restart are dropped.
|
|
940
|
+
await this.#rearmSlackReplyWatchers();
|
|
858
941
|
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
|
|
859
942
|
await this.#writeLoopHeartbeat(heartbeatPath, registryPath, 'running', iteration, maxIterations);
|
|
860
943
|
try {
|
|
@@ -973,8 +1056,9 @@ export class FactoryLoop {
|
|
|
973
1056
|
}
|
|
974
1057
|
const spawnedForReaperHandoff = [];
|
|
975
1058
|
try {
|
|
1059
|
+
const specs = dispatchSpecs(dispatchDecision);
|
|
976
1060
|
const agents = [];
|
|
977
|
-
for (const spec of
|
|
1061
|
+
for (const spec of specs) {
|
|
978
1062
|
const spawned = await this.#spawnAgent(record, spec, dryRun);
|
|
979
1063
|
const tracked = record.agents.get(spawned.name);
|
|
980
1064
|
if (tracked) {
|
|
@@ -1070,8 +1154,9 @@ export class FactoryLoop {
|
|
|
1070
1154
|
}
|
|
1071
1155
|
async #backfillReadyIssues() {
|
|
1072
1156
|
const page = await this.#mount.getEvents({ limit: READY_EVENTS_LIMIT });
|
|
1073
|
-
const
|
|
1074
|
-
const
|
|
1157
|
+
const allPaths = page.events.map((event) => changeEventPath(event)).filter((p) => Boolean(p));
|
|
1158
|
+
const eventPaths = allPaths.filter(isIssueFilePath);
|
|
1159
|
+
const githubEventPaths = allPaths.filter(isGithubIssueFilePath);
|
|
1075
1160
|
for (const path of new Set(githubEventPaths)) {
|
|
1076
1161
|
await this.#handleGithubIssueChange(path, { dryRun: this.#config.dryRun });
|
|
1077
1162
|
}
|
|
@@ -1537,8 +1622,21 @@ export class FactoryLoop {
|
|
|
1537
1622
|
}
|
|
1538
1623
|
async #readIssue(path) {
|
|
1539
1624
|
try {
|
|
1540
|
-
|
|
1541
|
-
|
|
1625
|
+
// Newly-synced issues land as a change-event STUB at the primary
|
|
1626
|
+
// /linear/issues/<key>__<uuid>.json path (no state/url/team); the full
|
|
1627
|
+
// record lands at the by-id / by-uuid aliases. Read the canonical sibling
|
|
1628
|
+
// when the primary parses empty so triage sees real state.
|
|
1629
|
+
const issue = await readLinearIssueWithCanonicalFallback(this.#mount, path);
|
|
1630
|
+
// Synced Linear records may carry only the state NAME, not the state UUID
|
|
1631
|
+
// (relayfile-adapters#205). The factory matches state by UUID, so backfill
|
|
1632
|
+
// the id from the name when the payload omitted it — otherwise every issue
|
|
1633
|
+
// reads as stateId='' and no role (incl. readyForAgent) ever matches.
|
|
1634
|
+
if (issue && !issue.stateId && issue.state?.name) {
|
|
1635
|
+
const backfilled = this.#states.idForName(issue.state.name, issue.team);
|
|
1636
|
+
if (backfilled)
|
|
1637
|
+
return { ...issue, stateId: backfilled };
|
|
1638
|
+
}
|
|
1639
|
+
return issue;
|
|
1542
1640
|
}
|
|
1543
1641
|
catch (error) {
|
|
1544
1642
|
if (isMissingIssueFileError(error) && isIssuePathUnderRoot(path)) {
|
|
@@ -1619,7 +1717,10 @@ export class FactoryLoop {
|
|
|
1619
1717
|
return { pids: [scan.identity.pid], status: 'found' };
|
|
1620
1718
|
}
|
|
1621
1719
|
if (scan.status === 'ambiguous') {
|
|
1622
|
-
this.#
|
|
1720
|
+
if (!this.#ambiguousLookupWarned.has(agentName)) {
|
|
1721
|
+
this.#ambiguousLookupWarned.add(agentName);
|
|
1722
|
+
this.#logger.warn?.(`[factory] ambiguous process lookup for ${agentName} (suppressing repeats)`);
|
|
1723
|
+
}
|
|
1623
1724
|
return { pids: [], status: 'unresolved' };
|
|
1624
1725
|
}
|
|
1625
1726
|
if (pids.length > 0) {
|
|
@@ -1760,6 +1861,8 @@ export class FactoryLoop {
|
|
|
1760
1861
|
capability: spec.capability,
|
|
1761
1862
|
node: spec.node ?? 'self',
|
|
1762
1863
|
task: spec.task,
|
|
1864
|
+
workflow: spec.workflow,
|
|
1865
|
+
inputs: spec.inputs,
|
|
1763
1866
|
model: spec.model,
|
|
1764
1867
|
cwd: spec.clonePath,
|
|
1765
1868
|
sessionRef: spec.sessionRef,
|
|
@@ -1817,6 +1920,12 @@ export class FactoryLoop {
|
|
|
1817
1920
|
if (tracked.sessionRef) {
|
|
1818
1921
|
const resumeKey = `${issueKey(record.issue)}:${name}:${tracked.sessionRef}`;
|
|
1819
1922
|
if (await this.#state.isResumed(this.#workspaceId, resumeKey)) {
|
|
1923
|
+
// Already resumed once and STILL exiting with no completion PR — the
|
|
1924
|
+
// agent isn't making progress. Escalate so a human notices, instead of
|
|
1925
|
+
// leaving the issue silently in-flight forever.
|
|
1926
|
+
if (tracked.spec.role === 'implementer') {
|
|
1927
|
+
await this.#escalateStalledIssue(record, name);
|
|
1928
|
+
}
|
|
1820
1929
|
return;
|
|
1821
1930
|
}
|
|
1822
1931
|
const existing = this.#resumeInFlight.get(resumeKey);
|
|
@@ -1875,6 +1984,27 @@ export class FactoryLoop {
|
|
|
1875
1984
|
this.#error(error, record.issue);
|
|
1876
1985
|
}
|
|
1877
1986
|
}
|
|
1987
|
+
// An implementer that exited, was resumed once, and STILL produced no PR is
|
|
1988
|
+
// not making progress. Surface it (counter + a best-effort Slack note to the
|
|
1989
|
+
// dispatch thread) so a human can step in, instead of the issue sitting
|
|
1990
|
+
// silently "in flight" with nothing happening. We do NOT fake a Linear state
|
|
1991
|
+
// change here (the mount may be wedged); the human owns the next step.
|
|
1992
|
+
async #escalateStalledIssue(record, name) {
|
|
1993
|
+
this.#increment('issuesStalledNoPr');
|
|
1994
|
+
this.#logger.warn?.('[factory] implementer exited without a PR after a resume; escalating for human attention', {
|
|
1995
|
+
issue: record.issue.key,
|
|
1996
|
+
agent: name,
|
|
1997
|
+
});
|
|
1998
|
+
try {
|
|
1999
|
+
const thread = await this.#slackDispatchThreadFor(record);
|
|
2000
|
+
if (thread && this.#slack) {
|
|
2001
|
+
await this.#slack.reply(thread.threadId, `:warning: ${record.issue.key}: the implementer exited without opening a PR (after a retry). It needs a human look.`);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
catch (error) {
|
|
2005
|
+
this.#logger.warn?.('[factory] failed to post stalled-issue escalation to Slack', error);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
1878
2008
|
async #issueHasCompletionPr(record) {
|
|
1879
2009
|
try {
|
|
1880
2010
|
const issue = await this.#readIssue(record.issue.path);
|
|
@@ -2032,7 +2162,7 @@ export class FactoryLoop {
|
|
|
2032
2162
|
this.#counters.liveArrivalLatencyMsMax = Math.max(this.#counters.liveArrivalLatencyMsMax ?? 0, latencyMs);
|
|
2033
2163
|
this.#logger.debug?.('[factory] live issue event latency recorded', {
|
|
2034
2164
|
eventId: event.id,
|
|
2035
|
-
path: event
|
|
2165
|
+
path: changeEventPath(event),
|
|
2036
2166
|
latencyMs,
|
|
2037
2167
|
});
|
|
2038
2168
|
}
|
|
@@ -2065,6 +2195,7 @@ export class FactoryLoop {
|
|
|
2065
2195
|
const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
|
|
2066
2196
|
const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name ?? 'reviewer';
|
|
2067
2197
|
const implementerNames = implementers.map((agent) => agent.result?.name ?? agent.spec.name);
|
|
2198
|
+
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
2068
2199
|
for (const implementer of implementers) {
|
|
2069
2200
|
const input = {
|
|
2070
2201
|
to: implementer.result?.name ?? implementer.spec.name,
|
|
@@ -2076,6 +2207,8 @@ export class FactoryLoop {
|
|
|
2076
2207
|
reviewerName,
|
|
2077
2208
|
implementerNames,
|
|
2078
2209
|
slackDispatchThread: await this.#slackDispatchThreadFor(record),
|
|
2210
|
+
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
2211
|
+
integrationInstructions,
|
|
2079
2212
|
}),
|
|
2080
2213
|
from: 'factory',
|
|
2081
2214
|
data: { issue: record.issue },
|
|
@@ -2309,6 +2442,7 @@ export class FactoryLoop {
|
|
|
2309
2442
|
const implementerNames = [...record.agents.values()]
|
|
2310
2443
|
.filter((agent) => agent.spec.role === 'implementer')
|
|
2311
2444
|
.map((agent) => agent.result?.name ?? agent.spec.name);
|
|
2445
|
+
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
2312
2446
|
const task = renderAgentTask({
|
|
2313
2447
|
issue: templateIssueFromRecord(record, issue),
|
|
2314
2448
|
route: route ?? { repo: prRef.repo },
|
|
@@ -2318,6 +2452,8 @@ export class FactoryLoop {
|
|
|
2318
2452
|
implementerNames,
|
|
2319
2453
|
pr: { number: prRef.prNumber, url: prRef.url },
|
|
2320
2454
|
slackDispatchThread: await this.#slackDispatchThreadFor(record),
|
|
2455
|
+
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
2456
|
+
integrationInstructions,
|
|
2321
2457
|
});
|
|
2322
2458
|
const spawned = await this.#spawnAgent(record, { ...spec, task }, false);
|
|
2323
2459
|
await this.#writeInFlightRegistry();
|
|
@@ -2674,13 +2810,21 @@ export class FactoryLoop {
|
|
|
2674
2810
|
return;
|
|
2675
2811
|
}
|
|
2676
2812
|
const key = issueKey(record.issue);
|
|
2677
|
-
|
|
2813
|
+
const existingThread = await this.#state.getSlackThread(this.#workspaceId, key);
|
|
2814
|
+
if (existingThread || this.#slackWatcherStarts.has(key)) {
|
|
2678
2815
|
try {
|
|
2679
2816
|
await this.#slackWatcherStarts.get(key);
|
|
2680
2817
|
}
|
|
2681
2818
|
catch {
|
|
2682
2819
|
// The initiator logs Slack watcher startup failures.
|
|
2683
2820
|
}
|
|
2821
|
+
// A dispatch thread already exists (often persisted from a previous
|
|
2822
|
+
// process) but the in-process watcher map starts empty on restart. Re-arm
|
|
2823
|
+
// the reply watcher instead of returning early, otherwise human replies in
|
|
2824
|
+
// the existing thread are watched by nobody and silently dropped.
|
|
2825
|
+
if (existingThread) {
|
|
2826
|
+
await this.#rearmSlackWatcher(record, existingThread);
|
|
2827
|
+
}
|
|
2684
2828
|
return;
|
|
2685
2829
|
}
|
|
2686
2830
|
const start = this.#postAndWatchSlackDispatchThread(record, result);
|
|
@@ -2727,13 +2871,20 @@ export class FactoryLoop {
|
|
|
2727
2871
|
return;
|
|
2728
2872
|
}
|
|
2729
2873
|
const key = issueKey(decision.issue);
|
|
2730
|
-
|
|
2874
|
+
const existingThread = await this.#state.getSlackThread(this.#workspaceId, key);
|
|
2875
|
+
if (existingThread || this.#slackWatcherStarts.has(key)) {
|
|
2731
2876
|
try {
|
|
2732
2877
|
await this.#slackWatcherStarts.get(key);
|
|
2733
2878
|
}
|
|
2734
2879
|
catch {
|
|
2735
2880
|
// The initiator logs Slack watcher startup failures.
|
|
2736
2881
|
}
|
|
2882
|
+
// Re-arm the reply watcher for an escalation thread that already exists but
|
|
2883
|
+
// has no live in-process watcher (e.g. after a restart), matching the
|
|
2884
|
+
// dispatch-thread path.
|
|
2885
|
+
if (existingThread) {
|
|
2886
|
+
await this.#rearmSlackWatcher(escalationWatchRecord(decision), existingThread);
|
|
2887
|
+
}
|
|
2737
2888
|
return;
|
|
2738
2889
|
}
|
|
2739
2890
|
const start = this.#postAndWatchSlackEscalationThread(decision, reason);
|
|
@@ -2763,13 +2914,7 @@ export class FactoryLoop {
|
|
|
2763
2914
|
].join('\n'),
|
|
2764
2915
|
});
|
|
2765
2916
|
await this.#state.setSlackThread(this.#workspaceId, issueKey(decision.issue), root.threadId);
|
|
2766
|
-
await this.#watchSlackThread(
|
|
2767
|
-
issue: decision.issue,
|
|
2768
|
-
decision,
|
|
2769
|
-
agents: new Map(),
|
|
2770
|
-
invocationIds: new Set(),
|
|
2771
|
-
dryRun: false,
|
|
2772
|
-
}, root.threadId);
|
|
2917
|
+
await this.#watchSlackThread(escalationWatchRecord(decision), root.threadId);
|
|
2773
2918
|
this.#recordSlackWritebackSuccess('triage-escalation');
|
|
2774
2919
|
}
|
|
2775
2920
|
async #watchSlackThread(record, threadId) {
|
|
@@ -2794,8 +2939,9 @@ export class FactoryLoop {
|
|
|
2794
2939
|
const page = await this.#mount.getEvents({ limit: SLACK_REPLY_EVENTS_LIMIT });
|
|
2795
2940
|
cursor = page.nextCursor ?? undefined;
|
|
2796
2941
|
for (const event of page.events) {
|
|
2797
|
-
|
|
2798
|
-
|
|
2942
|
+
const path = changeEventPath(event);
|
|
2943
|
+
if (path && path.startsWith(messagesPrefix)) {
|
|
2944
|
+
preExistingPaths.add(path);
|
|
2799
2945
|
}
|
|
2800
2946
|
}
|
|
2801
2947
|
}
|
|
@@ -2805,7 +2951,11 @@ export class FactoryLoop {
|
|
|
2805
2951
|
};
|
|
2806
2952
|
const handle = async (event) => {
|
|
2807
2953
|
try {
|
|
2808
|
-
|
|
2954
|
+
// Polling-fallback / degraded-sync events can lack `resource.path`
|
|
2955
|
+
// despite the SDK type. Skip them quietly so one malformed event never
|
|
2956
|
+
// wedges Slack reply processing (replies that DO carry a path still flow).
|
|
2957
|
+
const path = changeEventPath(event);
|
|
2958
|
+
if (stopped || !path || !path.startsWith(messagesPrefix)) {
|
|
2809
2959
|
return;
|
|
2810
2960
|
}
|
|
2811
2961
|
const eventKey = eventIdentity(event);
|
|
@@ -2815,21 +2965,21 @@ export class FactoryLoop {
|
|
|
2815
2965
|
this.#logger.warn?.('[factory] Slack reply event missing stable identity; falling back to path/content dedupe');
|
|
2816
2966
|
}
|
|
2817
2967
|
}
|
|
2818
|
-
if (preExistingPaths.has(
|
|
2968
|
+
if (preExistingPaths.has(path)) {
|
|
2819
2969
|
return;
|
|
2820
2970
|
}
|
|
2821
|
-
const reply = await this.#readSlackReply(
|
|
2971
|
+
const reply = await this.#readSlackReply(path);
|
|
2822
2972
|
if (!reply || !reply.isThreadReply || reply.threadTs !== threadId || reply.channelDir !== channelDir) {
|
|
2823
2973
|
return;
|
|
2824
2974
|
}
|
|
2825
2975
|
const replyMessageKey = `${reply.threadTs}:${reply.messageTs}`;
|
|
2826
2976
|
if (seenReplyMessages.has(replyMessageKey)) {
|
|
2827
|
-
this.#logger.debug?.('[factory] suppressed duplicate Slack reply message', { issue: record.issue.key, path
|
|
2977
|
+
this.#logger.debug?.('[factory] suppressed duplicate Slack reply message', { issue: record.issue.key, path });
|
|
2828
2978
|
return;
|
|
2829
2979
|
}
|
|
2830
|
-
const replyKey = `${eventKey ??
|
|
2980
|
+
const replyKey = `${eventKey ?? path}:${stableHash(JSON.stringify(reply.raw))}`;
|
|
2831
2981
|
if (seenReplies.has(replyKey)) {
|
|
2832
|
-
this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path
|
|
2982
|
+
this.#logger.debug?.('[factory] suppressed duplicate Slack reply payload', { issue: record.issue.key, path });
|
|
2833
2983
|
return;
|
|
2834
2984
|
}
|
|
2835
2985
|
seenReplies.add(replyKey);
|
|
@@ -2886,6 +3036,53 @@ export class FactoryLoop {
|
|
|
2886
3036
|
},
|
|
2887
3037
|
});
|
|
2888
3038
|
}
|
|
3039
|
+
// Re-attach a live reply watcher to a dispatch/escalation thread that already
|
|
3040
|
+
// exists (persisted thread id) but has no in-process watcher. #watchSlackThread
|
|
3041
|
+
// is itself idempotent on #slackWatchers; the guard here keeps the counter (and
|
|
3042
|
+
// the seeding getEvents call) limited to genuine re-arms.
|
|
3043
|
+
async #rearmSlackWatcher(record, threadId) {
|
|
3044
|
+
const key = issueKey(record.issue);
|
|
3045
|
+
if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
try {
|
|
3049
|
+
await this.#watchSlackThread(record, threadId);
|
|
3050
|
+
this.#increment('slackWatchersRearmed');
|
|
3051
|
+
}
|
|
3052
|
+
catch (error) {
|
|
3053
|
+
this.#logger.warn?.('[factory] failed to re-arm Slack reply watcher', { issue: record.issue.key, error });
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
// On start()/runLoop() init, re-arm Slack reply watchers for every in-flight
|
|
3057
|
+
// issue that has a persisted dispatch thread. A watcher otherwise only lives as
|
|
3058
|
+
// long as the process that dispatched it, so replies after a restart (or a
|
|
3059
|
+
// run-once exit followed by a loop) would be watched by nobody.
|
|
3060
|
+
async #rearmSlackReplyWatchers() {
|
|
3061
|
+
if (!this.#slack || !this.#config.slack) {
|
|
3062
|
+
return;
|
|
3063
|
+
}
|
|
3064
|
+
for (const record of (await this.#batch()).inFlight) {
|
|
3065
|
+
if (record.dryRun) {
|
|
3066
|
+
continue;
|
|
3067
|
+
}
|
|
3068
|
+
const key = issueKey(record.issue);
|
|
3069
|
+
if (this.#slackWatchers.has(key) || this.#slackWatcherStarts.has(key)) {
|
|
3070
|
+
continue;
|
|
3071
|
+
}
|
|
3072
|
+
let threadId;
|
|
3073
|
+
try {
|
|
3074
|
+
threadId = await this.#state.getSlackThread(this.#workspaceId, key);
|
|
3075
|
+
}
|
|
3076
|
+
catch (error) {
|
|
3077
|
+
this.#logger.warn?.('[factory] unable to read persisted Slack thread during watcher rehydration', { issue: record.issue.key, error });
|
|
3078
|
+
continue;
|
|
3079
|
+
}
|
|
3080
|
+
if (!threadId) {
|
|
3081
|
+
continue;
|
|
3082
|
+
}
|
|
3083
|
+
await this.#rearmSlackWatcher(record, threadId);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
2889
3086
|
async #stopSlackWatcher(issue) {
|
|
2890
3087
|
const key = issueKey(issue);
|
|
2891
3088
|
const watcher = this.#slackWatchers.get(key);
|
|
@@ -2927,18 +3124,34 @@ export class FactoryLoop {
|
|
|
2927
3124
|
this.#increment('slackAnswersIgnoredNoImplementer');
|
|
2928
3125
|
return;
|
|
2929
3126
|
}
|
|
2930
|
-
const input = slackAnswerInput(liveRecord.issue, text);
|
|
2931
3127
|
for (const recipient of new Set(recipients)) {
|
|
2932
|
-
await this.#
|
|
3128
|
+
await this.#injectSlackReplyEvent(recipient, liveRecord.issue, text);
|
|
2933
3129
|
this.#increment('slackAnswersInjected');
|
|
2934
3130
|
}
|
|
2935
3131
|
}
|
|
3132
|
+
// Inject the human's Slack reply into the agent framed as the
|
|
3133
|
+
// <integration-event> the spawn prompt tells it to expect (not an ambiguous
|
|
3134
|
+
// "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
|
|
3135
|
+
// event. (A broker confirmed-delivery path via waitForInjected is a possible
|
|
3136
|
+
// robustness follow-up.)
|
|
3137
|
+
async #injectSlackReplyEvent(recipient, issue, text) {
|
|
3138
|
+
await this.#fleet.sendInput?.(recipient, slackReplyEvent(issue, text));
|
|
3139
|
+
}
|
|
3140
|
+
// Absolute path to the local .integrations mount the daemon manages. The mount
|
|
3141
|
+
// is created at the daemon's cwd (see ensureLocalMount), and spawned agents run
|
|
3142
|
+
// in their repo clonePath, so writeback paths handed to agents must be absolute
|
|
3143
|
+
// against this root rather than a bare relative `.integrations/...`.
|
|
3144
|
+
#integrationsMountRoot() {
|
|
3145
|
+
return resolve(process.cwd(), '.integrations');
|
|
3146
|
+
}
|
|
2936
3147
|
async #slackDispatchThreadFor(record) {
|
|
2937
3148
|
if (!this.#config.slack) {
|
|
2938
3149
|
return undefined;
|
|
2939
3150
|
}
|
|
2940
3151
|
const threadId = await this.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
|
|
2941
|
-
return threadId
|
|
3152
|
+
return threadId
|
|
3153
|
+
? { channel: this.#config.slack.channel, threadId, mountRoot: this.#integrationsMountRoot() }
|
|
3154
|
+
: undefined;
|
|
2942
3155
|
}
|
|
2943
3156
|
async #runCompletionMergeGate(issue) {
|
|
2944
3157
|
if (this.#isSyntheticProbeIssue(issue)) {
|
|
@@ -3063,6 +3276,46 @@ export function parseLinearIssue(path, content) {
|
|
|
3063
3276
|
raw: asRecord(parsed) ?? payload,
|
|
3064
3277
|
};
|
|
3065
3278
|
}
|
|
3279
|
+
// A primary issue file that parsed without any usable state is a change-event
|
|
3280
|
+
// STUB ({created,path,externalId,ts,id}) — the active-issues sync wrote the full
|
|
3281
|
+
// body to the by-id/by-uuid aliases instead. Distinguishes a stub from a real
|
|
3282
|
+
// record (which always carries at least a state name from sync).
|
|
3283
|
+
const isUsableIssueRecord = (issue) => Boolean(issue.stateId || issue.state?.name);
|
|
3284
|
+
// The canonical sibling records for a primary /linear/issues/<key>__<uuid>.json
|
|
3285
|
+
// path: by-id keyed on the human key, by-uuid keyed on the Linear UUID.
|
|
3286
|
+
const canonicalIssueRecordPaths = (path) => {
|
|
3287
|
+
const key = keyFromPath(path);
|
|
3288
|
+
const uuid = uuidFromPath(path);
|
|
3289
|
+
return [
|
|
3290
|
+
...(key ? [linearByIdPath(key)] : []),
|
|
3291
|
+
...(uuid ? [linearByUuidPath(uuid)] : []),
|
|
3292
|
+
].filter((candidate) => candidate !== path);
|
|
3293
|
+
};
|
|
3294
|
+
// Read an issue, falling back to the canonical by-id/by-uuid alias when the
|
|
3295
|
+
// primary path holds only a stub. The canonical content is re-parsed against
|
|
3296
|
+
// the ORIGINAL primary path so issue.path/key/uuid stay primary-anchored (the
|
|
3297
|
+
// rest of the factory dedupes and dispatches by the primary path). A missing
|
|
3298
|
+
// alias is tolerated; the original (stub) record is returned if no alias helps.
|
|
3299
|
+
export async function readLinearIssueWithCanonicalFallback(mount, path) {
|
|
3300
|
+
const { content } = await mount.readFile(path);
|
|
3301
|
+
const issue = parseLinearIssue(path, content);
|
|
3302
|
+
if (isUsableIssueRecord(issue))
|
|
3303
|
+
return issue;
|
|
3304
|
+
for (const candidate of canonicalIssueRecordPaths(path)) {
|
|
3305
|
+
try {
|
|
3306
|
+
const canonical = await mount.readFile(candidate);
|
|
3307
|
+
const parsed = parseLinearIssue(path, canonical.content);
|
|
3308
|
+
if (isUsableIssueRecord(parsed))
|
|
3309
|
+
return parsed;
|
|
3310
|
+
}
|
|
3311
|
+
catch (error) {
|
|
3312
|
+
if (isMissingIssueFileError(error))
|
|
3313
|
+
continue;
|
|
3314
|
+
throw error;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
return issue;
|
|
3318
|
+
}
|
|
3066
3319
|
export function parseGithubIssue(path, content) {
|
|
3067
3320
|
const parsed = parseJsonContent(content);
|
|
3068
3321
|
const payload = wrappedPayload(parsed);
|
|
@@ -3138,11 +3391,42 @@ const pidsFromSpawnResult = (result) => {
|
|
|
3138
3391
|
const dispatchComment = (decision, agents) => [
|
|
3139
3392
|
`Factory dispatch for ${decision.issue.key}`,
|
|
3140
3393
|
`Implementers: ${agents.filter((agent) => agent.role === 'implementer').map((agent) => agent.name).join(', ') || 'none'}`,
|
|
3394
|
+
decision.scope === 'workflow' ? `Workflow: ${agents.find((agent) => agent.role === 'workflow')?.name ?? 'none'}` : undefined,
|
|
3141
3395
|
`Reviewer: ${agents.find((agent) => agent.role === 'reviewer')?.name ?? 'none'}`,
|
|
3142
|
-
].join('\n');
|
|
3396
|
+
].filter((line) => line !== undefined).join('\n');
|
|
3397
|
+
function dispatchSpecs(decision) {
|
|
3398
|
+
if (decision.scope === 'workflow') {
|
|
3399
|
+
return decision.workflow ? [decision.workflow] : [];
|
|
3400
|
+
}
|
|
3401
|
+
return [...decision.implementers, decision.reviewer];
|
|
3402
|
+
}
|
|
3143
3403
|
function labelDerivedDispatchDecision(liveIssue, decision, config) {
|
|
3144
3404
|
const routesByLabel = labelRoutesForIssue(liveIssue, config);
|
|
3145
3405
|
if (routesByLabel.labels.length === 0) {
|
|
3406
|
+
// No repo labels — which is also what a label-less sync produces
|
|
3407
|
+
// (relayfile-adapters#205, labels dropped from the synced record). Fall back
|
|
3408
|
+
// to the configured default repo (consistent with triage, which already
|
|
3409
|
+
// routes unlabeled issues to repos.default) rather than refusing to dispatch.
|
|
3410
|
+
const defaultRepo = config.repos.default;
|
|
3411
|
+
if (defaultRepo) {
|
|
3412
|
+
const route = {
|
|
3413
|
+
repo: defaultRepo,
|
|
3414
|
+
clonePath: config.repos.clonePaths[defaultRepo],
|
|
3415
|
+
rationale: 'No repo label present; routed to repos.default.',
|
|
3416
|
+
};
|
|
3417
|
+
const implementer = routeImplementerSpec(liveIssue, config, 'default', route);
|
|
3418
|
+
return {
|
|
3419
|
+
ok: true,
|
|
3420
|
+
decision: {
|
|
3421
|
+
...decision,
|
|
3422
|
+
routes: [route],
|
|
3423
|
+
scope: 'single',
|
|
3424
|
+
implementers: [implementer],
|
|
3425
|
+
workflow: undefined,
|
|
3426
|
+
reviewer: routeReviewerSpec(liveIssue, config, route, decision.reviewer),
|
|
3427
|
+
},
|
|
3428
|
+
};
|
|
3429
|
+
}
|
|
3146
3430
|
return {
|
|
3147
3431
|
ok: false,
|
|
3148
3432
|
reason: 'no-labels',
|
|
@@ -3156,8 +3440,15 @@ function labelDerivedDispatchDecision(liveIssue, decision, config) {
|
|
|
3156
3440
|
offendingLabels: routesByLabel.offendingLabels.length > 0 ? routesByLabel.offendingLabels : routesByLabel.labels,
|
|
3157
3441
|
};
|
|
3158
3442
|
}
|
|
3443
|
+
const explicitScope = scopeFromLabels(liveIssue.labels);
|
|
3444
|
+
const scope = explicitScope
|
|
3445
|
+
?? (routesByLabel.routes.length >= 2 || decision.scope === 'team'
|
|
3446
|
+
? 'team'
|
|
3447
|
+
: decision.scope === 'workflow'
|
|
3448
|
+
? 'workflow'
|
|
3449
|
+
: 'single');
|
|
3159
3450
|
const maxImplementers = Math.min(config.triage.maxImplementers, MAX_LABEL_IMPLEMENTERS);
|
|
3160
|
-
if (routesByLabel.routes.length > maxImplementers) {
|
|
3451
|
+
if (scope === 'team' && routesByLabel.routes.length > maxImplementers) {
|
|
3161
3452
|
return {
|
|
3162
3453
|
ok: false,
|
|
3163
3454
|
reason: 'too-many-labels',
|
|
@@ -3166,19 +3457,30 @@ function labelDerivedDispatchDecision(liveIssue, decision, config) {
|
|
|
3166
3457
|
};
|
|
3167
3458
|
}
|
|
3168
3459
|
const implementers = routesByLabel.routes.map(({ slug, route }) => routeImplementerSpec(liveIssue, config, slug, route));
|
|
3460
|
+
const selectedRoutes = scope === 'single' ? routesByLabel.routes.slice(0, 1) : routesByLabel.routes;
|
|
3461
|
+
const selectedImplementers = scope === 'team'
|
|
3462
|
+
? implementers
|
|
3463
|
+
: scope === 'single'
|
|
3464
|
+
? implementers.slice(0, 1)
|
|
3465
|
+
: [];
|
|
3466
|
+
const routes = selectedRoutes.map(({ route }) => route);
|
|
3467
|
+
const workflow = scope === 'workflow'
|
|
3468
|
+
? routeWorkflowSpec(liveIssue, config, selectedRoutes, decision.workflow)
|
|
3469
|
+
: undefined;
|
|
3169
3470
|
return {
|
|
3170
3471
|
ok: true,
|
|
3171
3472
|
decision: {
|
|
3172
3473
|
...decision,
|
|
3173
|
-
routes
|
|
3174
|
-
scope
|
|
3175
|
-
implementers,
|
|
3176
|
-
|
|
3474
|
+
routes,
|
|
3475
|
+
scope,
|
|
3476
|
+
implementers: selectedImplementers,
|
|
3477
|
+
workflow,
|
|
3478
|
+
reviewer: routeReviewerSpec(liveIssue, config, selectedRoutes[0].route, decision.reviewer),
|
|
3177
3479
|
},
|
|
3178
3480
|
};
|
|
3179
3481
|
}
|
|
3180
3482
|
function labelRoutesForIssue(issue, config) {
|
|
3181
|
-
const labels = uniqueNormalizedLabels(issue.labels);
|
|
3483
|
+
const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label));
|
|
3182
3484
|
const routes = [];
|
|
3183
3485
|
const offendingLabels = [];
|
|
3184
3486
|
const seenRepos = new Set();
|
|
@@ -3229,6 +3531,29 @@ function routeReviewerSpec(issue, config, route, reviewer) {
|
|
|
3229
3531
|
node: reviewer.node ?? 'self',
|
|
3230
3532
|
};
|
|
3231
3533
|
}
|
|
3534
|
+
function routeWorkflowSpec(issue, _config, routesByLabel, workflow) {
|
|
3535
|
+
const route = routesByLabel[0].route;
|
|
3536
|
+
return {
|
|
3537
|
+
...workflow,
|
|
3538
|
+
name: workflow?.name ?? `${agentBaseName(issue)}-workflow`,
|
|
3539
|
+
role: 'workflow',
|
|
3540
|
+
capability: 'workflow:run',
|
|
3541
|
+
task: workflow?.task ?? taskForDispatch(issue, route, 'workflow'),
|
|
3542
|
+
workflow: workflow?.workflow ?? 'workflows/factory/linear-issue.ts',
|
|
3543
|
+
inputs: {
|
|
3544
|
+
...workflow?.inputs,
|
|
3545
|
+
issue: { uuid: issue.uuid, key: issue.key, path: issue.path },
|
|
3546
|
+
title: issue.title,
|
|
3547
|
+
description: issue.description,
|
|
3548
|
+
labels: issue.labels,
|
|
3549
|
+
repoLabels: routesByLabel.map(({ slug }) => slug),
|
|
3550
|
+
routes: routesByLabel.map(({ route }) => route),
|
|
3551
|
+
},
|
|
3552
|
+
repo: route.repo,
|
|
3553
|
+
clonePath: route.clonePath,
|
|
3554
|
+
node: workflow?.node ?? 'self',
|
|
3555
|
+
};
|
|
3556
|
+
}
|
|
3232
3557
|
function labelDispatchFailureSignature(resolution) {
|
|
3233
3558
|
return `${resolution.reason}:${[...resolution.offendingLabels].sort().join(',')}`;
|
|
3234
3559
|
}
|
|
@@ -3276,7 +3601,13 @@ function uniqueNormalizedLabels(labels) {
|
|
|
3276
3601
|
return unique;
|
|
3277
3602
|
}
|
|
3278
3603
|
function taskForDispatch(issue, route, role) {
|
|
3279
|
-
const verb = role === 'implementer'
|
|
3604
|
+
const verb = role === 'implementer'
|
|
3605
|
+
? 'Implement'
|
|
3606
|
+
: role === 'babysitter'
|
|
3607
|
+
? 'Babysit the PR for'
|
|
3608
|
+
: role === 'workflow'
|
|
3609
|
+
? 'Run workflow for'
|
|
3610
|
+
: 'Review';
|
|
3280
3611
|
return [
|
|
3281
3612
|
`${verb} ${issue.key}: ${issue.title}`,
|
|
3282
3613
|
`Repo: ${route.repo}`,
|
|
@@ -3690,7 +4021,7 @@ const liveEventDedupeKey = (event) => {
|
|
|
3690
4021
|
return [
|
|
3691
4022
|
event.id,
|
|
3692
4023
|
event.type,
|
|
3693
|
-
|
|
4024
|
+
stringValue(resource.path) ?? '',
|
|
3694
4025
|
stringValue(resource.revision) ?? '',
|
|
3695
4026
|
event.digest ?? '',
|
|
3696
4027
|
].join('\u001f');
|
|
@@ -3840,6 +4171,15 @@ const eventIdentity = (event) => {
|
|
|
3840
4171
|
const id = typeof rawId === 'string' || typeof rawId === 'number' ? String(rawId) : undefined;
|
|
3841
4172
|
return id ? `event:${id}` : undefined;
|
|
3842
4173
|
};
|
|
4174
|
+
// Safe accessor for an event's resource path. The SDK types `resource` as
|
|
4175
|
+
// always-present, but the HTTP polling fallback and degraded-sync paths can
|
|
4176
|
+
// deliver events without it — reading `.path` directly then throws and (in a
|
|
4177
|
+
// subscription/poll handler) crash-loops. Returns undefined for such events so
|
|
4178
|
+
// callers skip them and keep processing the well-formed ones.
|
|
4179
|
+
export const changeEventPath = (event) => {
|
|
4180
|
+
const path = event?.resource?.path;
|
|
4181
|
+
return typeof path === 'string' && path ? path : undefined;
|
|
4182
|
+
};
|
|
3843
4183
|
const describeError = (error) => {
|
|
3844
4184
|
if (error instanceof Error) {
|
|
3845
4185
|
return {
|
|
@@ -3907,6 +4247,16 @@ const contextualError = (context, error) => {
|
|
|
3907
4247
|
return wrapped;
|
|
3908
4248
|
};
|
|
3909
4249
|
const registryHandoffKey = (issue, agentName) => `${issueKey(issue)}:${agentName}`;
|
|
4250
|
+
// Synthetic in-flight record used to watch an escalation thread. Escalations have
|
|
4251
|
+
// no spawned agents; reply routing re-reads the live batch record by issue, so
|
|
4252
|
+
// the empty agents/invocations here are only placeholders for the watcher key.
|
|
4253
|
+
const escalationWatchRecord = (decision) => ({
|
|
4254
|
+
issue: decision.issue,
|
|
4255
|
+
decision,
|
|
4256
|
+
agents: new Map(),
|
|
4257
|
+
invocationIds: new Set(),
|
|
4258
|
+
dryRun: false,
|
|
4259
|
+
});
|
|
3910
4260
|
const cloneTrackedAgent = (tracked) => ({
|
|
3911
4261
|
spec: { ...tracked.spec },
|
|
3912
4262
|
result: tracked.result ? { ...tracked.result } : undefined,
|
|
@@ -3948,6 +4298,10 @@ const triageEscalationReason = (decision) => {
|
|
|
3948
4298
|
return `${reasons.join(' and ')}${decision.rationale ? `: ${decision.rationale}` : ''}`;
|
|
3949
4299
|
};
|
|
3950
4300
|
const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
|
|
4301
|
+
// The human's Slack-thread reply, framed as an <integration-event> the agent is
|
|
4302
|
+
// told (at spawn) to expect — a recognizable injected event, not an ambiguous
|
|
4303
|
+
// keystroke. Trailing CR submits it to the agent's PTY.
|
|
4304
|
+
const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
|
|
3951
4305
|
const isFactoryQuestionTarget = (target) => {
|
|
3952
4306
|
const normalized = target.trim().replace(/^@/u, '').toLowerCase();
|
|
3953
4307
|
return normalized === 'broker' || normalized === 'factory';
|