@agent-relay/factory 0.1.15 → 0.1.17
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 +9 -4
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +13 -0
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +1 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/templates.js +3 -3
- package/dist/dispatch/templates.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/github/merge-gate.d.ts.map +1 -1
- package/dist/github/merge-gate.js +2 -0
- package/dist/github/merge-gate.js.map +1 -1
- package/dist/github/probe-closer.d.ts +3 -1
- package/dist/github/probe-closer.d.ts.map +1 -1
- package/dist/github/probe-closer.js +8 -8
- package/dist/github/probe-closer.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +3 -2
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +9 -2
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/relayfile-github-connection-write.d.ts +26 -0
- package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -0
- package/dist/mount/relayfile-github-connection-write.js +127 -0
- package/dist/mount/relayfile-github-connection-write.js.map +1 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +285 -21
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/fleet.d.ts +1 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/index.d.ts +1 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/mount.d.ts +27 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/testing/fakes.d.ts +3 -2
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +3 -1
- package/dist/testing/fakes.js.map +1 -1
- package/dist/writeback/github.d.ts +2 -0
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +2 -0
- package/dist/writeback/github.js.map +1 -1
- package/package.json +1 -1
|
@@ -156,6 +156,7 @@ export class FactoryLoop {
|
|
|
156
156
|
// Issue key -> the open PR the babysitter is shepherding, including the
|
|
157
157
|
// webhook-fed mount path so readiness can re-read PR meta without a gh call.
|
|
158
158
|
#babysitterPr = new Map();
|
|
159
|
+
#publishedPullRequests = new Set();
|
|
159
160
|
#probePrGhBackoffUntilMs = new Map();
|
|
160
161
|
#probePrResolvedCache = new Map();
|
|
161
162
|
// GitHub issue mirror-id -> resolved Linear mirror path, so repeat ingestion
|
|
@@ -2195,6 +2196,19 @@ export class FactoryLoop {
|
|
|
2195
2196
|
}
|
|
2196
2197
|
const exiting = record.agents.get(name);
|
|
2197
2198
|
if (isCompletionReason(reason)) {
|
|
2199
|
+
let publishedPr;
|
|
2200
|
+
if (exiting?.spec.role === 'implementer' &&
|
|
2201
|
+
!record.dryRun &&
|
|
2202
|
+
(this.#mount.githubWrite || this.#mount.writebackTransport === 'relayfile-cloud')) {
|
|
2203
|
+
try {
|
|
2204
|
+
publishedPr = await this.#publishImplementerPullRequest(record, exiting);
|
|
2205
|
+
}
|
|
2206
|
+
catch (error) {
|
|
2207
|
+
this.#increment('githubPullRequestPublishFailures');
|
|
2208
|
+
this.#error(error, record.issue);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2198
2212
|
if (this.#config.babysitter.enabled && !record.dryRun) {
|
|
2199
2213
|
// Babysitter path: an implementer/reviewer finishing does NOT mark the
|
|
2200
2214
|
// issue done — it hands the open PR to the babysitter. The babysitter
|
|
@@ -2203,6 +2217,13 @@ export class FactoryLoop {
|
|
|
2203
2217
|
if (exiting?.spec.role === 'babysitter') {
|
|
2204
2218
|
await this.#maybeAdvanceToHumanReview(record);
|
|
2205
2219
|
}
|
|
2220
|
+
else if (publishedPr) {
|
|
2221
|
+
await this.#ensureBabysitter(record, {
|
|
2222
|
+
repo: publishedPr.repo,
|
|
2223
|
+
prNumber: publishedPr.number,
|
|
2224
|
+
url: publishedPr.url,
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2206
2227
|
else {
|
|
2207
2228
|
await this.#ensureBabysitterForIssue(record);
|
|
2208
2229
|
}
|
|
@@ -2224,14 +2245,40 @@ export class FactoryLoop {
|
|
|
2224
2245
|
await this.#completeIssue(record);
|
|
2225
2246
|
return;
|
|
2226
2247
|
}
|
|
2248
|
+
// The implementer's turn ended without a PR of record. Agents reliably
|
|
2249
|
+
// COMMIT their work to a feature branch but often exit (turn-end / idle)
|
|
2250
|
+
// before running `gh pr create` — the root reason a dispatch never reached
|
|
2251
|
+
// human-review even after the #67 fixes. Rather than respawn a done agent
|
|
2252
|
+
// and hope, factory finalizes the branch into a PR itself (the same
|
|
2253
|
+
// publish path the completion flow uses), then advances to the babysitter
|
|
2254
|
+
// / human-review path. Best-effort: with no publishable branch (no commits
|
|
2255
|
+
// ahead of base, clone gone) it returns undefined and we fall through.
|
|
2256
|
+
if (tracked.spec.role === 'implementer') {
|
|
2257
|
+
const publishedPr = await this.#tryPublishImplementerPr(record, tracked);
|
|
2258
|
+
if (publishedPr) {
|
|
2259
|
+
if (this.#config.babysitter.enabled) {
|
|
2260
|
+
await this.#ensureBabysitter(record, {
|
|
2261
|
+
repo: publishedPr.repo,
|
|
2262
|
+
prNumber: publishedPr.number,
|
|
2263
|
+
url: publishedPr.url,
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
else {
|
|
2267
|
+
await this.#completeIssue(record);
|
|
2268
|
+
}
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2227
2272
|
if (tracked.sessionRef) {
|
|
2228
2273
|
const resumeKey = `${issueKey(record.issue)}:${name}:${tracked.sessionRef}`;
|
|
2229
2274
|
if (await this.#state.isResumed(this.#workspaceId, resumeKey)) {
|
|
2230
2275
|
// Already resumed once and STILL exiting with no completion PR — the
|
|
2231
|
-
// agent isn't making progress.
|
|
2232
|
-
//
|
|
2276
|
+
// agent isn't making progress. Conclude the dispatch so a human
|
|
2277
|
+
// notices AND the reviewer waiting on the implementer's DM is torn
|
|
2278
|
+
// down, instead of leaving the issue silently in-flight forever with
|
|
2279
|
+
// a live reviewer stalling the owned-broker dispose-wait (#67).
|
|
2233
2280
|
if (tracked.spec.role === 'implementer') {
|
|
2234
|
-
await this.#
|
|
2281
|
+
await this.#concludeTerminalImplementer(record, name, 'stalled-no-pr');
|
|
2235
2282
|
}
|
|
2236
2283
|
return;
|
|
2237
2284
|
}
|
|
@@ -2250,17 +2297,24 @@ export class FactoryLoop {
|
|
|
2250
2297
|
if (isAgentAlreadyExistsError(error)) {
|
|
2251
2298
|
// The broker never released this agent's name on exit
|
|
2252
2299
|
// (relay#1116-family), so re-registering collides with the stuck
|
|
2253
|
-
// name.
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2256
|
-
//
|
|
2257
|
-
|
|
2300
|
+
// name. Retrying just re-collides forever. Treat it as terminal for
|
|
2301
|
+
// this name: record the resume key so subsequent exit events
|
|
2302
|
+
// short-circuit, count it, and warn once. The external reaper / a
|
|
2303
|
+
// broker restart reclaims the leaked name.
|
|
2304
|
+
this.#fleet.markAgentTerminal?.(name, 'resume-already-exists');
|
|
2258
2305
|
await this.#state.markResumed(this.#workspaceId, resumeKey);
|
|
2259
2306
|
this.#increment('resumeNameCollisions');
|
|
2260
2307
|
this.#logger.warn?.('[factory] resume skipped: broker still holds agent name (relay#1116); not retrying', {
|
|
2261
2308
|
issue: record.issue.key,
|
|
2262
2309
|
name,
|
|
2263
2310
|
});
|
|
2311
|
+
// The implementer is now terminal but never sent the completion DM
|
|
2312
|
+
// the reviewer is blocked on ("Wait for a DM from the
|
|
2313
|
+
// implementer(s)"). Resolve the dispatch so the reviewer does not
|
|
2314
|
+
// stay live forever and stall the owned-broker dispose-wait (#67).
|
|
2315
|
+
if (tracked.spec.role === 'implementer') {
|
|
2316
|
+
await this.#concludeTerminalImplementer(record, name, 'resume-already-exists');
|
|
2317
|
+
}
|
|
2264
2318
|
}
|
|
2265
2319
|
else {
|
|
2266
2320
|
throw error;
|
|
@@ -2272,25 +2326,141 @@ export class FactoryLoop {
|
|
|
2272
2326
|
}
|
|
2273
2327
|
else {
|
|
2274
2328
|
const invocationId = `${batch.invocationIdFor(record.issue, tracked.spec)}:restart:${this.#clock.now()}`;
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2329
|
+
try {
|
|
2330
|
+
const result = await this.#fleet.spawn({
|
|
2331
|
+
name: tracked.spec.name,
|
|
2332
|
+
capability: tracked.spec.capability,
|
|
2333
|
+
node: tracked.spec.node ?? 'self',
|
|
2334
|
+
task: tracked.spec.task,
|
|
2335
|
+
model: tracked.spec.model,
|
|
2336
|
+
cwd: tracked.spec.clonePath,
|
|
2337
|
+
sessionRef: tracked.spec.sessionRef,
|
|
2338
|
+
invocationId,
|
|
2339
|
+
restartPolicy: defaultRestartPolicy(tracked.spec),
|
|
2340
|
+
channel: tracked.spec.channel,
|
|
2341
|
+
});
|
|
2342
|
+
batch.recordSpawn(record, tracked.spec, invocationId, result);
|
|
2343
|
+
}
|
|
2344
|
+
catch (error) {
|
|
2345
|
+
if (!isAgentAlreadyExistsError(error)) {
|
|
2346
|
+
throw error;
|
|
2347
|
+
}
|
|
2348
|
+
// Same leaked-broker-name collision as the resume path, on a
|
|
2349
|
+
// no-sessionRef respawn: the broker's own restartPolicy already
|
|
2350
|
+
// re-registered the name (relay#1116-family), so our respawn collides.
|
|
2351
|
+
// Retrying just re-collides forever. Treat it as terminal and resolve
|
|
2352
|
+
// the dispatch so a reviewer blocked on the implementer's DM does not
|
|
2353
|
+
// hang the owned-broker dispose-wait (#67).
|
|
2354
|
+
this.#fleet.markAgentTerminal?.(name, 'respawn-already-exists');
|
|
2355
|
+
this.#increment('resumeNameCollisions');
|
|
2356
|
+
this.#logger.warn?.('[factory] respawn skipped: broker still holds agent name (relay#1116); not retrying', {
|
|
2357
|
+
issue: record.issue.key,
|
|
2358
|
+
name,
|
|
2359
|
+
});
|
|
2360
|
+
if (tracked.spec.role === 'implementer') {
|
|
2361
|
+
await this.#concludeTerminalImplementer(record, name, 'respawn-already-exists');
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2288
2364
|
}
|
|
2289
2365
|
}
|
|
2290
2366
|
catch (error) {
|
|
2291
2367
|
this.#error(error, record.issue);
|
|
2292
2368
|
}
|
|
2293
2369
|
}
|
|
2370
|
+
// Publish a PR from the implementer's committed branch when it exited without
|
|
2371
|
+
// opening one. Best-effort and idempotent: returns undefined when there is no
|
|
2372
|
+
// GitHub write path, no clone, or nothing publishable (no branch / no commits
|
|
2373
|
+
// ahead of base — `#publishImplementerPullRequest` refuses head==base), so the
|
|
2374
|
+
// caller falls back to its normal restart/conclude handling.
|
|
2375
|
+
async #tryPublishImplementerPr(record, implementer) {
|
|
2376
|
+
if (record.dryRun || !implementer.spec.clonePath || !this.#mount.githubWrite) {
|
|
2377
|
+
return undefined;
|
|
2378
|
+
}
|
|
2379
|
+
try {
|
|
2380
|
+
const published = await this.#publishImplementerPullRequest(record, implementer);
|
|
2381
|
+
if (published) {
|
|
2382
|
+
this.#increment('implementerPrsPublishedOnExit');
|
|
2383
|
+
this.#logger.info?.('[factory] published PR from implementer clone after it exited without opening one', {
|
|
2384
|
+
issue: record.issue.key,
|
|
2385
|
+
repo: published.repo,
|
|
2386
|
+
prNumber: published.number,
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
return published;
|
|
2390
|
+
}
|
|
2391
|
+
catch (error) {
|
|
2392
|
+
this.#increment('exitPrPublishSkipped');
|
|
2393
|
+
this.#logger.warn?.('[factory] could not publish implementer PR on exit; falling back', {
|
|
2394
|
+
issue: record.issue.key,
|
|
2395
|
+
name: implementer.result?.name ?? implementer.spec.name,
|
|
2396
|
+
error: describeError(error).errorMessage,
|
|
2397
|
+
});
|
|
2398
|
+
return undefined;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
async #publishImplementerPullRequest(record, implementer) {
|
|
2402
|
+
const key = `${issueKey(record.issue)}:${implementer.spec.repo}`;
|
|
2403
|
+
if (this.#publishedPullRequests.has(key))
|
|
2404
|
+
return undefined;
|
|
2405
|
+
const githubWrite = this.#mount.githubWrite;
|
|
2406
|
+
if (!githubWrite) {
|
|
2407
|
+
throw new Error('GitHub write path not available on this mount — connect GitHub to your workspace');
|
|
2408
|
+
}
|
|
2409
|
+
if (!implementer.spec.clonePath) {
|
|
2410
|
+
throw new Error(`GitHub PR publication requires a configured clone path for ${implementer.spec.repo}`);
|
|
2411
|
+
}
|
|
2412
|
+
const issue = await this.#readIssue(record.issue.path);
|
|
2413
|
+
if (!issue) {
|
|
2414
|
+
throw new Error(`Unable to publish GitHub PR: issue ${record.issue.key} is no longer readable`);
|
|
2415
|
+
}
|
|
2416
|
+
const sourceRepo = githubMirrorRepoForIssue(issue);
|
|
2417
|
+
const sourceRepoParts = sourceRepo ? githubRepoParts(sourceRepo) : undefined;
|
|
2418
|
+
const bareRepoName = implementer.spec.repo.includes('/') ? undefined : implementer.spec.repo;
|
|
2419
|
+
const sourceOwner = bareRepoName && sourceRepoParts?.repo === bareRepoName
|
|
2420
|
+
? sourceRepoParts.owner
|
|
2421
|
+
: undefined;
|
|
2422
|
+
const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
|
|
2423
|
+
const baseRef = await this.#githubDefaultBranch(repo);
|
|
2424
|
+
const result = await githubWrite.publishPullRequest({
|
|
2425
|
+
repo,
|
|
2426
|
+
clonePath: implementer.spec.clonePath,
|
|
2427
|
+
baseRef,
|
|
2428
|
+
title: `${issue.key}: ${issue.title}`,
|
|
2429
|
+
body: githubPullRequestBody(issue),
|
|
2430
|
+
});
|
|
2431
|
+
this.#publishedPullRequests.add(key);
|
|
2432
|
+
this.#increment('githubPullRequestsPublished');
|
|
2433
|
+
this.#logger.info?.('[factory] published PR through workspace GitHub connection', {
|
|
2434
|
+
issue: issue.key,
|
|
2435
|
+
repo: result.repo,
|
|
2436
|
+
prNumber: result.number,
|
|
2437
|
+
url: result.url,
|
|
2438
|
+
});
|
|
2439
|
+
return result;
|
|
2440
|
+
}
|
|
2441
|
+
async #githubDefaultBranch(repo) {
|
|
2442
|
+
const parts = githubRepoParts(repo);
|
|
2443
|
+
if (!parts) {
|
|
2444
|
+
throw new Error(`GitHub repo must be owner/repo before resolving its default branch: ${repo}`);
|
|
2445
|
+
}
|
|
2446
|
+
const path = `/github/repos/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}/meta.json`;
|
|
2447
|
+
let payload;
|
|
2448
|
+
try {
|
|
2449
|
+
payload = wrappedPayload((await this.#mount.readFile(path)).content);
|
|
2450
|
+
}
|
|
2451
|
+
catch (error) {
|
|
2452
|
+
throw new Error(`Unable to resolve the default branch for ${repo} from ${path}: ${describeError(error).errorMessage}`);
|
|
2453
|
+
}
|
|
2454
|
+
const repository = asRecord(payload.repository);
|
|
2455
|
+
const defaultBranch = (stringValue(payload.defaultBranch) ??
|
|
2456
|
+
stringValue(payload.default_branch) ??
|
|
2457
|
+
stringValue(repository?.defaultBranch) ??
|
|
2458
|
+
stringValue(repository?.default_branch))?.trim();
|
|
2459
|
+
if (!defaultBranch) {
|
|
2460
|
+
throw new Error(`GitHub repository metadata for ${repo} does not include a default branch`);
|
|
2461
|
+
}
|
|
2462
|
+
return defaultBranch;
|
|
2463
|
+
}
|
|
2294
2464
|
// An implementer that exited, was resumed once, and STILL produced no PR is
|
|
2295
2465
|
// not making progress. Surface it (counter + a best-effort Slack note to the
|
|
2296
2466
|
// dispatch thread) so a human can step in, instead of the issue sitting
|
|
@@ -2312,6 +2482,75 @@ export class FactoryLoop {
|
|
|
2312
2482
|
this.#logger.warn?.('[factory] failed to post stalled-issue escalation to Slack', error);
|
|
2313
2483
|
}
|
|
2314
2484
|
}
|
|
2485
|
+
// An implementer that collided on its leaked broker name, or that exited a
|
|
2486
|
+
// second time with no PR, is terminal: it will never send the completion DM
|
|
2487
|
+
// the reviewer is blocked on (its prompt is literally "Wait for a DM from the
|
|
2488
|
+
// implementer(s)"). Left alone, the reviewer stays live forever and the
|
|
2489
|
+
// owned-broker dispose-wait stalls until FACTORY_AGENT_EXIT_TIMEOUT_MS
|
|
2490
|
+
// (default 30 min), so the issue never reaches human-review (#67). Signal any
|
|
2491
|
+
// waiting reviewer(s) so they can proceed, then resolve the dispatch
|
|
2492
|
+
// deterministically: complete to the terminal state when a PR is now visible
|
|
2493
|
+
// (mount lag at exit time is why we fell through to resume in the first
|
|
2494
|
+
// place), otherwise escalate and tear down the stuck agents so dispose drains.
|
|
2495
|
+
async #concludeTerminalImplementer(record, implementerName, reason) {
|
|
2496
|
+
await this.#signalReviewersImplementerDone(record, implementerName, reason);
|
|
2497
|
+
if (await this.#issueHasCompletionPr(record)) {
|
|
2498
|
+
await this.#completeIssue(record);
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
await this.#escalateStalledIssue(record, implementerName);
|
|
2502
|
+
await this.#abandonStuckDispatch(record, reason);
|
|
2503
|
+
}
|
|
2504
|
+
// Deliver a synthetic "implementer done" DM so a reviewer blocked on the
|
|
2505
|
+
// implementer's message unblocks and proceeds (review the PR if one is open,
|
|
2506
|
+
// otherwise conclude). Best-effort: the deterministic teardown below is the
|
|
2507
|
+
// backstop when the reviewer cannot act on it.
|
|
2508
|
+
async #signalReviewersImplementerDone(record, implementerName, reason) {
|
|
2509
|
+
const reviewers = new Set([...record.agents.values()]
|
|
2510
|
+
.filter((agent) => agent.spec.role === 'reviewer')
|
|
2511
|
+
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
2512
|
+
.filter((name) => Boolean(name)));
|
|
2513
|
+
for (const reviewer of reviewers) {
|
|
2514
|
+
try {
|
|
2515
|
+
await this.#fleet.sendMessage({
|
|
2516
|
+
to: reviewer,
|
|
2517
|
+
from: implementerName,
|
|
2518
|
+
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.`,
|
|
2519
|
+
});
|
|
2520
|
+
this.#increment('reviewerImplementerTerminatedSignals');
|
|
2521
|
+
}
|
|
2522
|
+
catch (error) {
|
|
2523
|
+
this.#logger.warn?.('[factory] failed to signal reviewer of implementer termination', {
|
|
2524
|
+
issue: record.issue.key,
|
|
2525
|
+
reviewer,
|
|
2526
|
+
error,
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
// Tear down agents still live after a terminal implementer produced no PR —
|
|
2532
|
+
// chiefly the reviewer, blocked on a DM that will never arrive — and free the
|
|
2533
|
+
// batch slot, so the owned-broker dispose-wait drains instead of stalling for
|
|
2534
|
+
// the full agent-exit timeout. Marking each agent terminal first suppresses
|
|
2535
|
+
// the release-driven exit event so it cannot re-trigger a resume before the
|
|
2536
|
+
// record leaves the batch.
|
|
2537
|
+
async #abandonStuckDispatch(record, reason) {
|
|
2538
|
+
const remaining = [...record.agents].filter(([, tracked]) => tracked.spec.role !== 'implementer');
|
|
2539
|
+
for (const [agentName] of remaining) {
|
|
2540
|
+
this.#fleet.markAgentTerminal?.(agentName, `implementer-terminal:${reason}`);
|
|
2541
|
+
}
|
|
2542
|
+
if (remaining.length > 0) {
|
|
2543
|
+
await this.#releaseAndTerminateAgents(remaining, 'issue-abandoned', 'completion');
|
|
2544
|
+
}
|
|
2545
|
+
await this.#recordDispatchTerminal(record.issue);
|
|
2546
|
+
const next = (await this.#batch()).complete(record.issue);
|
|
2547
|
+
await this.#stopSlackWatcher(record.issue);
|
|
2548
|
+
await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
|
|
2549
|
+
await this.#writeInFlightRegistry();
|
|
2550
|
+
if (next) {
|
|
2551
|
+
await this.dispatch(next.decision, { dryRun: next.dryRun });
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2315
2554
|
async #issueHasCompletionPr(record) {
|
|
2316
2555
|
try {
|
|
2317
2556
|
const issue = await this.#readIssue(record.issue.path);
|
|
@@ -3410,6 +3649,10 @@ export class FactoryLoop {
|
|
|
3410
3649
|
this.#probePrResolvedCache.delete(completionKey);
|
|
3411
3650
|
this.#babysitterSpawned.delete(record.issue.key);
|
|
3412
3651
|
this.#babysitterPr.delete(record.issue.key);
|
|
3652
|
+
for (const publishedKey of this.#publishedPullRequests) {
|
|
3653
|
+
if (publishedKey.startsWith(`${record.issue.key}:`))
|
|
3654
|
+
this.#publishedPullRequests.delete(publishedKey);
|
|
3655
|
+
}
|
|
3413
3656
|
}
|
|
3414
3657
|
}
|
|
3415
3658
|
#emit(event, payload) {
|
|
@@ -4261,6 +4504,7 @@ export class FactoryLoop {
|
|
|
4261
4504
|
prNumber: probe.prNumber,
|
|
4262
4505
|
expectedIssueKey: issue.key,
|
|
4263
4506
|
requireTitleMarker: false,
|
|
4507
|
+
...(this.#mount.githubWrite ? { githubWrite: this.#mount.githubWrite } : {}),
|
|
4264
4508
|
});
|
|
4265
4509
|
this.#increment('mergeGateSyntheticClosed');
|
|
4266
4510
|
}
|
|
@@ -5310,8 +5554,12 @@ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
|
|
|
5310
5554
|
if (/^\/slack\/channels\/[^/]+\/messages\/.+/u.test(path)) {
|
|
5311
5555
|
return true;
|
|
5312
5556
|
}
|
|
5557
|
+
if (isFactoryGithubWritebackPath(path)) {
|
|
5558
|
+
return true;
|
|
5559
|
+
}
|
|
5313
5560
|
return false;
|
|
5314
5561
|
};
|
|
5562
|
+
const isFactoryGithubWritebackPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/refs%2Fheads%2F[^/]+\.json|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
|
|
5315
5563
|
const isIssuePathInFactoryScope = async (mount, path, config) => {
|
|
5316
5564
|
try {
|
|
5317
5565
|
return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
|
|
@@ -5469,6 +5717,22 @@ const labelName = (value) => {
|
|
|
5469
5717
|
return stringValue(record?.name);
|
|
5470
5718
|
};
|
|
5471
5719
|
const isCompletionReason = (reason) => reason === 'issue-done' || reason === 'done' || reason === 'completed';
|
|
5720
|
+
const normalizeGithubRepo = (repo, defaultOwner) => {
|
|
5721
|
+
if (repo.includes('/'))
|
|
5722
|
+
return repo;
|
|
5723
|
+
const owner = defaultOwner?.trim();
|
|
5724
|
+
if (!owner) {
|
|
5725
|
+
throw new Error(`GitHub repository owner is required for bare repo route: ${repo}; set repos.org or use owner/repo`);
|
|
5726
|
+
}
|
|
5727
|
+
return `${owner}/${repo}`;
|
|
5728
|
+
};
|
|
5729
|
+
const githubPullRequestBody = (issue) => [
|
|
5730
|
+
issue.description,
|
|
5731
|
+
'',
|
|
5732
|
+
isGithubIssue(issue) && /^\d+$/u.test(issue.key)
|
|
5733
|
+
? `Fixes #${issue.key}`
|
|
5734
|
+
: `Factory issue ${issue.key}`,
|
|
5735
|
+
].join('\n').trim();
|
|
5472
5736
|
// The broker rejects re-registering a name it never released on exit
|
|
5473
5737
|
// (relay#1116-family) with a 500 "agent '<name>' already exists". Detect it from
|
|
5474
5738
|
// the structured payload or the message so resume can treat it as terminal
|