@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.
- package/README.md +36 -10
- package/dist/cli/fleet.d.ts +2 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +1 -1
- package/dist/cli/fleet.js.map +1 -1
- package/dist/fleet/create-fleet.d.ts +2 -0
- package/dist/fleet/create-fleet.d.ts.map +1 -1
- package/dist/fleet/create-fleet.js +5 -1
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/internal-fleet-client.d.ts +1 -0
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +41 -1
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +52 -63
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +316 -323
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/fleet/relay-workspace-key.d.ts +5 -0
- package/dist/fleet/relay-workspace-key.d.ts.map +1 -1
- package/dist/fleet/relay-workspace-key.js +7 -0
- package/dist/fleet/relay-workspace-key.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +3 -1
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/node/factory-node.d.ts.map +1 -1
- package/dist/node/factory-node.js +14 -0
- package/dist/node/factory-node.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +203 -22
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/fleet.d.ts +12 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/testing/fakes.d.ts +16 -0
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +22 -0
- package/dist/testing/fakes.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- 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.
|
|
2253
|
-
//
|
|
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.#
|
|
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.
|
|
2275
|
-
//
|
|
2276
|
-
//
|
|
2277
|
-
//
|
|
2278
|
-
|
|
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
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
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);
|