@agent-relay/factory 0.1.68 → 0.1.70
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/dist/cli/fleet.d.ts +31 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +59 -3
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +18 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +5 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/work-unit-identity.d.ts +18 -1
- package/dist/dispatch/work-unit-identity.d.ts.map +1 -1
- package/dist/dispatch/work-unit-identity.js +68 -0
- package/dist/dispatch/work-unit-identity.js.map +1 -1
- package/dist/fleet/create-fleet.d.ts +8 -1
- package/dist/fleet/create-fleet.d.ts.map +1 -1
- package/dist/fleet/create-fleet.js +3 -1
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +44 -0
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +221 -5
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/issue-key-match.d.ts +47 -0
- package/dist/issue-key-match.d.ts.map +1 -1
- package/dist/issue-key-match.js +174 -0
- package/dist/issue-key-match.js.map +1 -1
- package/dist/mount/relayfile-integration-preflight.js +7 -0
- package/dist/mount/relayfile-integration-preflight.js.map +1 -1
- package/dist/orchestrator/atomic-json-file.d.ts +44 -0
- package/dist/orchestrator/atomic-json-file.d.ts.map +1 -0
- package/dist/orchestrator/atomic-json-file.js +87 -0
- package/dist/orchestrator/atomic-json-file.js.map +1 -0
- package/dist/orchestrator/batch-tracker.d.ts +18 -0
- package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
- package/dist/orchestrator/batch-tracker.js +28 -13
- package/dist/orchestrator/batch-tracker.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +456 -92
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/public-health.d.ts.map +1 -1
- package/dist/orchestrator/public-health.js +115 -13
- package/dist/orchestrator/public-health.js.map +1 -1
- package/dist/ports/state.d.ts +21 -2
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/ports/state.js +18 -1
- package/dist/ports/state.js.map +1 -1
- package/dist/state/file-state-store.d.ts.map +1 -1
- package/dist/state/file-state-store.js +54 -11
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/state/github-lifecycle-identity.d.ts +4 -4
- package/dist/state/github-lifecycle-identity.d.ts.map +1 -1
- package/dist/state/github-lifecycle-identity.js +4 -26
- package/dist/state/github-lifecycle-identity.js.map +1 -1
- package/dist/state/in-memory-state-store.d.ts.map +1 -1
- package/dist/state/in-memory-state-store.js +40 -9
- package/dist/state/in-memory-state-store.js.map +1 -1
- package/dist/state/work-unit-lifecycle-migration.d.ts +85 -0
- package/dist/state/work-unit-lifecycle-migration.d.ts.map +1 -0
- package/dist/state/work-unit-lifecycle-migration.js +246 -0
- package/dist/state/work-unit-lifecycle-migration.js.map +1 -0
- package/dist/triage/heuristic.d.ts.map +1 -1
- package/dist/triage/heuristic.js +3 -1
- package/dist/triage/heuristic.js.map +1 -1
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
4
4
|
import { DEFAULT_READINESS_RECONCILE_TIMEOUT_MS, FactoryConfigSchema } from '../config/schema.js';
|
|
5
5
|
import { linearByStatePath, linearByIdPath, linearByUuidPath } from '../constants/linear.js';
|
|
@@ -10,17 +10,18 @@ import { VerificationPipeline } from '../environments/verification-pipeline.js';
|
|
|
10
10
|
import { factoryWorktreeIssueSlug, factoryWorktreePath } from '../git/agent-worktree.js';
|
|
11
11
|
import { InMemoryStateStore } from '../state/in-memory-state-store.js';
|
|
12
12
|
import { dispatchHandedOffToBabysitters, dispatchLifecycleOccupiesSlot, dispatchPhaseOccupiesSlot, } from '../state/dispatch-lifecycle-slot.js';
|
|
13
|
-
import { containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsToIssue } from '../issue-key-match.js';
|
|
13
|
+
import { branchImplementsIssue, containsExplicitIssueReference, containsIssueKey, factoryBranchBelongsToIssue, prBodyDisclaimsClosing, prClosureAuthority } from '../issue-key-match.js';
|
|
14
14
|
import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValue } from '../logging.js';
|
|
15
15
|
import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
16
16
|
import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
|
|
17
|
-
import { dispatchAgentIdentityKey } from '../dispatch/work-unit-identity.js';
|
|
17
|
+
import { dispatchAgentIdentityKey, dispatchIssueIdentity, mirrorWorkUnitOrigin } from '../dispatch/work-unit-identity.js';
|
|
18
18
|
import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
|
|
19
19
|
import { parseGithubHumanInputRequest, renderAgentTask, } from '../dispatch/templates.js';
|
|
20
20
|
import { resolveTestGuidance } from '../dispatch/test-guidance.js';
|
|
21
21
|
import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels, swarmChannel, swarmMemberSlugs, swarmTaskFor } from '../triage/index.js';
|
|
22
22
|
import { agentNameForRole, sanitizeAgentSlug } from '../triage/agent-names.js';
|
|
23
23
|
import { isResourceSubscriptionsUnavailable } from '../subscriptions/index.js';
|
|
24
|
+
import { DispatchLifecycleMigrationConflictError } from '../ports/state.js';
|
|
24
25
|
import { AppGithubWriteback, FACTORY_GITHUB_STATUS_LABELS, GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
25
26
|
import { parseSlackThreadReply, slackThreadReplyGlob } from '../subscriptions/slack-filter.js';
|
|
26
27
|
import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
|
|
@@ -28,6 +29,7 @@ import { issueKey, } from './batch-tracker.js';
|
|
|
28
29
|
import { dependencyIdentity, findDependencyCycle, parseBlockedBy, resolveDependency, } from './dependencies.js';
|
|
29
30
|
import { CoalescedTaskQueue } from './coalesced-task-queue.js';
|
|
30
31
|
import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
|
|
32
|
+
import { writeJsonFileAtomically } from './atomic-json-file.js';
|
|
31
33
|
import { readFactoryInFlightRegistry, terminatePids } from './reaper.js';
|
|
32
34
|
import { createFactoryCloudEventV1, factoryCloudReleaseReasonV1, } from '../observability/events.js';
|
|
33
35
|
import { telemetryErrorClass } from '../observability/error-class.js';
|
|
@@ -482,6 +484,9 @@ export class FactoryLoop {
|
|
|
482
484
|
#liveHeartbeatInFlight = false;
|
|
483
485
|
#liveHeartbeatRefresh;
|
|
484
486
|
#liveHeartbeatLastWriteMs = 0;
|
|
487
|
+
#heartbeatStartedAtMs;
|
|
488
|
+
#progressSequence = 0;
|
|
489
|
+
#lastProgressAtMs;
|
|
485
490
|
#stoppingHeartbeatRefreshActive = false;
|
|
486
491
|
#readinessReconcileTimer;
|
|
487
492
|
#readinessReconcileInFlight;
|
|
@@ -643,7 +648,9 @@ export class FactoryLoop {
|
|
|
643
648
|
#started = false;
|
|
644
649
|
#startMode;
|
|
645
650
|
#stopping = false;
|
|
651
|
+
#readOnly;
|
|
646
652
|
constructor(config, ports) {
|
|
653
|
+
this.#readOnly = ports.readOnly ?? false;
|
|
647
654
|
this.#config = config;
|
|
648
655
|
this.#mount = ports.mount;
|
|
649
656
|
// Resolved role<->state mapping. The CLI injects a name-resolved, per-team
|
|
@@ -729,7 +736,14 @@ export class FactoryLoop {
|
|
|
729
736
|
}
|
|
730
737
|
},
|
|
731
738
|
});
|
|
732
|
-
|
|
739
|
+
// Read-only commands consume no fleet events, and subscribing is not free:
|
|
740
|
+
// the relay client mints this process's workspace identity to open the
|
|
741
|
+
// socket. Wiring it here unconditionally is what made `factory status`
|
|
742
|
+
// register an agent it then abandoned before presence (factory-cloud#55).
|
|
743
|
+
// The live paths wire in `#start()` instead, where the subscription is
|
|
744
|
+
// actually used and the identity is meant to persist.
|
|
745
|
+
if (!this.#readOnly)
|
|
746
|
+
this.#wireFleetEvents();
|
|
733
747
|
}
|
|
734
748
|
async #resolveIntegrationInstructions() {
|
|
735
749
|
if (this.#integrationInstructionsRefresh) {
|
|
@@ -807,7 +821,7 @@ export class FactoryLoop {
|
|
|
807
821
|
* row to wait on, or the wait ended because Factory is stopping.
|
|
808
822
|
*/
|
|
809
823
|
async waitForDispatchTerminal(issue) {
|
|
810
|
-
const key =
|
|
824
|
+
const key = dispatchLifecycleKey(issue);
|
|
811
825
|
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
812
826
|
// No durable row means this dispatch never claimed a lifecycle: a
|
|
813
827
|
// dependency park, a triage escalation, and a label refusal all return
|
|
@@ -893,6 +907,13 @@ export class FactoryLoop {
|
|
|
893
907
|
return observedPhase;
|
|
894
908
|
}
|
|
895
909
|
async start(opts = {}) {
|
|
910
|
+
// A read-only instance was built with no fleet event wiring and, in the
|
|
911
|
+
// CLI, with a fleet client that cannot register. Starting one would be a
|
|
912
|
+
// half-live daemon; refuse rather than let a future caller reintroduce the
|
|
913
|
+
// side effect this mode exists to remove.
|
|
914
|
+
if (this.#readOnly) {
|
|
915
|
+
throw new Error('Factory was constructed read-only and cannot be started');
|
|
916
|
+
}
|
|
896
917
|
if (this.#started) {
|
|
897
918
|
return;
|
|
898
919
|
}
|
|
@@ -1290,6 +1311,7 @@ export class FactoryLoop {
|
|
|
1290
1311
|
}
|
|
1291
1312
|
}
|
|
1292
1313
|
async #startLiveHeartbeat() {
|
|
1314
|
+
this.#beginHeartbeatLifetime();
|
|
1293
1315
|
this.#liveHeartbeatActive = true;
|
|
1294
1316
|
await this.#writeLiveHeartbeat('running');
|
|
1295
1317
|
this.#scheduleLiveHeartbeatRefresh();
|
|
@@ -1354,6 +1376,11 @@ export class FactoryLoop {
|
|
|
1354
1376
|
await this.#writeLoopHeartbeat(this.#config.loop.heartbeatPath, this.#config.loop.registryPath, status, 0, 0);
|
|
1355
1377
|
this.#liveHeartbeatLastWriteMs = this.#clock.now();
|
|
1356
1378
|
}
|
|
1379
|
+
#beginHeartbeatLifetime() {
|
|
1380
|
+
this.#heartbeatStartedAtMs = this.#clock.now();
|
|
1381
|
+
this.#progressSequence = 0;
|
|
1382
|
+
this.#lastProgressAtMs = undefined;
|
|
1383
|
+
}
|
|
1357
1384
|
#liveOptions(overrides) {
|
|
1358
1385
|
return {
|
|
1359
1386
|
transport: overrides.transport ?? this.#config.liveSubscription.transport,
|
|
@@ -1853,7 +1880,7 @@ export class FactoryLoop {
|
|
|
1853
1880
|
try {
|
|
1854
1881
|
const batch = await this.#batch();
|
|
1855
1882
|
const records = batch.inFlight
|
|
1856
|
-
.filter((record) => !record.dryRun && !this.#completionInFlight.has(
|
|
1883
|
+
.filter((record) => !record.dryRun && !this.#completionInFlight.has(dispatchLifecycleKey(record.issue)));
|
|
1857
1884
|
if (records.length === 0) {
|
|
1858
1885
|
return;
|
|
1859
1886
|
}
|
|
@@ -2009,7 +2036,7 @@ export class FactoryLoop {
|
|
|
2009
2036
|
async #orphanedPrMetaPaths(record) {
|
|
2010
2037
|
const wanted = [];
|
|
2011
2038
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
2012
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
2039
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
2013
2040
|
const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
|
|
2014
2041
|
for (const receipt of receipts)
|
|
2015
2042
|
wanted.push({ repo: receipt.repo, prNumber: receipt.number });
|
|
@@ -2294,6 +2321,10 @@ export class FactoryLoop {
|
|
|
2294
2321
|
leaseReleased = completed;
|
|
2295
2322
|
if (!completed)
|
|
2296
2323
|
throw new Error('discovery sweep lease was lost before completion');
|
|
2324
|
+
// This is the progress boundary consumed by deployment health. A timer
|
|
2325
|
+
// firing, a sweep starting, or a failed/deferred sweep must not move it.
|
|
2326
|
+
this.#progressSequence += 1;
|
|
2327
|
+
this.#lastProgressAtMs = this.#clock.now();
|
|
2297
2328
|
this.#logger.info?.('[factory] discovery sweep checkpoint committed', {
|
|
2298
2329
|
owner: claim.lease.owner,
|
|
2299
2330
|
epoch: claim.lease.epoch,
|
|
@@ -2560,12 +2591,13 @@ export class FactoryLoop {
|
|
|
2560
2591
|
const labels = isGithubIssue(issue)
|
|
2561
2592
|
? new Set(issue.labels.map((label) => label.trim().toLowerCase()))
|
|
2562
2593
|
: undefined;
|
|
2563
|
-
|
|
2594
|
+
// Must stay the same scope test as dispatch and as
|
|
2595
|
+
// #reconcileOrphanedGithubInProgress. Gating on the scope label alone
|
|
2596
|
+
// left every title-scoped issue stuck in `factory:in-progress` forever.
|
|
2564
2597
|
const mayRecoverGithubOrphan = !wasReady &&
|
|
2565
2598
|
!dryRun &&
|
|
2566
2599
|
issueSource === 'github' &&
|
|
2567
|
-
|
|
2568
|
-
Boolean(labels?.has(requiredLabel)) &&
|
|
2600
|
+
isInFactoryScope(issue, this.#config.safety) &&
|
|
2569
2601
|
Boolean(labels?.has('factory:in-progress')) &&
|
|
2570
2602
|
!labels?.has('factory:human-review');
|
|
2571
2603
|
if (!mayRecoverGithubOrphan) {
|
|
@@ -3182,9 +3214,13 @@ export class FactoryLoop {
|
|
|
3182
3214
|
if (!isGithubIssue(issue))
|
|
3183
3215
|
return { recovered: false, reason: 'issue is not GitHub-native' };
|
|
3184
3216
|
const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3217
|
+
// Recovery must admit exactly what dispatch admits. Dispatch accepts the
|
|
3218
|
+
// configured title prefix OR the scope label (`isInFactoryScope`), but this
|
|
3219
|
+
// gate used to demand the label alone — so an issue admitted by its title
|
|
3220
|
+
// could be dispatched and then never un-stuck, keeping `factory:in-progress`
|
|
3221
|
+
// forever once its dispatch died. factory#139 is the live instance: title
|
|
3222
|
+
// `[factory] ...`, and its only label is `factory:in-progress`.
|
|
3223
|
+
if (!isInFactoryScope(issue, this.#config.safety) ||
|
|
3188
3224
|
!labels.has('factory:in-progress') ||
|
|
3189
3225
|
labels.has('factory:human-review'))
|
|
3190
3226
|
return { recovered: false, reason: 'issue is not an orphan-recovery candidate' };
|
|
@@ -3316,7 +3352,7 @@ export class FactoryLoop {
|
|
|
3316
3352
|
}
|
|
3317
3353
|
}
|
|
3318
3354
|
async #releaseOrphanedGithubLifecycle(issue, identity, candidate) {
|
|
3319
|
-
|
|
3355
|
+
const key = candidate.key;
|
|
3320
3356
|
let lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
3321
3357
|
if (!lifecycle)
|
|
3322
3358
|
return true;
|
|
@@ -3356,7 +3392,6 @@ export class FactoryLoop {
|
|
|
3356
3392
|
});
|
|
3357
3393
|
return false;
|
|
3358
3394
|
}
|
|
3359
|
-
key = claim.key ?? key;
|
|
3360
3395
|
lifecycle = claim.lifecycle;
|
|
3361
3396
|
epoch = claim.lease.epoch;
|
|
3362
3397
|
this.#dispatchLifecycleEpochs.set(key, epoch);
|
|
@@ -3499,7 +3534,7 @@ export class FactoryLoop {
|
|
|
3499
3534
|
const record = batch.start(decision, false);
|
|
3500
3535
|
if (!record) {
|
|
3501
3536
|
if (durableAdoption) {
|
|
3502
|
-
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
3537
|
+
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(decision.issue));
|
|
3503
3538
|
if (durable) {
|
|
3504
3539
|
this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(durable));
|
|
3505
3540
|
this.#increment('githubOrphanedPullRequestsAdopted');
|
|
@@ -3750,6 +3785,7 @@ export class FactoryLoop {
|
|
|
3750
3785
|
const maxConsecutiveFailures = Math.min(5, Math.max(1, Math.trunc(opts.maxConsecutiveFailures ?? this.#config.loop.maxConsecutiveFailures)));
|
|
3751
3786
|
const heartbeatPath = opts.heartbeatPath ?? this.#config.loop.heartbeatPath;
|
|
3752
3787
|
const registryPath = opts.registryPath ?? this.#config.loop.registryPath;
|
|
3788
|
+
this.#beginHeartbeatLifetime();
|
|
3753
3789
|
this.#loopReapPaths = { heartbeatPath, registryPath };
|
|
3754
3790
|
const reports = [];
|
|
3755
3791
|
let consecutiveFailures = 0;
|
|
@@ -3814,15 +3850,29 @@ export class FactoryLoop {
|
|
|
3814
3850
|
}
|
|
3815
3851
|
}
|
|
3816
3852
|
async triageIssue(issue) {
|
|
3817
|
-
return this.#
|
|
3853
|
+
return await this.#triageWithOrigin(issue);
|
|
3854
|
+
}
|
|
3855
|
+
/**
|
|
3856
|
+
* The mirror origin is a property of the issue Factory fed in, not of the
|
|
3857
|
+
* engine's answer, so it is re-attached here rather than trusted from the
|
|
3858
|
+
* engine. `TriageDecisionSchema` drops unknown keys, so `LlmTriage` returns a
|
|
3859
|
+
* decision that has lost it, and a custom `TriageEngine` never set it at all
|
|
3860
|
+
* — either way a Linear mirror would fall back to `linear:<uuid>` and
|
|
3861
|
+
* duplicate its own GitHub-native arrival, which is the defect #211 fixes.
|
|
3862
|
+
*/
|
|
3863
|
+
async #triageWithOrigin(issue) {
|
|
3864
|
+
const decision = await this.#triage.triage(issue, {
|
|
3818
3865
|
config: this.#config,
|
|
3819
3866
|
repoMap: repoMapFromConfig(this.#config),
|
|
3820
3867
|
});
|
|
3868
|
+
return decisionWithMirrorOrigin(decision, issue);
|
|
3821
3869
|
}
|
|
3822
3870
|
async dispatch(decision, opts = {}) {
|
|
3823
3871
|
const dryRun = opts.dryRun ?? this.#config.dryRun;
|
|
3824
3872
|
const phase = triageEscalationReason(decision) ? 'escalation' : 'dispatch';
|
|
3825
|
-
|
|
3873
|
+
// The dispatch concurrency gate is per work unit: a mirror of an issue
|
|
3874
|
+
// already being dispatched must join that call, not start a second one.
|
|
3875
|
+
const key = `${dispatchLifecycleKey(decision.issue)}:${dryRun ? 'dry-run' : 'live'}:${phase}`;
|
|
3826
3876
|
const inFlight = this.#dispatchInFlight.get(key);
|
|
3827
3877
|
if (inFlight) {
|
|
3828
3878
|
this.#increment('dispatchDuplicateSuppressed');
|
|
@@ -3850,9 +3900,9 @@ export class FactoryLoop {
|
|
|
3850
3900
|
return existingRecord.result;
|
|
3851
3901
|
}
|
|
3852
3902
|
if (!dryRun && this.#usesDurableDispatchLifecycle()) {
|
|
3853
|
-
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
3903
|
+
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(decision.issue));
|
|
3854
3904
|
if (durable && !isTerminalDispatchLifecycle(durable)) {
|
|
3855
|
-
if (durable.result && this.#dispatchLifecycleEpochs.has(
|
|
3905
|
+
if (durable.result && this.#dispatchLifecycleEpochs.has(dispatchLifecycleKey(decision.issue))) {
|
|
3856
3906
|
return durable.result;
|
|
3857
3907
|
}
|
|
3858
3908
|
if (this.#startMode === 'dispatch-owner') {
|
|
@@ -4107,7 +4157,7 @@ export class FactoryLoop {
|
|
|
4107
4157
|
state: 'pending',
|
|
4108
4158
|
updatedAtMs: this.#clock.now(),
|
|
4109
4159
|
};
|
|
4110
|
-
this.#dispatchClaimStatuses.set(
|
|
4160
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
4111
4161
|
}
|
|
4112
4162
|
await this.#writeInFlightRegistry();
|
|
4113
4163
|
const comment = dispatchComment(dispatchDecision, agents);
|
|
@@ -4115,10 +4165,37 @@ export class FactoryLoop {
|
|
|
4115
4165
|
if (!dryRun) {
|
|
4116
4166
|
const issue = await this.#readIssue(dispatchDecision.issue.path);
|
|
4117
4167
|
if (!issue || !this.#isIssueReady(issue)) {
|
|
4118
|
-
|
|
4168
|
+
// The agents spawned a few lines above can reach terminal before we
|
|
4169
|
+
// get here. Their completion writeback parks the issue — Linear
|
|
4170
|
+
// `humanReview`, or the GitHub human-review label — and stamps
|
|
4171
|
+
// *this same record's* lifecycle on the way past. Re-reading the
|
|
4172
|
+
// issue then shows "not ready", but the writer was us.
|
|
4173
|
+
//
|
|
4174
|
+
// Treating that as a foreign change is not a cosmetic misreport: the
|
|
4175
|
+
// catch below classifies LiveDispatchStateChangedError as terminal
|
|
4176
|
+
// and calls #releaseAndTerminateAgents, so a dispatch whose agents
|
|
4177
|
+
// finished quickly tore down its own completed work and reported
|
|
4178
|
+
// RETRYABLE to its supervisor (factory#319).
|
|
4179
|
+
//
|
|
4180
|
+
// Only THIS dispatch's own confirmed issue writeback may excuse the
|
|
4181
|
+
// change. Lifecycle phases are not enough: `publishing` is entered
|
|
4182
|
+
// before the PR is published and `parking` before anything touches
|
|
4183
|
+
// the issue, so a phase records local progress, not authorship — and
|
|
4184
|
+
// a foreign park landing during those awaits would be misread as
|
|
4185
|
+
// ours (codex review on #321). Losing the row to another owner is a
|
|
4186
|
+
// different condition and is still caught where it always was, by
|
|
4187
|
+
// #saveDispatchLifecycle returning false.
|
|
4188
|
+
if (record.issueWritebackConfirmedAtMs === undefined) {
|
|
4189
|
+
throw new LiveDispatchStateChangedError(dispatchDecision.issue.key);
|
|
4190
|
+
}
|
|
4191
|
+
// The claim is moot and would be wrong to write: it would drag an
|
|
4192
|
+
// issue our own lifecycle has already parked back to `implementing`.
|
|
4193
|
+
}
|
|
4194
|
+
else {
|
|
4195
|
+
implementingStateId = await this.#applyDispatchClaim(record, issue, comment);
|
|
4196
|
+
record.issueWritebackConfirmedAtMs = this.#clock.now();
|
|
4197
|
+
this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
|
|
4119
4198
|
}
|
|
4120
|
-
implementingStateId = await this.#applyDispatchClaim(record, issue, comment);
|
|
4121
|
-
this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
|
|
4122
4199
|
}
|
|
4123
4200
|
const result = {
|
|
4124
4201
|
issue: dispatchDecision.issue,
|
|
@@ -4245,7 +4322,7 @@ export class FactoryLoop {
|
|
|
4245
4322
|
...(tracked.result?.node ? { node: tracked.result.node } : {}),
|
|
4246
4323
|
})),
|
|
4247
4324
|
claim: {
|
|
4248
|
-
...(record.dispatchClaim ?? this.#dispatchClaimStatuses.get(
|
|
4325
|
+
...(record.dispatchClaim ?? this.#dispatchClaimStatuses.get(dispatchLifecycleKey(record.issue)) ?? {
|
|
4249
4326
|
state: 'pending',
|
|
4250
4327
|
updatedAtMs: nowMs,
|
|
4251
4328
|
}),
|
|
@@ -4475,7 +4552,25 @@ export class FactoryLoop {
|
|
|
4475
4552
|
if (previews.length > 0)
|
|
4476
4553
|
this.#previewReferences.set(issueKey(lifecycle.issue), previews);
|
|
4477
4554
|
hasNonterminalDurableLifecycle = true;
|
|
4478
|
-
|
|
4555
|
+
// A work unit whose keys cannot be reconciled must not strand the rows
|
|
4556
|
+
// behind it — #315 is the same lesson from the shed-read path. Retain
|
|
4557
|
+
// the conflicting rows, leave that one unit blocked for an operator,
|
|
4558
|
+
// and carry on adopting the rest.
|
|
4559
|
+
let claim;
|
|
4560
|
+
try {
|
|
4561
|
+
claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, lifecycle, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
|
|
4562
|
+
}
|
|
4563
|
+
catch (error) {
|
|
4564
|
+
if (!(error instanceof DispatchLifecycleMigrationConflictError))
|
|
4565
|
+
throw error;
|
|
4566
|
+
this.#increment('dispatchLifecycleMigrationConflicts');
|
|
4567
|
+
this.#logger.error?.('[factory] dispatch is blocked for one work unit until its keys are reconciled', {
|
|
4568
|
+
issue: lifecycle.issue.key,
|
|
4569
|
+
canonicalKey: error.canonicalKey,
|
|
4570
|
+
conflictingKeys: error.conflictingKeys,
|
|
4571
|
+
});
|
|
4572
|
+
continue;
|
|
4573
|
+
}
|
|
4479
4574
|
if (!claim.acquired || !claim.lease) {
|
|
4480
4575
|
// The other process may have crashed while its nominal lease is
|
|
4481
4576
|
// still live. Keep this process attached so it reclaims the row
|
|
@@ -4483,7 +4578,7 @@ export class FactoryLoop {
|
|
|
4483
4578
|
this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(claim.lifecycle));
|
|
4484
4579
|
continue;
|
|
4485
4580
|
}
|
|
4486
|
-
this.#dispatchLifecycleEpochs.set(
|
|
4581
|
+
this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
|
|
4487
4582
|
this.#hydrateCostLedger(claim.lifecycle);
|
|
4488
4583
|
if (claim.lifecycle.phase === 'waiting-for-human')
|
|
4489
4584
|
continue;
|
|
@@ -4492,7 +4587,37 @@ export class FactoryLoop {
|
|
|
4492
4587
|
if (!durableRecord.dryRun &&
|
|
4493
4588
|
claim.lifecycle.phase !== 'writeback-applied' &&
|
|
4494
4589
|
claim.lifecycle.phase !== 'releasing') {
|
|
4495
|
-
|
|
4590
|
+
// `#readIssue` swallows every read failure except a relayfile 429,
|
|
4591
|
+
// which it rethrows so callers can back off. On this path that
|
|
4592
|
+
// rethrow aborted the whole adoption: this row was never restored
|
|
4593
|
+
// into the batch, every later row was skipped, and no hold deadline
|
|
4594
|
+
// was armed for any of them (#315). Startup already degrades safely
|
|
4595
|
+
// when the issue is simply unreadable — it treats the row as not
|
|
4596
|
+
// externally terminal and lets the retry driver carry it — so a shed
|
|
4597
|
+
// read must degrade the same way rather than strand the batch.
|
|
4598
|
+
try {
|
|
4599
|
+
liveIssue = await this.#readIssue(durableRecord.issue.path);
|
|
4600
|
+
}
|
|
4601
|
+
catch (error) {
|
|
4602
|
+
const overload = relayfileOverload(error);
|
|
4603
|
+
if (!overload)
|
|
4604
|
+
throw error;
|
|
4605
|
+
this.#increment('startupIssueReadsShed');
|
|
4606
|
+
this.#logger.warn?.('[factory] startup issue read was shed; adopting without it', {
|
|
4607
|
+
issue: durableRecord.issue.key,
|
|
4608
|
+
status: overload.status,
|
|
4609
|
+
reason: relayfileOverloadReasonLabel(overload.reason),
|
|
4610
|
+
});
|
|
4611
|
+
}
|
|
4612
|
+
// A row persisted before #211 has no `origin`, so a Linear mirror
|
|
4613
|
+
// still resolves to `linear:<uuid>` and a GitHub-native arrival for
|
|
4614
|
+
// the same work unit would claim alongside it — the rolling-upgrade
|
|
4615
|
+
// form of the duplicate this change removes. Nothing on the row links
|
|
4616
|
+
// it to its upstream, but the issue we just read does, so backfill it
|
|
4617
|
+
// here under the lease this process already holds. The next claim or
|
|
4618
|
+
// load then recognises the row by identity and re-keys it.
|
|
4619
|
+
if (liveIssue)
|
|
4620
|
+
await this.#backfillMirrorOrigin(key, claim, liveIssue);
|
|
4496
4621
|
// A babysat Linear issue already at Done may have merged while this
|
|
4497
4622
|
// process was down. Let authoritative PR restoration drive the
|
|
4498
4623
|
// normal `complete` path so merged work is not mislabeled abandoned.
|
|
@@ -4522,7 +4647,7 @@ export class FactoryLoop {
|
|
|
4522
4647
|
if (claim.lifecycle.phase === 'running' &&
|
|
4523
4648
|
this.#ticketDispatchNotificationIsPending(claim.lifecycle)) {
|
|
4524
4649
|
if (!liveIssue) {
|
|
4525
|
-
this.#dispatchLifecycleEpochs.delete(
|
|
4650
|
+
this.#dispatchLifecycleEpochs.delete(key);
|
|
4526
4651
|
this.#scheduleDispatchLifecycleRetry(restored);
|
|
4527
4652
|
throw new Error(`Unable to recover ticket-dispatch notification ${restored.issue.key}: issue is not currently readable`);
|
|
4528
4653
|
}
|
|
@@ -4629,11 +4754,28 @@ export class FactoryLoop {
|
|
|
4629
4754
|
});
|
|
4630
4755
|
}
|
|
4631
4756
|
}
|
|
4632
|
-
this.#rescheduleHeldAgentDeadlineSweep();
|
|
4633
4757
|
}
|
|
4634
4758
|
catch (error) {
|
|
4635
4759
|
this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
|
|
4636
4760
|
}
|
|
4761
|
+
finally {
|
|
4762
|
+
// Arming has to survive everything above it (#315).
|
|
4763
|
+
//
|
|
4764
|
+
// This is the only thing that arms a hold deadline for a row that
|
|
4765
|
+
// survived a restart, and it used to be the last statement of the `try`.
|
|
4766
|
+
// Two fleet awaits sit above it — `reconcileTrackedAgents`, which is not
|
|
4767
|
+
// circuit-proxied and so reaches the transport, and the guarded `roster`,
|
|
4768
|
+
// which throws outright once the circuit is open. With the broker down
|
|
4769
|
+
// either one throws, the catch logs, and no hold deadline is armed for
|
|
4770
|
+
// that boot: the #304 reaper is present in the build and was never
|
|
4771
|
+
// scheduled. That is the failure mode reaping exists to clear, so the
|
|
4772
|
+
// arming must not be reachable only along the happy path.
|
|
4773
|
+
//
|
|
4774
|
+
// A `dispatching` row is additionally armed by the per-row retry driver
|
|
4775
|
+
// below, which is why the wedge shows up on a `running` row: `running` is
|
|
4776
|
+
// the one slot-occupying phase that never gets a retry scheduled here.
|
|
4777
|
+
this.#rescheduleHeldAgentDeadlineSweep();
|
|
4778
|
+
}
|
|
4637
4779
|
}
|
|
4638
4780
|
async #deduplicateQueuedGithubLifecycleAliases(lifecycles) {
|
|
4639
4781
|
const groups = new Map();
|
|
@@ -4852,7 +4994,7 @@ export class FactoryLoop {
|
|
|
4852
4994
|
// never-placed — which also excludes its agents from the release below,
|
|
4853
4995
|
// leaking live workers (#303 review, CodeRabbit).
|
|
4854
4996
|
let effective = deadline;
|
|
4855
|
-
const key =
|
|
4997
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
4856
4998
|
if (this.#abandonedDispatchReasons.has(key))
|
|
4857
4999
|
continue;
|
|
4858
5000
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
@@ -4904,6 +5046,36 @@ export class FactoryLoop {
|
|
|
4904
5046
|
}
|
|
4905
5047
|
}
|
|
4906
5048
|
}
|
|
5049
|
+
/**
|
|
5050
|
+
* Records the upstream origin on an adopted lifecycle that predates it.
|
|
5051
|
+
* Best-effort: a failed write leaves the row exactly as it was, and the next
|
|
5052
|
+
* startup tries again.
|
|
5053
|
+
*/
|
|
5054
|
+
async #backfillMirrorOrigin(key, claim, liveIssue) {
|
|
5055
|
+
const epoch = claim.lease?.epoch;
|
|
5056
|
+
if (epoch === undefined || claim.lifecycle.issue.origin)
|
|
5057
|
+
return;
|
|
5058
|
+
const origin = mirrorWorkUnitOrigin(liveIssue);
|
|
5059
|
+
if (!origin)
|
|
5060
|
+
return;
|
|
5061
|
+
const issue = { ...claim.lifecycle.issue, origin };
|
|
5062
|
+
const enriched = {
|
|
5063
|
+
...claim.lifecycle,
|
|
5064
|
+
issue,
|
|
5065
|
+
decision: { ...claim.lifecycle.decision, issue },
|
|
5066
|
+
};
|
|
5067
|
+
const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), enriched).catch((error) => {
|
|
5068
|
+
this.#logger.warn?.('[factory] could not backfill mirror origin on an adopted lifecycle', {
|
|
5069
|
+
issue: claim.lifecycle.issue.key,
|
|
5070
|
+
error: describeError(error).errorMessage,
|
|
5071
|
+
});
|
|
5072
|
+
return false;
|
|
5073
|
+
});
|
|
5074
|
+
if (!saved)
|
|
5075
|
+
return;
|
|
5076
|
+
claim.lifecycle = enriched;
|
|
5077
|
+
this.#increment('dispatchLifecycleMirrorOriginsBackfilled');
|
|
5078
|
+
}
|
|
4907
5079
|
async #renewDispatchLifecycles() {
|
|
4908
5080
|
for (const [key, epoch] of [...this.#dispatchLifecycleEpochs]) {
|
|
4909
5081
|
const renewed = await this.#state.renewDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
|
|
@@ -4928,7 +5100,7 @@ export class FactoryLoop {
|
|
|
4928
5100
|
}
|
|
4929
5101
|
}
|
|
4930
5102
|
async #claimDispatchLifecycle(decision, dryRun, preparedRunId, initial = {}) {
|
|
4931
|
-
const key =
|
|
5103
|
+
const key = dispatchLifecycleKey(decision.issue);
|
|
4932
5104
|
const seed = {
|
|
4933
5105
|
runId: preparedRunId ?? randomUUID(),
|
|
4934
5106
|
issue: { ...decision.issue },
|
|
@@ -4951,7 +5123,7 @@ export class FactoryLoop {
|
|
|
4951
5123
|
: `dispatch lifecycle is owned by ${claim.lifecycle.lease?.owner ?? 'another publisher'}`;
|
|
4952
5124
|
throw new DispatchLifecycleClaimRefusedError(decision.issue.key, terminal ? 'terminal' : 'owned-elsewhere', `Refusing to dispatch ${decision.issue.key}: ${reason}`);
|
|
4953
5125
|
}
|
|
4954
|
-
this.#dispatchLifecycleEpochs.set(
|
|
5126
|
+
this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
|
|
4955
5127
|
this.#hydrateCostLedger(claim.lifecycle);
|
|
4956
5128
|
this.#scheduleDispatchLifecycleRenewal();
|
|
4957
5129
|
if (claim.created) {
|
|
@@ -5007,7 +5179,7 @@ export class FactoryLoop {
|
|
|
5007
5179
|
}
|
|
5008
5180
|
async #reportAgent(record, tracked, type, options = {}) {
|
|
5009
5181
|
const lifecycle = await this.#state
|
|
5010
|
-
.getDispatchLifecycle(this.#workspaceId,
|
|
5182
|
+
.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
|
|
5011
5183
|
.catch(() => undefined);
|
|
5012
5184
|
if (!lifecycle)
|
|
5013
5185
|
return;
|
|
@@ -5035,7 +5207,7 @@ export class FactoryLoop {
|
|
|
5035
5207
|
}
|
|
5036
5208
|
async #handleAgentUsage(usage, key) {
|
|
5037
5209
|
const record = (await this.#batch()).getIssueByAgent(usage.name);
|
|
5038
|
-
const activeRecord = record &&
|
|
5210
|
+
const activeRecord = record && dispatchLifecycleKey(record.issue) === key ? record : undefined;
|
|
5039
5211
|
let tracked = activeRecord?.agents.get(usage.name);
|
|
5040
5212
|
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
5041
5213
|
// Completion removes the in-memory batch record immediately after the
|
|
@@ -5143,7 +5315,7 @@ export class FactoryLoop {
|
|
|
5143
5315
|
async #dispatchLifecycleKeyForAgent(name) {
|
|
5144
5316
|
const record = (await this.#batch()).getIssueByAgent(name);
|
|
5145
5317
|
if (record?.agents.has(name))
|
|
5146
|
-
return
|
|
5318
|
+
return dispatchLifecycleKey(record.issue);
|
|
5147
5319
|
return (await this.#findDispatchLifecycleAgent(name))?.key;
|
|
5148
5320
|
}
|
|
5149
5321
|
#serializeDispatchLifecyclePersistence(key, operation) {
|
|
@@ -5283,7 +5455,7 @@ export class FactoryLoop {
|
|
|
5283
5455
|
return true;
|
|
5284
5456
|
if (isTerminalDispatchPhase(phase))
|
|
5285
5457
|
await this.#drainAgentUsage();
|
|
5286
|
-
const key =
|
|
5458
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
5287
5459
|
return this.#serializeDispatchLifecyclePersistence(key, async () => {
|
|
5288
5460
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
5289
5461
|
if (epoch === undefined) {
|
|
@@ -5368,7 +5540,7 @@ export class FactoryLoop {
|
|
|
5368
5540
|
// after release could see it cleared, or see the next dispatch for the same
|
|
5369
5541
|
// issue, and classify the wrong run.
|
|
5370
5542
|
#resolveDispatchTerminalWaiters(issue, phase) {
|
|
5371
|
-
const key =
|
|
5543
|
+
const key = dispatchLifecycleKey(issue);
|
|
5372
5544
|
for (const resolve of this.#dispatchTerminalWaiters.get(key) ?? [])
|
|
5373
5545
|
resolve(phase);
|
|
5374
5546
|
this.#dispatchTerminalWaiters.delete(key);
|
|
@@ -5497,7 +5669,7 @@ export class FactoryLoop {
|
|
|
5497
5669
|
this.#increment('dispatchCapacityBackoffResets');
|
|
5498
5670
|
}
|
|
5499
5671
|
#scheduleDispatchLifecycleRetry(record, delayMs = DISPATCH_LIFECYCLE_RETRY_MS) {
|
|
5500
|
-
const key =
|
|
5672
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
5501
5673
|
if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
|
|
5502
5674
|
return;
|
|
5503
5675
|
const timer = setTimeout(() => {
|
|
@@ -5547,7 +5719,7 @@ export class FactoryLoop {
|
|
|
5547
5719
|
this.#scheduleDispatchLifecycleRetry(record);
|
|
5548
5720
|
return;
|
|
5549
5721
|
}
|
|
5550
|
-
const key =
|
|
5722
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
5551
5723
|
if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
|
|
5552
5724
|
return;
|
|
5553
5725
|
const timer = setTimeout(() => {
|
|
@@ -5585,7 +5757,7 @@ export class FactoryLoop {
|
|
|
5585
5757
|
if (!claim.acquired || !claim.lease) {
|
|
5586
5758
|
throw new DispatchLifecycleOwnedElsewhereError(claim.lifecycle.lease?.leaseUntilMs);
|
|
5587
5759
|
}
|
|
5588
|
-
this.#dispatchLifecycleEpochs.set(
|
|
5760
|
+
this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
|
|
5589
5761
|
this.#hydrateCostLedger(claim.lifecycle);
|
|
5590
5762
|
this.#scheduleDispatchLifecycleRenewal();
|
|
5591
5763
|
lifecycle = claim.lifecycle;
|
|
@@ -5838,7 +6010,7 @@ export class FactoryLoop {
|
|
|
5838
6010
|
state: 'pending',
|
|
5839
6011
|
updatedAtMs: this.#clock.now(),
|
|
5840
6012
|
};
|
|
5841
|
-
this.#dispatchClaimStatuses.set(
|
|
6013
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
5842
6014
|
}
|
|
5843
6015
|
await this.#writeInFlightRegistry();
|
|
5844
6016
|
if (!record.dryRun) {
|
|
@@ -5964,7 +6136,7 @@ export class FactoryLoop {
|
|
|
5964
6136
|
async #finishDurableRelease(record, releaseReason) {
|
|
5965
6137
|
const batch = await this.#batch();
|
|
5966
6138
|
const reason = releaseReason ?? (this.#config.terminalState === 'human-review' ? 'issue-human-review' : 'issue-done');
|
|
5967
|
-
const releaseKey =
|
|
6139
|
+
const releaseKey = dispatchLifecycleKey(record.issue);
|
|
5968
6140
|
// Terminal writeback has already been acknowledged before this method is
|
|
5969
6141
|
// entered. Remove externally reachable routes first so a stuck agent
|
|
5970
6142
|
// release cannot leave a preview live after Human Review or Done.
|
|
@@ -6040,7 +6212,7 @@ export class FactoryLoop {
|
|
|
6040
6212
|
async #assertIssueDispatchLifecycleOwner(issue) {
|
|
6041
6213
|
if (!this.#usesDurableDispatchLifecycle())
|
|
6042
6214
|
return true;
|
|
6043
|
-
const key =
|
|
6215
|
+
const key = dispatchLifecycleKey(issue);
|
|
6044
6216
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
6045
6217
|
if (epoch === undefined)
|
|
6046
6218
|
return false;
|
|
@@ -6179,7 +6351,7 @@ export class FactoryLoop {
|
|
|
6179
6351
|
state: 'verified',
|
|
6180
6352
|
updatedAtMs: this.#clock.now(),
|
|
6181
6353
|
};
|
|
6182
|
-
this.#dispatchClaimStatuses.set(
|
|
6354
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
6183
6355
|
await this.#writeDispatchClaimRegistry(record.issue);
|
|
6184
6356
|
return implementingStateId;
|
|
6185
6357
|
}
|
|
@@ -6208,7 +6380,7 @@ export class FactoryLoop {
|
|
|
6208
6380
|
...(deadLettered ? { deadLettered: true } : {}),
|
|
6209
6381
|
updatedAtMs: this.#clock.now(),
|
|
6210
6382
|
};
|
|
6211
|
-
this.#dispatchClaimStatuses.set(
|
|
6383
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
6212
6384
|
await this.#writeDispatchClaimRegistry(record.issue);
|
|
6213
6385
|
if (deadLettered) {
|
|
6214
6386
|
this.#increment('dispatchWritebackDeadLetters');
|
|
@@ -6437,7 +6609,16 @@ export class FactoryLoop {
|
|
|
6437
6609
|
!labels.every((label) => typeof label === 'string')) {
|
|
6438
6610
|
return undefined;
|
|
6439
6611
|
}
|
|
6440
|
-
|
|
6612
|
+
// Retain Factory's own lifecycle rows even when they lack the scope
|
|
6613
|
+
// label. The index carries no title, so a title-scoped issue cannot be
|
|
6614
|
+
// recognised here — and dropping it means its file is never read and the
|
|
6615
|
+
// orphan-recovery sweep never sees it. `factory:in-progress` is a label
|
|
6616
|
+
// only Factory applies, so a row carrying it is by definition
|
|
6617
|
+
// Factory-touched and worth reading; `isInFactoryScope` downstream
|
|
6618
|
+
// remains the authority on whether anything may be done with it.
|
|
6619
|
+
const rowLabels = labels.map((label) => label.trim().toLowerCase());
|
|
6620
|
+
if (state !== 'open' ||
|
|
6621
|
+
!(rowLabels.includes(requiredLabel) || rowLabels.includes('factory:in-progress'))) {
|
|
6441
6622
|
continue;
|
|
6442
6623
|
}
|
|
6443
6624
|
paths.push(`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`);
|
|
@@ -7034,24 +7215,48 @@ export class FactoryLoop {
|
|
|
7034
7215
|
await this.#state.recordDispatchAttempt(this.#workspaceId, key, dispatchState);
|
|
7035
7216
|
this.#increment('dispatchTerminalReopened');
|
|
7036
7217
|
}
|
|
7037
|
-
|
|
7038
|
-
|
|
7218
|
+
// Match on the work unit, not the surface key: a completed Linear mirror
|
|
7219
|
+
// persists `AR-448` while the GitHub-native arrival of the same issue is
|
|
7220
|
+
// `448`, and comparing surface keys would leave that terminal row in
|
|
7221
|
+
// place to refuse the reopened work forever.
|
|
7222
|
+
const reopenedIdentity = safeDispatchLifecycleKey(issueRefForState(issue));
|
|
7223
|
+
for (const [lifecycleKey, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
|
|
7224
|
+
if (!isTerminalDispatchLifecycle(lifecycle))
|
|
7039
7225
|
continue;
|
|
7040
|
-
|
|
7041
|
-
|
|
7226
|
+
const matches = reopenedIdentity !== undefined &&
|
|
7227
|
+
safeDispatchLifecycleKey(lifecycle.issue) === reopenedIdentity;
|
|
7228
|
+
if (!matches && lifecycle.issue.key !== issue.key)
|
|
7229
|
+
continue;
|
|
7230
|
+
await this.#state.clearDispatchLifecycle(this.#workspaceId, lifecycleKey);
|
|
7231
|
+
this.#dispatchLifecycleEpochs.delete(lifecycleKey);
|
|
7042
7232
|
}
|
|
7043
7233
|
}
|
|
7044
7234
|
await this.#state.recordCanonicalState(this.#workspaceId, key, issue.stateId);
|
|
7045
7235
|
}
|
|
7046
7236
|
async #writeLoopHeartbeat(path, registryPath, status, iteration, maxIterations) {
|
|
7047
7237
|
const updatedAtMs = this.#clock.now();
|
|
7238
|
+
this.#heartbeatStartedAtMs ??= updatedAtMs;
|
|
7048
7239
|
const heartbeat = {
|
|
7049
7240
|
pid: process.pid,
|
|
7050
7241
|
status,
|
|
7242
|
+
source: maxIterations === 0 ? 'live-timer' : 'bounded-loop',
|
|
7051
7243
|
iteration,
|
|
7052
7244
|
maxIterations,
|
|
7245
|
+
startedAt: new Date(this.#heartbeatStartedAtMs).toISOString(),
|
|
7246
|
+
startedAtMs: this.#heartbeatStartedAtMs,
|
|
7247
|
+
progressContract: 'discovery-sweep-v1',
|
|
7053
7248
|
updatedAt: new Date(updatedAtMs).toISOString(),
|
|
7054
7249
|
updatedAtMs,
|
|
7250
|
+
...(this.#lastProgressAtMs !== undefined
|
|
7251
|
+
? {
|
|
7252
|
+
progress: {
|
|
7253
|
+
sequence: this.#progressSequence,
|
|
7254
|
+
operation: 'discovery-sweep',
|
|
7255
|
+
updatedAt: new Date(this.#lastProgressAtMs).toISOString(),
|
|
7256
|
+
updatedAtMs: this.#lastProgressAtMs,
|
|
7257
|
+
},
|
|
7258
|
+
}
|
|
7259
|
+
: {}),
|
|
7055
7260
|
registryPath,
|
|
7056
7261
|
eventListener: this.#eventListenerStatus(),
|
|
7057
7262
|
readinessReconcile: this.#readinessReconcileStatus(),
|
|
@@ -7081,8 +7286,10 @@ export class FactoryLoop {
|
|
|
7081
7286
|
error: describeError(error).errorMessage,
|
|
7082
7287
|
});
|
|
7083
7288
|
}
|
|
7084
|
-
|
|
7085
|
-
|
|
7289
|
+
// Atomic publish (#323): `/healthz` and the crash reaper read this file
|
|
7290
|
+
// while it is being rewritten, and a torn read reaches them as a missing
|
|
7291
|
+
// heartbeat, i.e. as a dead daemon.
|
|
7292
|
+
await writeJsonFileAtomically(path, heartbeat);
|
|
7086
7293
|
await this.#writeInFlightRegistry(registryPath, path);
|
|
7087
7294
|
const batch = this.#batchView;
|
|
7088
7295
|
await this.#report({
|
|
@@ -7369,7 +7576,7 @@ export class FactoryLoop {
|
|
|
7369
7576
|
this.#logger.warn?.(`[factory] failed to release ${agentName} during ${context}`, error);
|
|
7370
7577
|
if (record) {
|
|
7371
7578
|
const lifecycle = await this.#state
|
|
7372
|
-
.getDispatchLifecycle(this.#workspaceId,
|
|
7579
|
+
.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
|
|
7373
7580
|
.catch(() => undefined);
|
|
7374
7581
|
if (lifecycle) {
|
|
7375
7582
|
await this.#reportLifecycle(lifecycle, 'factory.failure', {
|
|
@@ -7544,7 +7751,7 @@ export class FactoryLoop {
|
|
|
7544
7751
|
}
|
|
7545
7752
|
}
|
|
7546
7753
|
const fleetTracked = this.#fleet.trackedAgents?.().get(agentName);
|
|
7547
|
-
const dispatchClaim = this.#dispatchClaimStatuses.get(
|
|
7754
|
+
const dispatchClaim = this.#dispatchClaimStatuses.get(dispatchLifecycleKey(issue));
|
|
7548
7755
|
agents.push({
|
|
7549
7756
|
name: agentName,
|
|
7550
7757
|
role: tracked.spec.role,
|
|
@@ -7568,7 +7775,7 @@ export class FactoryLoop {
|
|
|
7568
7775
|
if (record.dryRun)
|
|
7569
7776
|
continue;
|
|
7570
7777
|
if (record.dispatchClaim) {
|
|
7571
|
-
this.#dispatchClaimStatuses.set(
|
|
7778
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
7572
7779
|
}
|
|
7573
7780
|
for (const [agentName, tracked] of record.agents) {
|
|
7574
7781
|
await appendAgent(record.issue, agentName, tracked, record);
|
|
@@ -7585,8 +7792,10 @@ export class FactoryLoop {
|
|
|
7585
7792
|
updatedAtMs,
|
|
7586
7793
|
agents,
|
|
7587
7794
|
};
|
|
7588
|
-
|
|
7589
|
-
|
|
7795
|
+
// Atomic publish (#323): the crash reaper reads this file in the same
|
|
7796
|
+
// sweep as the heartbeat, so leaving it torn keeps the reaper racy even
|
|
7797
|
+
// with an atomic heartbeat.
|
|
7798
|
+
await writeJsonFileAtomically(path, registry);
|
|
7590
7799
|
}
|
|
7591
7800
|
async #spawnAgent(record, spec, dryRun) {
|
|
7592
7801
|
const batch = await this.#batch();
|
|
@@ -7697,7 +7906,7 @@ export class FactoryLoop {
|
|
|
7697
7906
|
* durable row, which is authoritative when another owner terminalized it.
|
|
7698
7907
|
*/
|
|
7699
7908
|
async #dispatchLifecycleStillOwned(record) {
|
|
7700
|
-
const key =
|
|
7909
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
7701
7910
|
if (this.#abandonedDispatchReasons.has(key))
|
|
7702
7911
|
return false;
|
|
7703
7912
|
if (!this.#usesDurableDispatchLifecycle())
|
|
@@ -7814,7 +8023,7 @@ export class FactoryLoop {
|
|
|
7814
8023
|
if (tracingReconciledExit)
|
|
7815
8024
|
this.#logger.info?.('[factory] reconciled agent exit telemetry completed', { issue: record.issue.key, name });
|
|
7816
8025
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
7817
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
8026
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
7818
8027
|
if (lifecycle?.phase === 'parking') {
|
|
7819
8028
|
this.#increment('clarificationParkingExitsSuppressed');
|
|
7820
8029
|
return;
|
|
@@ -8004,7 +8213,7 @@ export class FactoryLoop {
|
|
|
8004
8213
|
// that exited immediately consume a batch slot forever while Factory
|
|
8005
8214
|
// continuously recreated it. One successful recovery is enough; a
|
|
8006
8215
|
// subsequent no-PR implementer exit is terminal and frees the slot.
|
|
8007
|
-
const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
8216
|
+
const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
8008
8217
|
const recoveryRunIdentity = recoveryLifecycle?.runId
|
|
8009
8218
|
?? tracked.spec.branch
|
|
8010
8219
|
?? batch.invocationIdFor(record.issue, tracked.spec);
|
|
@@ -8210,7 +8419,7 @@ export class FactoryLoop {
|
|
|
8210
8419
|
if (!matchesCurrentConvention && !matchesAuthorizedLegacyBranch) {
|
|
8211
8420
|
throw new Error(`Refusing to publish ${record.issue.key}: branch ${expectedHeadRef} belongs to a different issue`);
|
|
8212
8421
|
}
|
|
8213
|
-
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
8422
|
+
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
8214
8423
|
const cached = this.#publishedPullRequests.get(key);
|
|
8215
8424
|
if (cached) {
|
|
8216
8425
|
if (cached.headRef !== expectedHeadRef) {
|
|
@@ -8868,7 +9077,7 @@ export class FactoryLoop {
|
|
|
8868
9077
|
}
|
|
8869
9078
|
}
|
|
8870
9079
|
#scheduleAbandonedDispatchRetry(record, reason) {
|
|
8871
|
-
const key =
|
|
9080
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
8872
9081
|
if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
|
|
8873
9082
|
return;
|
|
8874
9083
|
const timer = setTimeout(() => {
|
|
@@ -8900,7 +9109,7 @@ export class FactoryLoop {
|
|
|
8900
9109
|
? githubIssuePathParts(record.issue.path)?.owner
|
|
8901
9110
|
: undefined;
|
|
8902
9111
|
const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
|
|
8903
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
9112
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
8904
9113
|
if (publishedPullRequests(lifecycle).some((receipt) => receipt.repo.toLowerCase() === repo.toLowerCase()))
|
|
8905
9114
|
return true;
|
|
8906
9115
|
return Boolean(await this.#openPullRequestByHead(repo, implementer.spec.branch));
|
|
@@ -10468,10 +10677,7 @@ export class FactoryLoop {
|
|
|
10468
10677
|
return false;
|
|
10469
10678
|
}
|
|
10470
10679
|
const clarifiedIssue = issueWithGithubClarification(issue, text);
|
|
10471
|
-
const decision = await this.#
|
|
10472
|
-
config: this.#config,
|
|
10473
|
-
repoMap: repoMapFromConfig(this.#config),
|
|
10474
|
-
});
|
|
10680
|
+
const decision = await this.#triageWithOrigin(clarifiedIssue);
|
|
10475
10681
|
const escalationReason = triageEscalationReason(decision);
|
|
10476
10682
|
if (escalationReason) {
|
|
10477
10683
|
if (hasDispatchableRoute(decision)) {
|
|
@@ -10922,7 +11128,7 @@ export class FactoryLoop {
|
|
|
10922
11128
|
for (const record of records) {
|
|
10923
11129
|
if (!await this.#assertIssueDispatchLifecycleOwner(record.issue))
|
|
10924
11130
|
continue;
|
|
10925
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
11131
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
10926
11132
|
// Babysitter sessions are independently durable and restore only after
|
|
10927
11133
|
// their mounted PR metadata passes the open/draft/issue-identity guard.
|
|
10928
11134
|
// Fold those validated receipts back into the lifecycle on takeover.
|
|
@@ -11575,7 +11781,7 @@ export class FactoryLoop {
|
|
|
11575
11781
|
});
|
|
11576
11782
|
}
|
|
11577
11783
|
async #suspendBabysitterWakeForHuman(state) {
|
|
11578
|
-
const key =
|
|
11784
|
+
const key = dispatchLifecycleKey(state.issue);
|
|
11579
11785
|
const [lifecycle, clarification] = await Promise.all([
|
|
11580
11786
|
this.#usesDurableDispatchLifecycle()
|
|
11581
11787
|
? this.#state.getDispatchLifecycle(this.#workspaceId, key)
|
|
@@ -12082,6 +12288,49 @@ export class FactoryLoop {
|
|
|
12082
12288
|
async #advanceMergedPrToDone(snapshot, repo, record) {
|
|
12083
12289
|
const mergedPullRequest = { repo, number: snapshot.number, url: snapshot.url };
|
|
12084
12290
|
if (record) {
|
|
12291
|
+
// An in-flight record's association comes from Factory's own dispatch,
|
|
12292
|
+
// not from the PR's prose, so closure here does NOT require a closing
|
|
12293
|
+
// keyword — requiring one would strand every ordinary dispatch whose PR
|
|
12294
|
+
// links its issue with a bare `Linear: AR-411`.
|
|
12295
|
+
//
|
|
12296
|
+
// What it does honour is an explicit disclaimer: a merged PR that says in
|
|
12297
|
+
// its own body that it does not fix this issue is taken at its word. That
|
|
12298
|
+
// is the exact shape of #313 (`Diagnostic for #155`).
|
|
12299
|
+
//
|
|
12300
|
+
// Ownership does NOT exempt a PR from this. Ownership settles which issue
|
|
12301
|
+
// the PR belongs to; it says nothing about whether the merge completed
|
|
12302
|
+
// that issue. Skipping the check for owned PRs would mean the same merged
|
|
12303
|
+
// body is refused before ownership is established and accepted after.
|
|
12304
|
+
if (prBodyDisclaimsClosing({ headRef: snapshot.headRef, body: snapshot.body, repo }, record.issue.key)) {
|
|
12305
|
+
this.#increment('mergedPrClosureRefused');
|
|
12306
|
+
this.#logger.warn?.('[factory] merged PR disclaims completing the issue it references', {
|
|
12307
|
+
issue: record.issue.key,
|
|
12308
|
+
prNumber: snapshot.number,
|
|
12309
|
+
repo,
|
|
12310
|
+
});
|
|
12311
|
+
return;
|
|
12312
|
+
}
|
|
12313
|
+
// The protected-label gate has to cover the tracked paths too, or an
|
|
12314
|
+
// incident issue that Factory itself dispatched is still auto-closed by
|
|
12315
|
+
// its own merge and `neverAutoCloseLabels` guarantees nothing.
|
|
12316
|
+
const tracked = await this.#readIssue(record.issue.path);
|
|
12317
|
+
const trackedBlockingLabel = tracked ? this.#neverAutoCloseLabel(tracked) : undefined;
|
|
12318
|
+
if (tracked && trackedBlockingLabel) {
|
|
12319
|
+
await this.#declineMergeClosure(tracked, snapshot, repo, `it carries the \`${trackedBlockingLabel}\` label, which Factory never auto-closes`, 'label');
|
|
12320
|
+
return;
|
|
12321
|
+
}
|
|
12322
|
+
if (!tracked) {
|
|
12323
|
+
// Proceed rather than block. The disclaimer check above already ran on
|
|
12324
|
+
// the merged body; wedging every completion behind a transient mount
|
|
12325
|
+
// read would strand dispatches far more often than it would protect an
|
|
12326
|
+
// incident, and stranded dispatches are what generate incidents here.
|
|
12327
|
+
this.#logger.warn?.('[factory] could not read issue to apply the never-auto-close label gate', {
|
|
12328
|
+
issue: record.issue.key,
|
|
12329
|
+
prNumber: snapshot.number,
|
|
12330
|
+
repo,
|
|
12331
|
+
});
|
|
12332
|
+
this.#increment('mergedPrLabelGateUnverified');
|
|
12333
|
+
}
|
|
12085
12334
|
await this.#completeIssue(record, {
|
|
12086
12335
|
targetState: 'done',
|
|
12087
12336
|
runMergeGate: false,
|
|
@@ -12100,11 +12349,24 @@ export class FactoryLoop {
|
|
|
12100
12349
|
this.#increment('mergedPrAdvanceDuplicatesSuppressed');
|
|
12101
12350
|
return;
|
|
12102
12351
|
}
|
|
12352
|
+
// A merged PR that merely *references* this issue has no authority to
|
|
12353
|
+
// complete it, and an incident issue is never closed by a merge at all.
|
|
12354
|
+
// Both refusals comment instead of closing, so the decision is auditable
|
|
12355
|
+
// rather than silent. #313.
|
|
12356
|
+
const authority = prClosureAuthority({ headRef: snapshot.headRef, body: snapshot.body, repo }, issue.key);
|
|
12357
|
+
const blockingLabel = this.#neverAutoCloseLabel(issue);
|
|
12358
|
+
if (!authority.authorised || blockingLabel) {
|
|
12359
|
+
const reason = blockingLabel
|
|
12360
|
+
? `it carries the \`${blockingLabel}\` label, which Factory never auto-closes`
|
|
12361
|
+
: authority.evidence;
|
|
12362
|
+
await this.#declineMergeClosure(issue, snapshot, repo, reason, blockingLabel ? 'label' : 'authority');
|
|
12363
|
+
return;
|
|
12364
|
+
}
|
|
12103
12365
|
this.#postMergeDoneAdvances.add(advanceKey);
|
|
12104
12366
|
try {
|
|
12105
12367
|
const githubIssue = isGithubIssue(issue);
|
|
12106
12368
|
if (githubIssue) {
|
|
12107
|
-
await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.`);
|
|
12369
|
+
await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.\n\nClosing authority: ${authority.evidence}.`);
|
|
12108
12370
|
}
|
|
12109
12371
|
else {
|
|
12110
12372
|
const doneStateId = this.#states.idFor(issue.team, 'done');
|
|
@@ -12119,6 +12381,7 @@ export class FactoryLoop {
|
|
|
12119
12381
|
issue: issue.key,
|
|
12120
12382
|
prNumber: snapshot.number,
|
|
12121
12383
|
url: snapshot.url,
|
|
12384
|
+
closureAuthority: authority.source,
|
|
12122
12385
|
});
|
|
12123
12386
|
if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('merge-done-thread')) {
|
|
12124
12387
|
try {
|
|
@@ -12145,6 +12408,61 @@ export class FactoryLoop {
|
|
|
12145
12408
|
this.#error(error, issueRef(issue));
|
|
12146
12409
|
}
|
|
12147
12410
|
}
|
|
12411
|
+
/** The configured label, if any, that bars this issue from ever auto-closing. */
|
|
12412
|
+
#neverAutoCloseLabel(issue) {
|
|
12413
|
+
const blocked = this.#config.safety.neverAutoCloseLabels;
|
|
12414
|
+
if (!blocked || blocked.length === 0)
|
|
12415
|
+
return undefined;
|
|
12416
|
+
const carried = new Set((issue.labels ?? []).map((label) => label.trim().toLowerCase()));
|
|
12417
|
+
return blocked.find((label) => carried.has(label));
|
|
12418
|
+
}
|
|
12419
|
+
/**
|
|
12420
|
+
* Leave the issue open and say why. A refusal has to be *louder* than a
|
|
12421
|
+
* closure, not quieter: the failure this replaces marked a live outage
|
|
12422
|
+
* COMPLETED and dropped it out of every open-issue view.
|
|
12423
|
+
*/
|
|
12424
|
+
async #declineMergeClosure(issue, snapshot, repo, reason, kind) {
|
|
12425
|
+
this.#increment(kind === 'label' ? 'mergedPrClosureBlockedByLabel' : 'mergedPrClosureRefused');
|
|
12426
|
+
this.#logger.warn?.('[factory] declined to close issue on merged PR', {
|
|
12427
|
+
issue: issue.key,
|
|
12428
|
+
prNumber: snapshot.number,
|
|
12429
|
+
repo,
|
|
12430
|
+
reason,
|
|
12431
|
+
kind,
|
|
12432
|
+
});
|
|
12433
|
+
const marker = `factory-merge-close-declined:${repo}#${snapshot.number}`;
|
|
12434
|
+
const body = [
|
|
12435
|
+
`Factory observed pull request ${repo}#${snapshot.number} merge, and did **not** close this issue.`,
|
|
12436
|
+
'',
|
|
12437
|
+
`Reason: ${reason}.`,
|
|
12438
|
+
'',
|
|
12439
|
+
'A merged pull request closes an issue only when it carries a GitHub closing keyword'
|
|
12440
|
+
+ ' (`closes`/`fixes`/`resolves`) for it, or when its head is that issue\'s own'
|
|
12441
|
+
+ ' implementation branch. A bare reference is an association, not a completion.',
|
|
12442
|
+
'',
|
|
12443
|
+
'If this pull request really did complete the issue, close it by hand.',
|
|
12444
|
+
'',
|
|
12445
|
+
`<!-- ${marker} -->`,
|
|
12446
|
+
].join('\n');
|
|
12447
|
+
try {
|
|
12448
|
+
// A Linear-backed candidate reaches this path too, and a refusal that is
|
|
12449
|
+
// invisible on the system of record is barely better than the silent
|
|
12450
|
+
// closure it replaces.
|
|
12451
|
+
if (!isGithubIssue(issue)) {
|
|
12452
|
+
await this.#linear.postComment(issue, body);
|
|
12453
|
+
return;
|
|
12454
|
+
}
|
|
12455
|
+
// Merge events can redeliver. Skip a repeat only on a positive
|
|
12456
|
+
// confirmation that the note already landed; an unavailable check must
|
|
12457
|
+
// not be read as "already commented".
|
|
12458
|
+
if (await this.#githubWriteback.hasCommentMarker?.(issue, marker))
|
|
12459
|
+
return;
|
|
12460
|
+
await this.#githubWriteback.postComment(issue, body);
|
|
12461
|
+
}
|
|
12462
|
+
catch (error) {
|
|
12463
|
+
this.#error(error, issueRef(issue));
|
|
12464
|
+
}
|
|
12465
|
+
}
|
|
12148
12466
|
async #findMergeAdvanceIssueForPr(snapshot, eventRepo) {
|
|
12149
12467
|
// An issue is "upstream" of a merge if it sits in the agent-implementing or
|
|
12150
12468
|
// human-review role for its team. UUIDs are globally unique, so the reverse
|
|
@@ -12226,7 +12544,7 @@ export class FactoryLoop {
|
|
|
12226
12544
|
// after opening its PR (an event, not a poll).
|
|
12227
12545
|
async #ensureBabysitterForIssue(record) {
|
|
12228
12546
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
12229
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12547
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12230
12548
|
const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
|
|
12231
12549
|
if (receipts.length > 0) {
|
|
12232
12550
|
for (const receipt of receipts) {
|
|
@@ -12456,7 +12774,7 @@ export class FactoryLoop {
|
|
|
12456
12774
|
}
|
|
12457
12775
|
async #retireSupersededBabysitters(record, prRef) {
|
|
12458
12776
|
const wanted = githubPrIdentity(prRef.repo, prRef.prNumber);
|
|
12459
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12777
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12460
12778
|
const trackedAgents = new Map([
|
|
12461
12779
|
...(lifecycle?.agents ?? [])
|
|
12462
12780
|
.filter((agent) => agent.releasedAtMs === undefined)
|
|
@@ -12499,7 +12817,7 @@ export class FactoryLoop {
|
|
|
12499
12817
|
});
|
|
12500
12818
|
}
|
|
12501
12819
|
if (superseded.length > 0) {
|
|
12502
|
-
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12820
|
+
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12503
12821
|
if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
|
|
12504
12822
|
throw new Error(`Failed to persist superseded babysitter cleanup for ${record.issue.key}`);
|
|
12505
12823
|
}
|
|
@@ -12508,7 +12826,7 @@ export class FactoryLoop {
|
|
|
12508
12826
|
}
|
|
12509
12827
|
async #retireBabysittersOutsideCurrentRoutes(record) {
|
|
12510
12828
|
const wantedRepos = new Set(record.decision.implementers.map((implementer) => implementer.repo.toLowerCase()));
|
|
12511
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12829
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12512
12830
|
const trackedAgents = new Map([
|
|
12513
12831
|
...(lifecycle?.agents ?? [])
|
|
12514
12832
|
.filter((agent) => agent.releasedAtMs === undefined)
|
|
@@ -12550,7 +12868,7 @@ export class FactoryLoop {
|
|
|
12550
12868
|
});
|
|
12551
12869
|
}
|
|
12552
12870
|
if (unrouted.length > 0) {
|
|
12553
|
-
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12871
|
+
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12554
12872
|
if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
|
|
12555
12873
|
throw new Error(`Failed to persist unrouted babysitter cleanup for ${record.issue.key}`);
|
|
12556
12874
|
}
|
|
@@ -12563,7 +12881,7 @@ export class FactoryLoop {
|
|
|
12563
12881
|
// guards on the PR's OWN webhook-fed meta (still open, not a draft, not already
|
|
12564
12882
|
// merged) before flipping the issue to Human Review. No `gh` call.
|
|
12565
12883
|
async #maybeAdvanceToHumanReview(record, agentName) {
|
|
12566
|
-
if (this.#completionInFlight.has(
|
|
12884
|
+
if (this.#completionInFlight.has(dispatchLifecycleKey(record.issue))) {
|
|
12567
12885
|
return;
|
|
12568
12886
|
}
|
|
12569
12887
|
const ownership = [...this.#babysitterPr.entries()].find(([key, ref]) => ref.agentName === agentName && issueKey(this.#babysitterIssueRefs.get(key) ?? record.issue) === issueKey(record.issue));
|
|
@@ -12696,7 +13014,7 @@ export class FactoryLoop {
|
|
|
12696
13014
|
return false;
|
|
12697
13015
|
}
|
|
12698
13016
|
async #completeIssue(record, opts = {}) {
|
|
12699
|
-
const completionKey =
|
|
13017
|
+
const completionKey = dispatchLifecycleKey(record.issue);
|
|
12700
13018
|
if (this.#completionInFlight.has(completionKey)) {
|
|
12701
13019
|
return;
|
|
12702
13020
|
}
|
|
@@ -12766,6 +13084,7 @@ export class FactoryLoop {
|
|
|
12766
13084
|
await this.#linear.setState(issue, targetState);
|
|
12767
13085
|
await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
|
|
12768
13086
|
}
|
|
13087
|
+
record.issueWritebackConfirmedAtMs = this.#clock.now();
|
|
12769
13088
|
this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
|
|
12770
13089
|
if (!humanReview)
|
|
12771
13090
|
await this.#markDependencyTerminalAndReconcile(issue);
|
|
@@ -12841,7 +13160,7 @@ export class FactoryLoop {
|
|
|
12841
13160
|
// Cancellation must see the subscription identity so it can issue the
|
|
12842
13161
|
// idempotent Relayfile DELETE before clearing the local owner maps.
|
|
12843
13162
|
await this.#cancelBabysittersForIssue(record.issue);
|
|
12844
|
-
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
13163
|
+
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue)).catch(() => undefined);
|
|
12845
13164
|
if (!this.#usesDurableDispatchLifecycle() || (durable && isTerminalDispatchLifecycle(durable))) {
|
|
12846
13165
|
for (const publishedKey of this.#publishedPullRequests.keys()) {
|
|
12847
13166
|
if (publishedKey.startsWith(`${completionKey}:`))
|
|
@@ -12938,7 +13257,7 @@ export class FactoryLoop {
|
|
|
12938
13257
|
async #claimTicketDispatchNotification(record) {
|
|
12939
13258
|
if (record.dryRun || !this.#usesDurableDispatchLifecycle())
|
|
12940
13259
|
return true;
|
|
12941
|
-
const key =
|
|
13260
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
12942
13261
|
return this.#serializeDispatchLifecyclePersistence(key, async () => {
|
|
12943
13262
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
12944
13263
|
if (epoch === undefined) {
|
|
@@ -13001,7 +13320,7 @@ export class FactoryLoop {
|
|
|
13001
13320
|
void (async () => {
|
|
13002
13321
|
try {
|
|
13003
13322
|
const lifecycle = issue
|
|
13004
|
-
? await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
13323
|
+
? await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(issue)).catch(() => undefined)
|
|
13005
13324
|
: undefined;
|
|
13006
13325
|
if (lifecycle) {
|
|
13007
13326
|
await this.#reportLifecycle(lifecycle, 'factory.failure', {
|
|
@@ -13269,7 +13588,7 @@ export class FactoryLoop {
|
|
|
13269
13588
|
}
|
|
13270
13589
|
async #slackPullRequestRefs(record, additional = []) {
|
|
13271
13590
|
const lifecycle = await this.#state
|
|
13272
|
-
.getDispatchLifecycle(this.#workspaceId,
|
|
13591
|
+
.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
|
|
13273
13592
|
.catch(() => undefined);
|
|
13274
13593
|
const lifecycleRefs = publishedPullRequests(lifecycle).map((receipt) => ({
|
|
13275
13594
|
repo: receipt.repo,
|
|
@@ -13600,7 +13919,7 @@ export class FactoryLoop {
|
|
|
13600
13919
|
async #ownsActiveSlackConversationIssue(issue) {
|
|
13601
13920
|
if (!(await this.#batch()).getIssue(issue))
|
|
13602
13921
|
return false;
|
|
13603
|
-
const key =
|
|
13922
|
+
const key = dispatchLifecycleKey(issue);
|
|
13604
13923
|
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
13605
13924
|
if (!lifecycle)
|
|
13606
13925
|
return true;
|
|
@@ -14960,7 +15279,7 @@ export class FactoryLoop {
|
|
|
14960
15279
|
if (!waiting.dryRun && this.#usesDurableDispatchLifecycle()) {
|
|
14961
15280
|
try {
|
|
14962
15281
|
const claim = await this.#claimDispatchLifecycle(waiting.decision, false);
|
|
14963
|
-
const lifecycleKey =
|
|
15282
|
+
const lifecycleKey = dispatchLifecycleKey(waiting.issue);
|
|
14964
15283
|
const epoch = this.#dispatchLifecycleEpochs.get(lifecycleKey);
|
|
14965
15284
|
if (claim.lifecycle.phase === 'waiting-for-human' &&
|
|
14966
15285
|
(epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, lifecycleKey, this.#dispatchLifecycleOwner, epoch, this.#clock.now()))) {
|
|
@@ -15252,10 +15571,7 @@ export class FactoryLoop {
|
|
|
15252
15571
|
return;
|
|
15253
15572
|
}
|
|
15254
15573
|
const clarifiedIssue = issueWithSlackClarification(issue, text);
|
|
15255
|
-
const decision = await this.#
|
|
15256
|
-
config: this.#config,
|
|
15257
|
-
repoMap: repoMapFromConfig(this.#config),
|
|
15258
|
-
});
|
|
15574
|
+
const decision = await this.#triageWithOrigin(clarifiedIssue);
|
|
15259
15575
|
const escalationReason = triageEscalationReason(decision);
|
|
15260
15576
|
if (escalationReason) {
|
|
15261
15577
|
if (hasDispatchableRoute(decision)) {
|
|
@@ -15819,7 +16135,54 @@ const issueRefFromPath = (path) => {
|
|
|
15819
16135
|
const key = keyFromPath(path);
|
|
15820
16136
|
return { uuid: uuidFromPath(path) ?? key, key, path };
|
|
15821
16137
|
};
|
|
15822
|
-
const issueRef = (issue) =>
|
|
16138
|
+
const issueRef = (issue) => {
|
|
16139
|
+
const origin = mirrorWorkUnitOrigin(issue);
|
|
16140
|
+
return { uuid: issue.uuid, key: issue.key, path: issue.path, ...(origin ? { origin } : {}) };
|
|
16141
|
+
};
|
|
16142
|
+
/**
|
|
16143
|
+
* An `IssueRef` for a canonical-state record, carrying the mirror origin from
|
|
16144
|
+
* whichever shape the caller had — a full `LinearIssue`, or an `IssueRef` that
|
|
16145
|
+
* already resolved one.
|
|
16146
|
+
*/
|
|
16147
|
+
const issueRefForState = (issue) => {
|
|
16148
|
+
const origin = issue.origin ??
|
|
16149
|
+
(issue.raw === undefined ? undefined : mirrorWorkUnitOrigin({ path: issue.path, raw: issue.raw }));
|
|
16150
|
+
return { uuid: issue.uuid, key: issue.key, path: issue.path, ...(origin ? { origin } : {}) };
|
|
16151
|
+
};
|
|
16152
|
+
/** Work-unit key, or undefined for a row with no derivable provider identity. */
|
|
16153
|
+
const safeDispatchLifecycleKey = (issue) => {
|
|
16154
|
+
try {
|
|
16155
|
+
return dispatchLifecycleKey(issue);
|
|
16156
|
+
}
|
|
16157
|
+
catch {
|
|
16158
|
+
return undefined;
|
|
16159
|
+
}
|
|
16160
|
+
};
|
|
16161
|
+
/**
|
|
16162
|
+
* Re-attaches the provider origin a triage engine did not carry through.
|
|
16163
|
+
* Only fills a gap — an engine that already resolved an origin keeps it.
|
|
16164
|
+
*/
|
|
16165
|
+
const decisionWithMirrorOrigin = (decision, issue) => {
|
|
16166
|
+
if (decision.issue.origin)
|
|
16167
|
+
return decision;
|
|
16168
|
+
const origin = mirrorWorkUnitOrigin(issue);
|
|
16169
|
+
return origin ? { ...decision, issue: { ...decision.issue, origin } } : decision;
|
|
16170
|
+
};
|
|
16171
|
+
/**
|
|
16172
|
+
* The claim/dedup/lifecycle authority key: the work unit, not the surface it
|
|
16173
|
+
* arrived through.
|
|
16174
|
+
*
|
|
16175
|
+
* `issueKey()` composes the Relayfile sense path, so the same issue reaching
|
|
16176
|
+
* Factory GitHub-native and through its own Linear mirror hashed to two keys
|
|
16177
|
+
* and the lease never saw a conflict — both dispatched (#211, AR-448). Every
|
|
16178
|
+
* durable lifecycle read and write goes through this instead.
|
|
16179
|
+
*
|
|
16180
|
+
* `issueKey()` deliberately stays as-is for the surface-scoped correlation
|
|
16181
|
+
* maps — waiting clarifications, babysitter sessions, Slack threads, preview
|
|
16182
|
+
* ownership — which are keyed by surface on disk and would be orphaned by a
|
|
16183
|
+
* global redefinition.
|
|
16184
|
+
*/
|
|
16185
|
+
const dispatchLifecycleKey = (issue) => dispatchIssueIdentity(issue);
|
|
15823
16186
|
// Preserve the historical Linear state namespace while keeping GitHub-native
|
|
15824
16187
|
// issue numbers independent across repositories in the same workspace.
|
|
15825
16188
|
const issueStateKey = (issue) => githubIssuePathParts(issue.path) ? issueKey(issue) : issue.key;
|
|
@@ -16992,9 +17355,10 @@ const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
|
|
|
16992
17355
|
return 0;
|
|
16993
17356
|
};
|
|
16994
17357
|
const hasTitlePrefix = (title, marker) => title === marker || title.startsWith(`${marker} `);
|
|
16995
|
-
|
|
16996
|
-
|
|
16997
|
-
|
|
17358
|
+
// Single definition shared with closure authority, so the branch rule that
|
|
17359
|
+
// grants a score-30 association and the one that authorises a close can never
|
|
17360
|
+
// drift apart.
|
|
17361
|
+
const factoryBranchMatchesIssue = branchImplementsIssue;
|
|
16998
17362
|
// Legacy Factory runs created GitHub-native branches as `<issue-number>-*`
|
|
16999
17363
|
// before the current `factory/<issue>-*` convention. Keep this matcher out of
|
|
17000
17364
|
// generic PR discovery: it is only enabled by orphan recovery, where the issue
|