@agent-relay/factory 0.1.67 → 0.1.69
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.map +1 -1
- package/dist/cli/fleet.js +14 -2
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +87 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +59 -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 +6 -0
- package/dist/fleet/create-fleet.d.ts.map +1 -1
- package/dist/fleet/create-fleet.js +1 -0
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +23 -0
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +206 -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 +410 -91
- 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 +51 -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';
|
|
@@ -807,7 +809,7 @@ export class FactoryLoop {
|
|
|
807
809
|
* row to wait on, or the wait ended because Factory is stopping.
|
|
808
810
|
*/
|
|
809
811
|
async waitForDispatchTerminal(issue) {
|
|
810
|
-
const key =
|
|
812
|
+
const key = dispatchLifecycleKey(issue);
|
|
811
813
|
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
812
814
|
// No durable row means this dispatch never claimed a lifecycle: a
|
|
813
815
|
// dependency park, a triage escalation, and a label refusal all return
|
|
@@ -1853,7 +1855,7 @@ export class FactoryLoop {
|
|
|
1853
1855
|
try {
|
|
1854
1856
|
const batch = await this.#batch();
|
|
1855
1857
|
const records = batch.inFlight
|
|
1856
|
-
.filter((record) => !record.dryRun && !this.#completionInFlight.has(
|
|
1858
|
+
.filter((record) => !record.dryRun && !this.#completionInFlight.has(dispatchLifecycleKey(record.issue)));
|
|
1857
1859
|
if (records.length === 0) {
|
|
1858
1860
|
return;
|
|
1859
1861
|
}
|
|
@@ -2009,7 +2011,7 @@ export class FactoryLoop {
|
|
|
2009
2011
|
async #orphanedPrMetaPaths(record) {
|
|
2010
2012
|
const wanted = [];
|
|
2011
2013
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
2012
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
2014
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
2013
2015
|
const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
|
|
2014
2016
|
for (const receipt of receipts)
|
|
2015
2017
|
wanted.push({ repo: receipt.repo, prNumber: receipt.number });
|
|
@@ -2560,12 +2562,13 @@ export class FactoryLoop {
|
|
|
2560
2562
|
const labels = isGithubIssue(issue)
|
|
2561
2563
|
? new Set(issue.labels.map((label) => label.trim().toLowerCase()))
|
|
2562
2564
|
: undefined;
|
|
2563
|
-
|
|
2565
|
+
// Must stay the same scope test as dispatch and as
|
|
2566
|
+
// #reconcileOrphanedGithubInProgress. Gating on the scope label alone
|
|
2567
|
+
// left every title-scoped issue stuck in `factory:in-progress` forever.
|
|
2564
2568
|
const mayRecoverGithubOrphan = !wasReady &&
|
|
2565
2569
|
!dryRun &&
|
|
2566
2570
|
issueSource === 'github' &&
|
|
2567
|
-
|
|
2568
|
-
Boolean(labels?.has(requiredLabel)) &&
|
|
2571
|
+
isInFactoryScope(issue, this.#config.safety) &&
|
|
2569
2572
|
Boolean(labels?.has('factory:in-progress')) &&
|
|
2570
2573
|
!labels?.has('factory:human-review');
|
|
2571
2574
|
if (!mayRecoverGithubOrphan) {
|
|
@@ -3182,9 +3185,13 @@ export class FactoryLoop {
|
|
|
3182
3185
|
if (!isGithubIssue(issue))
|
|
3183
3186
|
return { recovered: false, reason: 'issue is not GitHub-native' };
|
|
3184
3187
|
const labels = new Set(issue.labels.map((label) => label.trim().toLowerCase()));
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
+
// Recovery must admit exactly what dispatch admits. Dispatch accepts the
|
|
3189
|
+
// configured title prefix OR the scope label (`isInFactoryScope`), but this
|
|
3190
|
+
// gate used to demand the label alone — so an issue admitted by its title
|
|
3191
|
+
// could be dispatched and then never un-stuck, keeping `factory:in-progress`
|
|
3192
|
+
// forever once its dispatch died. factory#139 is the live instance: title
|
|
3193
|
+
// `[factory] ...`, and its only label is `factory:in-progress`.
|
|
3194
|
+
if (!isInFactoryScope(issue, this.#config.safety) ||
|
|
3188
3195
|
!labels.has('factory:in-progress') ||
|
|
3189
3196
|
labels.has('factory:human-review'))
|
|
3190
3197
|
return { recovered: false, reason: 'issue is not an orphan-recovery candidate' };
|
|
@@ -3316,7 +3323,7 @@ export class FactoryLoop {
|
|
|
3316
3323
|
}
|
|
3317
3324
|
}
|
|
3318
3325
|
async #releaseOrphanedGithubLifecycle(issue, identity, candidate) {
|
|
3319
|
-
|
|
3326
|
+
const key = candidate.key;
|
|
3320
3327
|
let lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
3321
3328
|
if (!lifecycle)
|
|
3322
3329
|
return true;
|
|
@@ -3356,7 +3363,6 @@ export class FactoryLoop {
|
|
|
3356
3363
|
});
|
|
3357
3364
|
return false;
|
|
3358
3365
|
}
|
|
3359
|
-
key = claim.key ?? key;
|
|
3360
3366
|
lifecycle = claim.lifecycle;
|
|
3361
3367
|
epoch = claim.lease.epoch;
|
|
3362
3368
|
this.#dispatchLifecycleEpochs.set(key, epoch);
|
|
@@ -3499,7 +3505,7 @@ export class FactoryLoop {
|
|
|
3499
3505
|
const record = batch.start(decision, false);
|
|
3500
3506
|
if (!record) {
|
|
3501
3507
|
if (durableAdoption) {
|
|
3502
|
-
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
3508
|
+
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(decision.issue));
|
|
3503
3509
|
if (durable) {
|
|
3504
3510
|
this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(durable));
|
|
3505
3511
|
this.#increment('githubOrphanedPullRequestsAdopted');
|
|
@@ -3814,15 +3820,29 @@ export class FactoryLoop {
|
|
|
3814
3820
|
}
|
|
3815
3821
|
}
|
|
3816
3822
|
async triageIssue(issue) {
|
|
3817
|
-
return this.#
|
|
3823
|
+
return await this.#triageWithOrigin(issue);
|
|
3824
|
+
}
|
|
3825
|
+
/**
|
|
3826
|
+
* The mirror origin is a property of the issue Factory fed in, not of the
|
|
3827
|
+
* engine's answer, so it is re-attached here rather than trusted from the
|
|
3828
|
+
* engine. `TriageDecisionSchema` drops unknown keys, so `LlmTriage` returns a
|
|
3829
|
+
* decision that has lost it, and a custom `TriageEngine` never set it at all
|
|
3830
|
+
* — either way a Linear mirror would fall back to `linear:<uuid>` and
|
|
3831
|
+
* duplicate its own GitHub-native arrival, which is the defect #211 fixes.
|
|
3832
|
+
*/
|
|
3833
|
+
async #triageWithOrigin(issue) {
|
|
3834
|
+
const decision = await this.#triage.triage(issue, {
|
|
3818
3835
|
config: this.#config,
|
|
3819
3836
|
repoMap: repoMapFromConfig(this.#config),
|
|
3820
3837
|
});
|
|
3838
|
+
return decisionWithMirrorOrigin(decision, issue);
|
|
3821
3839
|
}
|
|
3822
3840
|
async dispatch(decision, opts = {}) {
|
|
3823
3841
|
const dryRun = opts.dryRun ?? this.#config.dryRun;
|
|
3824
3842
|
const phase = triageEscalationReason(decision) ? 'escalation' : 'dispatch';
|
|
3825
|
-
|
|
3843
|
+
// The dispatch concurrency gate is per work unit: a mirror of an issue
|
|
3844
|
+
// already being dispatched must join that call, not start a second one.
|
|
3845
|
+
const key = `${dispatchLifecycleKey(decision.issue)}:${dryRun ? 'dry-run' : 'live'}:${phase}`;
|
|
3826
3846
|
const inFlight = this.#dispatchInFlight.get(key);
|
|
3827
3847
|
if (inFlight) {
|
|
3828
3848
|
this.#increment('dispatchDuplicateSuppressed');
|
|
@@ -3850,9 +3870,9 @@ export class FactoryLoop {
|
|
|
3850
3870
|
return existingRecord.result;
|
|
3851
3871
|
}
|
|
3852
3872
|
if (!dryRun && this.#usesDurableDispatchLifecycle()) {
|
|
3853
|
-
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
3873
|
+
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(decision.issue));
|
|
3854
3874
|
if (durable && !isTerminalDispatchLifecycle(durable)) {
|
|
3855
|
-
if (durable.result && this.#dispatchLifecycleEpochs.has(
|
|
3875
|
+
if (durable.result && this.#dispatchLifecycleEpochs.has(dispatchLifecycleKey(decision.issue))) {
|
|
3856
3876
|
return durable.result;
|
|
3857
3877
|
}
|
|
3858
3878
|
if (this.#startMode === 'dispatch-owner') {
|
|
@@ -4107,7 +4127,7 @@ export class FactoryLoop {
|
|
|
4107
4127
|
state: 'pending',
|
|
4108
4128
|
updatedAtMs: this.#clock.now(),
|
|
4109
4129
|
};
|
|
4110
|
-
this.#dispatchClaimStatuses.set(
|
|
4130
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
4111
4131
|
}
|
|
4112
4132
|
await this.#writeInFlightRegistry();
|
|
4113
4133
|
const comment = dispatchComment(dispatchDecision, agents);
|
|
@@ -4115,10 +4135,37 @@ export class FactoryLoop {
|
|
|
4115
4135
|
if (!dryRun) {
|
|
4116
4136
|
const issue = await this.#readIssue(dispatchDecision.issue.path);
|
|
4117
4137
|
if (!issue || !this.#isIssueReady(issue)) {
|
|
4118
|
-
|
|
4138
|
+
// The agents spawned a few lines above can reach terminal before we
|
|
4139
|
+
// get here. Their completion writeback parks the issue — Linear
|
|
4140
|
+
// `humanReview`, or the GitHub human-review label — and stamps
|
|
4141
|
+
// *this same record's* lifecycle on the way past. Re-reading the
|
|
4142
|
+
// issue then shows "not ready", but the writer was us.
|
|
4143
|
+
//
|
|
4144
|
+
// Treating that as a foreign change is not a cosmetic misreport: the
|
|
4145
|
+
// catch below classifies LiveDispatchStateChangedError as terminal
|
|
4146
|
+
// and calls #releaseAndTerminateAgents, so a dispatch whose agents
|
|
4147
|
+
// finished quickly tore down its own completed work and reported
|
|
4148
|
+
// RETRYABLE to its supervisor (factory#319).
|
|
4149
|
+
//
|
|
4150
|
+
// Only THIS dispatch's own confirmed issue writeback may excuse the
|
|
4151
|
+
// change. Lifecycle phases are not enough: `publishing` is entered
|
|
4152
|
+
// before the PR is published and `parking` before anything touches
|
|
4153
|
+
// the issue, so a phase records local progress, not authorship — and
|
|
4154
|
+
// a foreign park landing during those awaits would be misread as
|
|
4155
|
+
// ours (codex review on #321). Losing the row to another owner is a
|
|
4156
|
+
// different condition and is still caught where it always was, by
|
|
4157
|
+
// #saveDispatchLifecycle returning false.
|
|
4158
|
+
if (record.issueWritebackConfirmedAtMs === undefined) {
|
|
4159
|
+
throw new LiveDispatchStateChangedError(dispatchDecision.issue.key);
|
|
4160
|
+
}
|
|
4161
|
+
// The claim is moot and would be wrong to write: it would drag an
|
|
4162
|
+
// issue our own lifecycle has already parked back to `implementing`.
|
|
4163
|
+
}
|
|
4164
|
+
else {
|
|
4165
|
+
implementingStateId = await this.#applyDispatchClaim(record, issue, comment);
|
|
4166
|
+
record.issueWritebackConfirmedAtMs = this.#clock.now();
|
|
4167
|
+
this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
|
|
4119
4168
|
}
|
|
4120
|
-
implementingStateId = await this.#applyDispatchClaim(record, issue, comment);
|
|
4121
|
-
this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
|
|
4122
4169
|
}
|
|
4123
4170
|
const result = {
|
|
4124
4171
|
issue: dispatchDecision.issue,
|
|
@@ -4245,7 +4292,7 @@ export class FactoryLoop {
|
|
|
4245
4292
|
...(tracked.result?.node ? { node: tracked.result.node } : {}),
|
|
4246
4293
|
})),
|
|
4247
4294
|
claim: {
|
|
4248
|
-
...(record.dispatchClaim ?? this.#dispatchClaimStatuses.get(
|
|
4295
|
+
...(record.dispatchClaim ?? this.#dispatchClaimStatuses.get(dispatchLifecycleKey(record.issue)) ?? {
|
|
4249
4296
|
state: 'pending',
|
|
4250
4297
|
updatedAtMs: nowMs,
|
|
4251
4298
|
}),
|
|
@@ -4475,7 +4522,25 @@ export class FactoryLoop {
|
|
|
4475
4522
|
if (previews.length > 0)
|
|
4476
4523
|
this.#previewReferences.set(issueKey(lifecycle.issue), previews);
|
|
4477
4524
|
hasNonterminalDurableLifecycle = true;
|
|
4478
|
-
|
|
4525
|
+
// A work unit whose keys cannot be reconciled must not strand the rows
|
|
4526
|
+
// behind it — #315 is the same lesson from the shed-read path. Retain
|
|
4527
|
+
// the conflicting rows, leave that one unit blocked for an operator,
|
|
4528
|
+
// and carry on adopting the rest.
|
|
4529
|
+
let claim;
|
|
4530
|
+
try {
|
|
4531
|
+
claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, lifecycle, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
|
|
4532
|
+
}
|
|
4533
|
+
catch (error) {
|
|
4534
|
+
if (!(error instanceof DispatchLifecycleMigrationConflictError))
|
|
4535
|
+
throw error;
|
|
4536
|
+
this.#increment('dispatchLifecycleMigrationConflicts');
|
|
4537
|
+
this.#logger.error?.('[factory] dispatch is blocked for one work unit until its keys are reconciled', {
|
|
4538
|
+
issue: lifecycle.issue.key,
|
|
4539
|
+
canonicalKey: error.canonicalKey,
|
|
4540
|
+
conflictingKeys: error.conflictingKeys,
|
|
4541
|
+
});
|
|
4542
|
+
continue;
|
|
4543
|
+
}
|
|
4479
4544
|
if (!claim.acquired || !claim.lease) {
|
|
4480
4545
|
// The other process may have crashed while its nominal lease is
|
|
4481
4546
|
// still live. Keep this process attached so it reclaims the row
|
|
@@ -4483,7 +4548,7 @@ export class FactoryLoop {
|
|
|
4483
4548
|
this.#scheduleDispatchLifecycleRetry(inFlightRecordFromLifecycle(claim.lifecycle));
|
|
4484
4549
|
continue;
|
|
4485
4550
|
}
|
|
4486
|
-
this.#dispatchLifecycleEpochs.set(
|
|
4551
|
+
this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
|
|
4487
4552
|
this.#hydrateCostLedger(claim.lifecycle);
|
|
4488
4553
|
if (claim.lifecycle.phase === 'waiting-for-human')
|
|
4489
4554
|
continue;
|
|
@@ -4492,7 +4557,37 @@ export class FactoryLoop {
|
|
|
4492
4557
|
if (!durableRecord.dryRun &&
|
|
4493
4558
|
claim.lifecycle.phase !== 'writeback-applied' &&
|
|
4494
4559
|
claim.lifecycle.phase !== 'releasing') {
|
|
4495
|
-
|
|
4560
|
+
// `#readIssue` swallows every read failure except a relayfile 429,
|
|
4561
|
+
// which it rethrows so callers can back off. On this path that
|
|
4562
|
+
// rethrow aborted the whole adoption: this row was never restored
|
|
4563
|
+
// into the batch, every later row was skipped, and no hold deadline
|
|
4564
|
+
// was armed for any of them (#315). Startup already degrades safely
|
|
4565
|
+
// when the issue is simply unreadable — it treats the row as not
|
|
4566
|
+
// externally terminal and lets the retry driver carry it — so a shed
|
|
4567
|
+
// read must degrade the same way rather than strand the batch.
|
|
4568
|
+
try {
|
|
4569
|
+
liveIssue = await this.#readIssue(durableRecord.issue.path);
|
|
4570
|
+
}
|
|
4571
|
+
catch (error) {
|
|
4572
|
+
const overload = relayfileOverload(error);
|
|
4573
|
+
if (!overload)
|
|
4574
|
+
throw error;
|
|
4575
|
+
this.#increment('startupIssueReadsShed');
|
|
4576
|
+
this.#logger.warn?.('[factory] startup issue read was shed; adopting without it', {
|
|
4577
|
+
issue: durableRecord.issue.key,
|
|
4578
|
+
status: overload.status,
|
|
4579
|
+
reason: relayfileOverloadReasonLabel(overload.reason),
|
|
4580
|
+
});
|
|
4581
|
+
}
|
|
4582
|
+
// A row persisted before #211 has no `origin`, so a Linear mirror
|
|
4583
|
+
// still resolves to `linear:<uuid>` and a GitHub-native arrival for
|
|
4584
|
+
// the same work unit would claim alongside it — the rolling-upgrade
|
|
4585
|
+
// form of the duplicate this change removes. Nothing on the row links
|
|
4586
|
+
// it to its upstream, but the issue we just read does, so backfill it
|
|
4587
|
+
// here under the lease this process already holds. The next claim or
|
|
4588
|
+
// load then recognises the row by identity and re-keys it.
|
|
4589
|
+
if (liveIssue)
|
|
4590
|
+
await this.#backfillMirrorOrigin(key, claim, liveIssue);
|
|
4496
4591
|
// A babysat Linear issue already at Done may have merged while this
|
|
4497
4592
|
// process was down. Let authoritative PR restoration drive the
|
|
4498
4593
|
// normal `complete` path so merged work is not mislabeled abandoned.
|
|
@@ -4522,7 +4617,7 @@ export class FactoryLoop {
|
|
|
4522
4617
|
if (claim.lifecycle.phase === 'running' &&
|
|
4523
4618
|
this.#ticketDispatchNotificationIsPending(claim.lifecycle)) {
|
|
4524
4619
|
if (!liveIssue) {
|
|
4525
|
-
this.#dispatchLifecycleEpochs.delete(
|
|
4620
|
+
this.#dispatchLifecycleEpochs.delete(key);
|
|
4526
4621
|
this.#scheduleDispatchLifecycleRetry(restored);
|
|
4527
4622
|
throw new Error(`Unable to recover ticket-dispatch notification ${restored.issue.key}: issue is not currently readable`);
|
|
4528
4623
|
}
|
|
@@ -4629,11 +4724,28 @@ export class FactoryLoop {
|
|
|
4629
4724
|
});
|
|
4630
4725
|
}
|
|
4631
4726
|
}
|
|
4632
|
-
this.#rescheduleHeldAgentDeadlineSweep();
|
|
4633
4727
|
}
|
|
4634
4728
|
catch (error) {
|
|
4635
4729
|
this.#logger.warn?.('[factory] failed to re-adopt durable in-flight agents', { error });
|
|
4636
4730
|
}
|
|
4731
|
+
finally {
|
|
4732
|
+
// Arming has to survive everything above it (#315).
|
|
4733
|
+
//
|
|
4734
|
+
// This is the only thing that arms a hold deadline for a row that
|
|
4735
|
+
// survived a restart, and it used to be the last statement of the `try`.
|
|
4736
|
+
// Two fleet awaits sit above it — `reconcileTrackedAgents`, which is not
|
|
4737
|
+
// circuit-proxied and so reaches the transport, and the guarded `roster`,
|
|
4738
|
+
// which throws outright once the circuit is open. With the broker down
|
|
4739
|
+
// either one throws, the catch logs, and no hold deadline is armed for
|
|
4740
|
+
// that boot: the #304 reaper is present in the build and was never
|
|
4741
|
+
// scheduled. That is the failure mode reaping exists to clear, so the
|
|
4742
|
+
// arming must not be reachable only along the happy path.
|
|
4743
|
+
//
|
|
4744
|
+
// A `dispatching` row is additionally armed by the per-row retry driver
|
|
4745
|
+
// below, which is why the wedge shows up on a `running` row: `running` is
|
|
4746
|
+
// the one slot-occupying phase that never gets a retry scheduled here.
|
|
4747
|
+
this.#rescheduleHeldAgentDeadlineSweep();
|
|
4748
|
+
}
|
|
4637
4749
|
}
|
|
4638
4750
|
async #deduplicateQueuedGithubLifecycleAliases(lifecycles) {
|
|
4639
4751
|
const groups = new Map();
|
|
@@ -4852,7 +4964,7 @@ export class FactoryLoop {
|
|
|
4852
4964
|
// never-placed — which also excludes its agents from the release below,
|
|
4853
4965
|
// leaking live workers (#303 review, CodeRabbit).
|
|
4854
4966
|
let effective = deadline;
|
|
4855
|
-
const key =
|
|
4967
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
4856
4968
|
if (this.#abandonedDispatchReasons.has(key))
|
|
4857
4969
|
continue;
|
|
4858
4970
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
@@ -4904,6 +5016,36 @@ export class FactoryLoop {
|
|
|
4904
5016
|
}
|
|
4905
5017
|
}
|
|
4906
5018
|
}
|
|
5019
|
+
/**
|
|
5020
|
+
* Records the upstream origin on an adopted lifecycle that predates it.
|
|
5021
|
+
* Best-effort: a failed write leaves the row exactly as it was, and the next
|
|
5022
|
+
* startup tries again.
|
|
5023
|
+
*/
|
|
5024
|
+
async #backfillMirrorOrigin(key, claim, liveIssue) {
|
|
5025
|
+
const epoch = claim.lease?.epoch;
|
|
5026
|
+
if (epoch === undefined || claim.lifecycle.issue.origin)
|
|
5027
|
+
return;
|
|
5028
|
+
const origin = mirrorWorkUnitOrigin(liveIssue);
|
|
5029
|
+
if (!origin)
|
|
5030
|
+
return;
|
|
5031
|
+
const issue = { ...claim.lifecycle.issue, origin };
|
|
5032
|
+
const enriched = {
|
|
5033
|
+
...claim.lifecycle,
|
|
5034
|
+
issue,
|
|
5035
|
+
decision: { ...claim.lifecycle.decision, issue },
|
|
5036
|
+
};
|
|
5037
|
+
const saved = await this.#state.saveDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), enriched).catch((error) => {
|
|
5038
|
+
this.#logger.warn?.('[factory] could not backfill mirror origin on an adopted lifecycle', {
|
|
5039
|
+
issue: claim.lifecycle.issue.key,
|
|
5040
|
+
error: describeError(error).errorMessage,
|
|
5041
|
+
});
|
|
5042
|
+
return false;
|
|
5043
|
+
});
|
|
5044
|
+
if (!saved)
|
|
5045
|
+
return;
|
|
5046
|
+
claim.lifecycle = enriched;
|
|
5047
|
+
this.#increment('dispatchLifecycleMirrorOriginsBackfilled');
|
|
5048
|
+
}
|
|
4907
5049
|
async #renewDispatchLifecycles() {
|
|
4908
5050
|
for (const [key, epoch] of [...this.#dispatchLifecycleEpochs]) {
|
|
4909
5051
|
const renewed = await this.#state.renewDispatchLifecycle(this.#workspaceId, key, this.#dispatchLifecycleOwner, epoch, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
|
|
@@ -4928,7 +5070,7 @@ export class FactoryLoop {
|
|
|
4928
5070
|
}
|
|
4929
5071
|
}
|
|
4930
5072
|
async #claimDispatchLifecycle(decision, dryRun, preparedRunId, initial = {}) {
|
|
4931
|
-
const key =
|
|
5073
|
+
const key = dispatchLifecycleKey(decision.issue);
|
|
4932
5074
|
const seed = {
|
|
4933
5075
|
runId: preparedRunId ?? randomUUID(),
|
|
4934
5076
|
issue: { ...decision.issue },
|
|
@@ -4951,7 +5093,7 @@ export class FactoryLoop {
|
|
|
4951
5093
|
: `dispatch lifecycle is owned by ${claim.lifecycle.lease?.owner ?? 'another publisher'}`;
|
|
4952
5094
|
throw new DispatchLifecycleClaimRefusedError(decision.issue.key, terminal ? 'terminal' : 'owned-elsewhere', `Refusing to dispatch ${decision.issue.key}: ${reason}`);
|
|
4953
5095
|
}
|
|
4954
|
-
this.#dispatchLifecycleEpochs.set(
|
|
5096
|
+
this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
|
|
4955
5097
|
this.#hydrateCostLedger(claim.lifecycle);
|
|
4956
5098
|
this.#scheduleDispatchLifecycleRenewal();
|
|
4957
5099
|
if (claim.created) {
|
|
@@ -5007,7 +5149,7 @@ export class FactoryLoop {
|
|
|
5007
5149
|
}
|
|
5008
5150
|
async #reportAgent(record, tracked, type, options = {}) {
|
|
5009
5151
|
const lifecycle = await this.#state
|
|
5010
|
-
.getDispatchLifecycle(this.#workspaceId,
|
|
5152
|
+
.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
|
|
5011
5153
|
.catch(() => undefined);
|
|
5012
5154
|
if (!lifecycle)
|
|
5013
5155
|
return;
|
|
@@ -5035,7 +5177,7 @@ export class FactoryLoop {
|
|
|
5035
5177
|
}
|
|
5036
5178
|
async #handleAgentUsage(usage, key) {
|
|
5037
5179
|
const record = (await this.#batch()).getIssueByAgent(usage.name);
|
|
5038
|
-
const activeRecord = record &&
|
|
5180
|
+
const activeRecord = record && dispatchLifecycleKey(record.issue) === key ? record : undefined;
|
|
5039
5181
|
let tracked = activeRecord?.agents.get(usage.name);
|
|
5040
5182
|
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
5041
5183
|
// Completion removes the in-memory batch record immediately after the
|
|
@@ -5143,7 +5285,7 @@ export class FactoryLoop {
|
|
|
5143
5285
|
async #dispatchLifecycleKeyForAgent(name) {
|
|
5144
5286
|
const record = (await this.#batch()).getIssueByAgent(name);
|
|
5145
5287
|
if (record?.agents.has(name))
|
|
5146
|
-
return
|
|
5288
|
+
return dispatchLifecycleKey(record.issue);
|
|
5147
5289
|
return (await this.#findDispatchLifecycleAgent(name))?.key;
|
|
5148
5290
|
}
|
|
5149
5291
|
#serializeDispatchLifecyclePersistence(key, operation) {
|
|
@@ -5283,7 +5425,7 @@ export class FactoryLoop {
|
|
|
5283
5425
|
return true;
|
|
5284
5426
|
if (isTerminalDispatchPhase(phase))
|
|
5285
5427
|
await this.#drainAgentUsage();
|
|
5286
|
-
const key =
|
|
5428
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
5287
5429
|
return this.#serializeDispatchLifecyclePersistence(key, async () => {
|
|
5288
5430
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
5289
5431
|
if (epoch === undefined) {
|
|
@@ -5368,7 +5510,7 @@ export class FactoryLoop {
|
|
|
5368
5510
|
// after release could see it cleared, or see the next dispatch for the same
|
|
5369
5511
|
// issue, and classify the wrong run.
|
|
5370
5512
|
#resolveDispatchTerminalWaiters(issue, phase) {
|
|
5371
|
-
const key =
|
|
5513
|
+
const key = dispatchLifecycleKey(issue);
|
|
5372
5514
|
for (const resolve of this.#dispatchTerminalWaiters.get(key) ?? [])
|
|
5373
5515
|
resolve(phase);
|
|
5374
5516
|
this.#dispatchTerminalWaiters.delete(key);
|
|
@@ -5497,7 +5639,7 @@ export class FactoryLoop {
|
|
|
5497
5639
|
this.#increment('dispatchCapacityBackoffResets');
|
|
5498
5640
|
}
|
|
5499
5641
|
#scheduleDispatchLifecycleRetry(record, delayMs = DISPATCH_LIFECYCLE_RETRY_MS) {
|
|
5500
|
-
const key =
|
|
5642
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
5501
5643
|
if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
|
|
5502
5644
|
return;
|
|
5503
5645
|
const timer = setTimeout(() => {
|
|
@@ -5547,7 +5689,7 @@ export class FactoryLoop {
|
|
|
5547
5689
|
this.#scheduleDispatchLifecycleRetry(record);
|
|
5548
5690
|
return;
|
|
5549
5691
|
}
|
|
5550
|
-
const key =
|
|
5692
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
5551
5693
|
if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
|
|
5552
5694
|
return;
|
|
5553
5695
|
const timer = setTimeout(() => {
|
|
@@ -5585,7 +5727,7 @@ export class FactoryLoop {
|
|
|
5585
5727
|
if (!claim.acquired || !claim.lease) {
|
|
5586
5728
|
throw new DispatchLifecycleOwnedElsewhereError(claim.lifecycle.lease?.leaseUntilMs);
|
|
5587
5729
|
}
|
|
5588
|
-
this.#dispatchLifecycleEpochs.set(
|
|
5730
|
+
this.#dispatchLifecycleEpochs.set(key, claim.lease.epoch);
|
|
5589
5731
|
this.#hydrateCostLedger(claim.lifecycle);
|
|
5590
5732
|
this.#scheduleDispatchLifecycleRenewal();
|
|
5591
5733
|
lifecycle = claim.lifecycle;
|
|
@@ -5838,7 +5980,7 @@ export class FactoryLoop {
|
|
|
5838
5980
|
state: 'pending',
|
|
5839
5981
|
updatedAtMs: this.#clock.now(),
|
|
5840
5982
|
};
|
|
5841
|
-
this.#dispatchClaimStatuses.set(
|
|
5983
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
5842
5984
|
}
|
|
5843
5985
|
await this.#writeInFlightRegistry();
|
|
5844
5986
|
if (!record.dryRun) {
|
|
@@ -5964,7 +6106,7 @@ export class FactoryLoop {
|
|
|
5964
6106
|
async #finishDurableRelease(record, releaseReason) {
|
|
5965
6107
|
const batch = await this.#batch();
|
|
5966
6108
|
const reason = releaseReason ?? (this.#config.terminalState === 'human-review' ? 'issue-human-review' : 'issue-done');
|
|
5967
|
-
const releaseKey =
|
|
6109
|
+
const releaseKey = dispatchLifecycleKey(record.issue);
|
|
5968
6110
|
// Terminal writeback has already been acknowledged before this method is
|
|
5969
6111
|
// entered. Remove externally reachable routes first so a stuck agent
|
|
5970
6112
|
// release cannot leave a preview live after Human Review or Done.
|
|
@@ -6040,7 +6182,7 @@ export class FactoryLoop {
|
|
|
6040
6182
|
async #assertIssueDispatchLifecycleOwner(issue) {
|
|
6041
6183
|
if (!this.#usesDurableDispatchLifecycle())
|
|
6042
6184
|
return true;
|
|
6043
|
-
const key =
|
|
6185
|
+
const key = dispatchLifecycleKey(issue);
|
|
6044
6186
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
6045
6187
|
if (epoch === undefined)
|
|
6046
6188
|
return false;
|
|
@@ -6179,7 +6321,7 @@ export class FactoryLoop {
|
|
|
6179
6321
|
state: 'verified',
|
|
6180
6322
|
updatedAtMs: this.#clock.now(),
|
|
6181
6323
|
};
|
|
6182
|
-
this.#dispatchClaimStatuses.set(
|
|
6324
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
6183
6325
|
await this.#writeDispatchClaimRegistry(record.issue);
|
|
6184
6326
|
return implementingStateId;
|
|
6185
6327
|
}
|
|
@@ -6208,7 +6350,7 @@ export class FactoryLoop {
|
|
|
6208
6350
|
...(deadLettered ? { deadLettered: true } : {}),
|
|
6209
6351
|
updatedAtMs: this.#clock.now(),
|
|
6210
6352
|
};
|
|
6211
|
-
this.#dispatchClaimStatuses.set(
|
|
6353
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
6212
6354
|
await this.#writeDispatchClaimRegistry(record.issue);
|
|
6213
6355
|
if (deadLettered) {
|
|
6214
6356
|
this.#increment('dispatchWritebackDeadLetters');
|
|
@@ -6437,7 +6579,16 @@ export class FactoryLoop {
|
|
|
6437
6579
|
!labels.every((label) => typeof label === 'string')) {
|
|
6438
6580
|
return undefined;
|
|
6439
6581
|
}
|
|
6440
|
-
|
|
6582
|
+
// Retain Factory's own lifecycle rows even when they lack the scope
|
|
6583
|
+
// label. The index carries no title, so a title-scoped issue cannot be
|
|
6584
|
+
// recognised here — and dropping it means its file is never read and the
|
|
6585
|
+
// orphan-recovery sweep never sees it. `factory:in-progress` is a label
|
|
6586
|
+
// only Factory applies, so a row carrying it is by definition
|
|
6587
|
+
// Factory-touched and worth reading; `isInFactoryScope` downstream
|
|
6588
|
+
// remains the authority on whether anything may be done with it.
|
|
6589
|
+
const rowLabels = labels.map((label) => label.trim().toLowerCase());
|
|
6590
|
+
if (state !== 'open' ||
|
|
6591
|
+
!(rowLabels.includes(requiredLabel) || rowLabels.includes('factory:in-progress'))) {
|
|
6441
6592
|
continue;
|
|
6442
6593
|
}
|
|
6443
6594
|
paths.push(`${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/by-id/${number}.json`);
|
|
@@ -7034,11 +7185,20 @@ export class FactoryLoop {
|
|
|
7034
7185
|
await this.#state.recordDispatchAttempt(this.#workspaceId, key, dispatchState);
|
|
7035
7186
|
this.#increment('dispatchTerminalReopened');
|
|
7036
7187
|
}
|
|
7037
|
-
|
|
7038
|
-
|
|
7188
|
+
// Match on the work unit, not the surface key: a completed Linear mirror
|
|
7189
|
+
// persists `AR-448` while the GitHub-native arrival of the same issue is
|
|
7190
|
+
// `448`, and comparing surface keys would leave that terminal row in
|
|
7191
|
+
// place to refuse the reopened work forever.
|
|
7192
|
+
const reopenedIdentity = safeDispatchLifecycleKey(issueRefForState(issue));
|
|
7193
|
+
for (const [lifecycleKey, lifecycle] of await this.#state.listDispatchLifecycles(this.#workspaceId)) {
|
|
7194
|
+
if (!isTerminalDispatchLifecycle(lifecycle))
|
|
7039
7195
|
continue;
|
|
7040
|
-
|
|
7041
|
-
|
|
7196
|
+
const matches = reopenedIdentity !== undefined &&
|
|
7197
|
+
safeDispatchLifecycleKey(lifecycle.issue) === reopenedIdentity;
|
|
7198
|
+
if (!matches && lifecycle.issue.key !== issue.key)
|
|
7199
|
+
continue;
|
|
7200
|
+
await this.#state.clearDispatchLifecycle(this.#workspaceId, lifecycleKey);
|
|
7201
|
+
this.#dispatchLifecycleEpochs.delete(lifecycleKey);
|
|
7042
7202
|
}
|
|
7043
7203
|
}
|
|
7044
7204
|
await this.#state.recordCanonicalState(this.#workspaceId, key, issue.stateId);
|
|
@@ -7081,8 +7241,10 @@ export class FactoryLoop {
|
|
|
7081
7241
|
error: describeError(error).errorMessage,
|
|
7082
7242
|
});
|
|
7083
7243
|
}
|
|
7084
|
-
|
|
7085
|
-
|
|
7244
|
+
// Atomic publish (#323): `/healthz` and the crash reaper read this file
|
|
7245
|
+
// while it is being rewritten, and a torn read reaches them as a missing
|
|
7246
|
+
// heartbeat, i.e. as a dead daemon.
|
|
7247
|
+
await writeJsonFileAtomically(path, heartbeat);
|
|
7086
7248
|
await this.#writeInFlightRegistry(registryPath, path);
|
|
7087
7249
|
const batch = this.#batchView;
|
|
7088
7250
|
await this.#report({
|
|
@@ -7369,7 +7531,7 @@ export class FactoryLoop {
|
|
|
7369
7531
|
this.#logger.warn?.(`[factory] failed to release ${agentName} during ${context}`, error);
|
|
7370
7532
|
if (record) {
|
|
7371
7533
|
const lifecycle = await this.#state
|
|
7372
|
-
.getDispatchLifecycle(this.#workspaceId,
|
|
7534
|
+
.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
|
|
7373
7535
|
.catch(() => undefined);
|
|
7374
7536
|
if (lifecycle) {
|
|
7375
7537
|
await this.#reportLifecycle(lifecycle, 'factory.failure', {
|
|
@@ -7544,7 +7706,7 @@ export class FactoryLoop {
|
|
|
7544
7706
|
}
|
|
7545
7707
|
}
|
|
7546
7708
|
const fleetTracked = this.#fleet.trackedAgents?.().get(agentName);
|
|
7547
|
-
const dispatchClaim = this.#dispatchClaimStatuses.get(
|
|
7709
|
+
const dispatchClaim = this.#dispatchClaimStatuses.get(dispatchLifecycleKey(issue));
|
|
7548
7710
|
agents.push({
|
|
7549
7711
|
name: agentName,
|
|
7550
7712
|
role: tracked.spec.role,
|
|
@@ -7568,7 +7730,7 @@ export class FactoryLoop {
|
|
|
7568
7730
|
if (record.dryRun)
|
|
7569
7731
|
continue;
|
|
7570
7732
|
if (record.dispatchClaim) {
|
|
7571
|
-
this.#dispatchClaimStatuses.set(
|
|
7733
|
+
this.#dispatchClaimStatuses.set(dispatchLifecycleKey(record.issue), record.dispatchClaim);
|
|
7572
7734
|
}
|
|
7573
7735
|
for (const [agentName, tracked] of record.agents) {
|
|
7574
7736
|
await appendAgent(record.issue, agentName, tracked, record);
|
|
@@ -7585,8 +7747,10 @@ export class FactoryLoop {
|
|
|
7585
7747
|
updatedAtMs,
|
|
7586
7748
|
agents,
|
|
7587
7749
|
};
|
|
7588
|
-
|
|
7589
|
-
|
|
7750
|
+
// Atomic publish (#323): the crash reaper reads this file in the same
|
|
7751
|
+
// sweep as the heartbeat, so leaving it torn keeps the reaper racy even
|
|
7752
|
+
// with an atomic heartbeat.
|
|
7753
|
+
await writeJsonFileAtomically(path, registry);
|
|
7590
7754
|
}
|
|
7591
7755
|
async #spawnAgent(record, spec, dryRun) {
|
|
7592
7756
|
const batch = await this.#batch();
|
|
@@ -7697,7 +7861,7 @@ export class FactoryLoop {
|
|
|
7697
7861
|
* durable row, which is authoritative when another owner terminalized it.
|
|
7698
7862
|
*/
|
|
7699
7863
|
async #dispatchLifecycleStillOwned(record) {
|
|
7700
|
-
const key =
|
|
7864
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
7701
7865
|
if (this.#abandonedDispatchReasons.has(key))
|
|
7702
7866
|
return false;
|
|
7703
7867
|
if (!this.#usesDurableDispatchLifecycle())
|
|
@@ -7814,7 +7978,7 @@ export class FactoryLoop {
|
|
|
7814
7978
|
if (tracingReconciledExit)
|
|
7815
7979
|
this.#logger.info?.('[factory] reconciled agent exit telemetry completed', { issue: record.issue.key, name });
|
|
7816
7980
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
7817
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
7981
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
7818
7982
|
if (lifecycle?.phase === 'parking') {
|
|
7819
7983
|
this.#increment('clarificationParkingExitsSuppressed');
|
|
7820
7984
|
return;
|
|
@@ -8004,7 +8168,7 @@ export class FactoryLoop {
|
|
|
8004
8168
|
// that exited immediately consume a batch slot forever while Factory
|
|
8005
8169
|
// continuously recreated it. One successful recovery is enough; a
|
|
8006
8170
|
// subsequent no-PR implementer exit is terminal and frees the slot.
|
|
8007
|
-
const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
8171
|
+
const recoveryLifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
8008
8172
|
const recoveryRunIdentity = recoveryLifecycle?.runId
|
|
8009
8173
|
?? tracked.spec.branch
|
|
8010
8174
|
?? batch.invocationIdFor(record.issue, tracked.spec);
|
|
@@ -8210,7 +8374,7 @@ export class FactoryLoop {
|
|
|
8210
8374
|
if (!matchesCurrentConvention && !matchesAuthorizedLegacyBranch) {
|
|
8211
8375
|
throw new Error(`Refusing to publish ${record.issue.key}: branch ${expectedHeadRef} belongs to a different issue`);
|
|
8212
8376
|
}
|
|
8213
|
-
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
8377
|
+
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
8214
8378
|
const cached = this.#publishedPullRequests.get(key);
|
|
8215
8379
|
if (cached) {
|
|
8216
8380
|
if (cached.headRef !== expectedHeadRef) {
|
|
@@ -8868,7 +9032,7 @@ export class FactoryLoop {
|
|
|
8868
9032
|
}
|
|
8869
9033
|
}
|
|
8870
9034
|
#scheduleAbandonedDispatchRetry(record, reason) {
|
|
8871
|
-
const key =
|
|
9035
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
8872
9036
|
if (this.#stopping || this.#dispatchLifecycleRetryTimers.has(key))
|
|
8873
9037
|
return;
|
|
8874
9038
|
const timer = setTimeout(() => {
|
|
@@ -8900,7 +9064,7 @@ export class FactoryLoop {
|
|
|
8900
9064
|
? githubIssuePathParts(record.issue.path)?.owner
|
|
8901
9065
|
: undefined;
|
|
8902
9066
|
const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
|
|
8903
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
9067
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
8904
9068
|
if (publishedPullRequests(lifecycle).some((receipt) => receipt.repo.toLowerCase() === repo.toLowerCase()))
|
|
8905
9069
|
return true;
|
|
8906
9070
|
return Boolean(await this.#openPullRequestByHead(repo, implementer.spec.branch));
|
|
@@ -10468,10 +10632,7 @@ export class FactoryLoop {
|
|
|
10468
10632
|
return false;
|
|
10469
10633
|
}
|
|
10470
10634
|
const clarifiedIssue = issueWithGithubClarification(issue, text);
|
|
10471
|
-
const decision = await this.#
|
|
10472
|
-
config: this.#config,
|
|
10473
|
-
repoMap: repoMapFromConfig(this.#config),
|
|
10474
|
-
});
|
|
10635
|
+
const decision = await this.#triageWithOrigin(clarifiedIssue);
|
|
10475
10636
|
const escalationReason = triageEscalationReason(decision);
|
|
10476
10637
|
if (escalationReason) {
|
|
10477
10638
|
if (hasDispatchableRoute(decision)) {
|
|
@@ -10922,7 +11083,7 @@ export class FactoryLoop {
|
|
|
10922
11083
|
for (const record of records) {
|
|
10923
11084
|
if (!await this.#assertIssueDispatchLifecycleOwner(record.issue))
|
|
10924
11085
|
continue;
|
|
10925
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
11086
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
10926
11087
|
// Babysitter sessions are independently durable and restore only after
|
|
10927
11088
|
// their mounted PR metadata passes the open/draft/issue-identity guard.
|
|
10928
11089
|
// Fold those validated receipts back into the lifecycle on takeover.
|
|
@@ -11575,7 +11736,7 @@ export class FactoryLoop {
|
|
|
11575
11736
|
});
|
|
11576
11737
|
}
|
|
11577
11738
|
async #suspendBabysitterWakeForHuman(state) {
|
|
11578
|
-
const key =
|
|
11739
|
+
const key = dispatchLifecycleKey(state.issue);
|
|
11579
11740
|
const [lifecycle, clarification] = await Promise.all([
|
|
11580
11741
|
this.#usesDurableDispatchLifecycle()
|
|
11581
11742
|
? this.#state.getDispatchLifecycle(this.#workspaceId, key)
|
|
@@ -12082,6 +12243,49 @@ export class FactoryLoop {
|
|
|
12082
12243
|
async #advanceMergedPrToDone(snapshot, repo, record) {
|
|
12083
12244
|
const mergedPullRequest = { repo, number: snapshot.number, url: snapshot.url };
|
|
12084
12245
|
if (record) {
|
|
12246
|
+
// An in-flight record's association comes from Factory's own dispatch,
|
|
12247
|
+
// not from the PR's prose, so closure here does NOT require a closing
|
|
12248
|
+
// keyword — requiring one would strand every ordinary dispatch whose PR
|
|
12249
|
+
// links its issue with a bare `Linear: AR-411`.
|
|
12250
|
+
//
|
|
12251
|
+
// What it does honour is an explicit disclaimer: a merged PR that says in
|
|
12252
|
+
// its own body that it does not fix this issue is taken at its word. That
|
|
12253
|
+
// is the exact shape of #313 (`Diagnostic for #155`).
|
|
12254
|
+
//
|
|
12255
|
+
// Ownership does NOT exempt a PR from this. Ownership settles which issue
|
|
12256
|
+
// the PR belongs to; it says nothing about whether the merge completed
|
|
12257
|
+
// that issue. Skipping the check for owned PRs would mean the same merged
|
|
12258
|
+
// body is refused before ownership is established and accepted after.
|
|
12259
|
+
if (prBodyDisclaimsClosing({ headRef: snapshot.headRef, body: snapshot.body, repo }, record.issue.key)) {
|
|
12260
|
+
this.#increment('mergedPrClosureRefused');
|
|
12261
|
+
this.#logger.warn?.('[factory] merged PR disclaims completing the issue it references', {
|
|
12262
|
+
issue: record.issue.key,
|
|
12263
|
+
prNumber: snapshot.number,
|
|
12264
|
+
repo,
|
|
12265
|
+
});
|
|
12266
|
+
return;
|
|
12267
|
+
}
|
|
12268
|
+
// The protected-label gate has to cover the tracked paths too, or an
|
|
12269
|
+
// incident issue that Factory itself dispatched is still auto-closed by
|
|
12270
|
+
// its own merge and `neverAutoCloseLabels` guarantees nothing.
|
|
12271
|
+
const tracked = await this.#readIssue(record.issue.path);
|
|
12272
|
+
const trackedBlockingLabel = tracked ? this.#neverAutoCloseLabel(tracked) : undefined;
|
|
12273
|
+
if (tracked && trackedBlockingLabel) {
|
|
12274
|
+
await this.#declineMergeClosure(tracked, snapshot, repo, `it carries the \`${trackedBlockingLabel}\` label, which Factory never auto-closes`, 'label');
|
|
12275
|
+
return;
|
|
12276
|
+
}
|
|
12277
|
+
if (!tracked) {
|
|
12278
|
+
// Proceed rather than block. The disclaimer check above already ran on
|
|
12279
|
+
// the merged body; wedging every completion behind a transient mount
|
|
12280
|
+
// read would strand dispatches far more often than it would protect an
|
|
12281
|
+
// incident, and stranded dispatches are what generate incidents here.
|
|
12282
|
+
this.#logger.warn?.('[factory] could not read issue to apply the never-auto-close label gate', {
|
|
12283
|
+
issue: record.issue.key,
|
|
12284
|
+
prNumber: snapshot.number,
|
|
12285
|
+
repo,
|
|
12286
|
+
});
|
|
12287
|
+
this.#increment('mergedPrLabelGateUnverified');
|
|
12288
|
+
}
|
|
12085
12289
|
await this.#completeIssue(record, {
|
|
12086
12290
|
targetState: 'done',
|
|
12087
12291
|
runMergeGate: false,
|
|
@@ -12100,11 +12304,24 @@ export class FactoryLoop {
|
|
|
12100
12304
|
this.#increment('mergedPrAdvanceDuplicatesSuppressed');
|
|
12101
12305
|
return;
|
|
12102
12306
|
}
|
|
12307
|
+
// A merged PR that merely *references* this issue has no authority to
|
|
12308
|
+
// complete it, and an incident issue is never closed by a merge at all.
|
|
12309
|
+
// Both refusals comment instead of closing, so the decision is auditable
|
|
12310
|
+
// rather than silent. #313.
|
|
12311
|
+
const authority = prClosureAuthority({ headRef: snapshot.headRef, body: snapshot.body, repo }, issue.key);
|
|
12312
|
+
const blockingLabel = this.#neverAutoCloseLabel(issue);
|
|
12313
|
+
if (!authority.authorised || blockingLabel) {
|
|
12314
|
+
const reason = blockingLabel
|
|
12315
|
+
? `it carries the \`${blockingLabel}\` label, which Factory never auto-closes`
|
|
12316
|
+
: authority.evidence;
|
|
12317
|
+
await this.#declineMergeClosure(issue, snapshot, repo, reason, blockingLabel ? 'label' : 'authority');
|
|
12318
|
+
return;
|
|
12319
|
+
}
|
|
12103
12320
|
this.#postMergeDoneAdvances.add(advanceKey);
|
|
12104
12321
|
try {
|
|
12105
12322
|
const githubIssue = isGithubIssue(issue);
|
|
12106
12323
|
if (githubIssue) {
|
|
12107
|
-
await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.`);
|
|
12324
|
+
await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.\n\nClosing authority: ${authority.evidence}.`);
|
|
12108
12325
|
}
|
|
12109
12326
|
else {
|
|
12110
12327
|
const doneStateId = this.#states.idFor(issue.team, 'done');
|
|
@@ -12119,6 +12336,7 @@ export class FactoryLoop {
|
|
|
12119
12336
|
issue: issue.key,
|
|
12120
12337
|
prNumber: snapshot.number,
|
|
12121
12338
|
url: snapshot.url,
|
|
12339
|
+
closureAuthority: authority.source,
|
|
12122
12340
|
});
|
|
12123
12341
|
if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('merge-done-thread')) {
|
|
12124
12342
|
try {
|
|
@@ -12145,6 +12363,61 @@ export class FactoryLoop {
|
|
|
12145
12363
|
this.#error(error, issueRef(issue));
|
|
12146
12364
|
}
|
|
12147
12365
|
}
|
|
12366
|
+
/** The configured label, if any, that bars this issue from ever auto-closing. */
|
|
12367
|
+
#neverAutoCloseLabel(issue) {
|
|
12368
|
+
const blocked = this.#config.safety.neverAutoCloseLabels;
|
|
12369
|
+
if (!blocked || blocked.length === 0)
|
|
12370
|
+
return undefined;
|
|
12371
|
+
const carried = new Set((issue.labels ?? []).map((label) => label.trim().toLowerCase()));
|
|
12372
|
+
return blocked.find((label) => carried.has(label));
|
|
12373
|
+
}
|
|
12374
|
+
/**
|
|
12375
|
+
* Leave the issue open and say why. A refusal has to be *louder* than a
|
|
12376
|
+
* closure, not quieter: the failure this replaces marked a live outage
|
|
12377
|
+
* COMPLETED and dropped it out of every open-issue view.
|
|
12378
|
+
*/
|
|
12379
|
+
async #declineMergeClosure(issue, snapshot, repo, reason, kind) {
|
|
12380
|
+
this.#increment(kind === 'label' ? 'mergedPrClosureBlockedByLabel' : 'mergedPrClosureRefused');
|
|
12381
|
+
this.#logger.warn?.('[factory] declined to close issue on merged PR', {
|
|
12382
|
+
issue: issue.key,
|
|
12383
|
+
prNumber: snapshot.number,
|
|
12384
|
+
repo,
|
|
12385
|
+
reason,
|
|
12386
|
+
kind,
|
|
12387
|
+
});
|
|
12388
|
+
const marker = `factory-merge-close-declined:${repo}#${snapshot.number}`;
|
|
12389
|
+
const body = [
|
|
12390
|
+
`Factory observed pull request ${repo}#${snapshot.number} merge, and did **not** close this issue.`,
|
|
12391
|
+
'',
|
|
12392
|
+
`Reason: ${reason}.`,
|
|
12393
|
+
'',
|
|
12394
|
+
'A merged pull request closes an issue only when it carries a GitHub closing keyword'
|
|
12395
|
+
+ ' (`closes`/`fixes`/`resolves`) for it, or when its head is that issue\'s own'
|
|
12396
|
+
+ ' implementation branch. A bare reference is an association, not a completion.',
|
|
12397
|
+
'',
|
|
12398
|
+
'If this pull request really did complete the issue, close it by hand.',
|
|
12399
|
+
'',
|
|
12400
|
+
`<!-- ${marker} -->`,
|
|
12401
|
+
].join('\n');
|
|
12402
|
+
try {
|
|
12403
|
+
// A Linear-backed candidate reaches this path too, and a refusal that is
|
|
12404
|
+
// invisible on the system of record is barely better than the silent
|
|
12405
|
+
// closure it replaces.
|
|
12406
|
+
if (!isGithubIssue(issue)) {
|
|
12407
|
+
await this.#linear.postComment(issue, body);
|
|
12408
|
+
return;
|
|
12409
|
+
}
|
|
12410
|
+
// Merge events can redeliver. Skip a repeat only on a positive
|
|
12411
|
+
// confirmation that the note already landed; an unavailable check must
|
|
12412
|
+
// not be read as "already commented".
|
|
12413
|
+
if (await this.#githubWriteback.hasCommentMarker?.(issue, marker))
|
|
12414
|
+
return;
|
|
12415
|
+
await this.#githubWriteback.postComment(issue, body);
|
|
12416
|
+
}
|
|
12417
|
+
catch (error) {
|
|
12418
|
+
this.#error(error, issueRef(issue));
|
|
12419
|
+
}
|
|
12420
|
+
}
|
|
12148
12421
|
async #findMergeAdvanceIssueForPr(snapshot, eventRepo) {
|
|
12149
12422
|
// An issue is "upstream" of a merge if it sits in the agent-implementing or
|
|
12150
12423
|
// human-review role for its team. UUIDs are globally unique, so the reverse
|
|
@@ -12226,7 +12499,7 @@ export class FactoryLoop {
|
|
|
12226
12499
|
// after opening its PR (an event, not a poll).
|
|
12227
12500
|
async #ensureBabysitterForIssue(record) {
|
|
12228
12501
|
if (this.#usesDurableDispatchLifecycle()) {
|
|
12229
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12502
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12230
12503
|
const receipts = lifecycle?.pullRequests ?? (lifecycle?.pullRequest ? [lifecycle.pullRequest] : []);
|
|
12231
12504
|
if (receipts.length > 0) {
|
|
12232
12505
|
for (const receipt of receipts) {
|
|
@@ -12456,7 +12729,7 @@ export class FactoryLoop {
|
|
|
12456
12729
|
}
|
|
12457
12730
|
async #retireSupersededBabysitters(record, prRef) {
|
|
12458
12731
|
const wanted = githubPrIdentity(prRef.repo, prRef.prNumber);
|
|
12459
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12732
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12460
12733
|
const trackedAgents = new Map([
|
|
12461
12734
|
...(lifecycle?.agents ?? [])
|
|
12462
12735
|
.filter((agent) => agent.releasedAtMs === undefined)
|
|
@@ -12499,7 +12772,7 @@ export class FactoryLoop {
|
|
|
12499
12772
|
});
|
|
12500
12773
|
}
|
|
12501
12774
|
if (superseded.length > 0) {
|
|
12502
|
-
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12775
|
+
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12503
12776
|
if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
|
|
12504
12777
|
throw new Error(`Failed to persist superseded babysitter cleanup for ${record.issue.key}`);
|
|
12505
12778
|
}
|
|
@@ -12508,7 +12781,7 @@ export class FactoryLoop {
|
|
|
12508
12781
|
}
|
|
12509
12782
|
async #retireBabysittersOutsideCurrentRoutes(record) {
|
|
12510
12783
|
const wantedRepos = new Set(record.decision.implementers.map((implementer) => implementer.repo.toLowerCase()));
|
|
12511
|
-
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12784
|
+
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12512
12785
|
const trackedAgents = new Map([
|
|
12513
12786
|
...(lifecycle?.agents ?? [])
|
|
12514
12787
|
.filter((agent) => agent.releasedAtMs === undefined)
|
|
@@ -12550,7 +12823,7 @@ export class FactoryLoop {
|
|
|
12550
12823
|
});
|
|
12551
12824
|
}
|
|
12552
12825
|
if (unrouted.length > 0) {
|
|
12553
|
-
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
12826
|
+
const latest = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue));
|
|
12554
12827
|
if (latest && !await this.#saveDispatchLifecycle(record, latest.phase)) {
|
|
12555
12828
|
throw new Error(`Failed to persist unrouted babysitter cleanup for ${record.issue.key}`);
|
|
12556
12829
|
}
|
|
@@ -12563,7 +12836,7 @@ export class FactoryLoop {
|
|
|
12563
12836
|
// guards on the PR's OWN webhook-fed meta (still open, not a draft, not already
|
|
12564
12837
|
// merged) before flipping the issue to Human Review. No `gh` call.
|
|
12565
12838
|
async #maybeAdvanceToHumanReview(record, agentName) {
|
|
12566
|
-
if (this.#completionInFlight.has(
|
|
12839
|
+
if (this.#completionInFlight.has(dispatchLifecycleKey(record.issue))) {
|
|
12567
12840
|
return;
|
|
12568
12841
|
}
|
|
12569
12842
|
const ownership = [...this.#babysitterPr.entries()].find(([key, ref]) => ref.agentName === agentName && issueKey(this.#babysitterIssueRefs.get(key) ?? record.issue) === issueKey(record.issue));
|
|
@@ -12696,7 +12969,7 @@ export class FactoryLoop {
|
|
|
12696
12969
|
return false;
|
|
12697
12970
|
}
|
|
12698
12971
|
async #completeIssue(record, opts = {}) {
|
|
12699
|
-
const completionKey =
|
|
12972
|
+
const completionKey = dispatchLifecycleKey(record.issue);
|
|
12700
12973
|
if (this.#completionInFlight.has(completionKey)) {
|
|
12701
12974
|
return;
|
|
12702
12975
|
}
|
|
@@ -12766,6 +13039,7 @@ export class FactoryLoop {
|
|
|
12766
13039
|
await this.#linear.setState(issue, targetState);
|
|
12767
13040
|
await this.#recordCanonicalIssueState({ ...record.issue, stateId: targetState });
|
|
12768
13041
|
}
|
|
13042
|
+
record.issueWritebackConfirmedAtMs = this.#clock.now();
|
|
12769
13043
|
this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
|
|
12770
13044
|
if (!humanReview)
|
|
12771
13045
|
await this.#markDependencyTerminalAndReconcile(issue);
|
|
@@ -12841,7 +13115,7 @@ export class FactoryLoop {
|
|
|
12841
13115
|
// Cancellation must see the subscription identity so it can issue the
|
|
12842
13116
|
// idempotent Relayfile DELETE before clearing the local owner maps.
|
|
12843
13117
|
await this.#cancelBabysittersForIssue(record.issue);
|
|
12844
|
-
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
13118
|
+
const durable = await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue)).catch(() => undefined);
|
|
12845
13119
|
if (!this.#usesDurableDispatchLifecycle() || (durable && isTerminalDispatchLifecycle(durable))) {
|
|
12846
13120
|
for (const publishedKey of this.#publishedPullRequests.keys()) {
|
|
12847
13121
|
if (publishedKey.startsWith(`${completionKey}:`))
|
|
@@ -12938,7 +13212,7 @@ export class FactoryLoop {
|
|
|
12938
13212
|
async #claimTicketDispatchNotification(record) {
|
|
12939
13213
|
if (record.dryRun || !this.#usesDurableDispatchLifecycle())
|
|
12940
13214
|
return true;
|
|
12941
|
-
const key =
|
|
13215
|
+
const key = dispatchLifecycleKey(record.issue);
|
|
12942
13216
|
return this.#serializeDispatchLifecyclePersistence(key, async () => {
|
|
12943
13217
|
const epoch = this.#dispatchLifecycleEpochs.get(key);
|
|
12944
13218
|
if (epoch === undefined) {
|
|
@@ -13001,7 +13275,7 @@ export class FactoryLoop {
|
|
|
13001
13275
|
void (async () => {
|
|
13002
13276
|
try {
|
|
13003
13277
|
const lifecycle = issue
|
|
13004
|
-
? await this.#state.getDispatchLifecycle(this.#workspaceId,
|
|
13278
|
+
? await this.#state.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(issue)).catch(() => undefined)
|
|
13005
13279
|
: undefined;
|
|
13006
13280
|
if (lifecycle) {
|
|
13007
13281
|
await this.#reportLifecycle(lifecycle, 'factory.failure', {
|
|
@@ -13269,7 +13543,7 @@ export class FactoryLoop {
|
|
|
13269
13543
|
}
|
|
13270
13544
|
async #slackPullRequestRefs(record, additional = []) {
|
|
13271
13545
|
const lifecycle = await this.#state
|
|
13272
|
-
.getDispatchLifecycle(this.#workspaceId,
|
|
13546
|
+
.getDispatchLifecycle(this.#workspaceId, dispatchLifecycleKey(record.issue))
|
|
13273
13547
|
.catch(() => undefined);
|
|
13274
13548
|
const lifecycleRefs = publishedPullRequests(lifecycle).map((receipt) => ({
|
|
13275
13549
|
repo: receipt.repo,
|
|
@@ -13600,7 +13874,7 @@ export class FactoryLoop {
|
|
|
13600
13874
|
async #ownsActiveSlackConversationIssue(issue) {
|
|
13601
13875
|
if (!(await this.#batch()).getIssue(issue))
|
|
13602
13876
|
return false;
|
|
13603
|
-
const key =
|
|
13877
|
+
const key = dispatchLifecycleKey(issue);
|
|
13604
13878
|
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, key);
|
|
13605
13879
|
if (!lifecycle)
|
|
13606
13880
|
return true;
|
|
@@ -14960,7 +15234,7 @@ export class FactoryLoop {
|
|
|
14960
15234
|
if (!waiting.dryRun && this.#usesDurableDispatchLifecycle()) {
|
|
14961
15235
|
try {
|
|
14962
15236
|
const claim = await this.#claimDispatchLifecycle(waiting.decision, false);
|
|
14963
|
-
const lifecycleKey =
|
|
15237
|
+
const lifecycleKey = dispatchLifecycleKey(waiting.issue);
|
|
14964
15238
|
const epoch = this.#dispatchLifecycleEpochs.get(lifecycleKey);
|
|
14965
15239
|
if (claim.lifecycle.phase === 'waiting-for-human' &&
|
|
14966
15240
|
(epoch === undefined || !await this.#state.promoteDispatchLifecycle(this.#workspaceId, lifecycleKey, this.#dispatchLifecycleOwner, epoch, this.#clock.now()))) {
|
|
@@ -15252,10 +15526,7 @@ export class FactoryLoop {
|
|
|
15252
15526
|
return;
|
|
15253
15527
|
}
|
|
15254
15528
|
const clarifiedIssue = issueWithSlackClarification(issue, text);
|
|
15255
|
-
const decision = await this.#
|
|
15256
|
-
config: this.#config,
|
|
15257
|
-
repoMap: repoMapFromConfig(this.#config),
|
|
15258
|
-
});
|
|
15529
|
+
const decision = await this.#triageWithOrigin(clarifiedIssue);
|
|
15259
15530
|
const escalationReason = triageEscalationReason(decision);
|
|
15260
15531
|
if (escalationReason) {
|
|
15261
15532
|
if (hasDispatchableRoute(decision)) {
|
|
@@ -15819,7 +16090,54 @@ const issueRefFromPath = (path) => {
|
|
|
15819
16090
|
const key = keyFromPath(path);
|
|
15820
16091
|
return { uuid: uuidFromPath(path) ?? key, key, path };
|
|
15821
16092
|
};
|
|
15822
|
-
const issueRef = (issue) =>
|
|
16093
|
+
const issueRef = (issue) => {
|
|
16094
|
+
const origin = mirrorWorkUnitOrigin(issue);
|
|
16095
|
+
return { uuid: issue.uuid, key: issue.key, path: issue.path, ...(origin ? { origin } : {}) };
|
|
16096
|
+
};
|
|
16097
|
+
/**
|
|
16098
|
+
* An `IssueRef` for a canonical-state record, carrying the mirror origin from
|
|
16099
|
+
* whichever shape the caller had — a full `LinearIssue`, or an `IssueRef` that
|
|
16100
|
+
* already resolved one.
|
|
16101
|
+
*/
|
|
16102
|
+
const issueRefForState = (issue) => {
|
|
16103
|
+
const origin = issue.origin ??
|
|
16104
|
+
(issue.raw === undefined ? undefined : mirrorWorkUnitOrigin({ path: issue.path, raw: issue.raw }));
|
|
16105
|
+
return { uuid: issue.uuid, key: issue.key, path: issue.path, ...(origin ? { origin } : {}) };
|
|
16106
|
+
};
|
|
16107
|
+
/** Work-unit key, or undefined for a row with no derivable provider identity. */
|
|
16108
|
+
const safeDispatchLifecycleKey = (issue) => {
|
|
16109
|
+
try {
|
|
16110
|
+
return dispatchLifecycleKey(issue);
|
|
16111
|
+
}
|
|
16112
|
+
catch {
|
|
16113
|
+
return undefined;
|
|
16114
|
+
}
|
|
16115
|
+
};
|
|
16116
|
+
/**
|
|
16117
|
+
* Re-attaches the provider origin a triage engine did not carry through.
|
|
16118
|
+
* Only fills a gap — an engine that already resolved an origin keeps it.
|
|
16119
|
+
*/
|
|
16120
|
+
const decisionWithMirrorOrigin = (decision, issue) => {
|
|
16121
|
+
if (decision.issue.origin)
|
|
16122
|
+
return decision;
|
|
16123
|
+
const origin = mirrorWorkUnitOrigin(issue);
|
|
16124
|
+
return origin ? { ...decision, issue: { ...decision.issue, origin } } : decision;
|
|
16125
|
+
};
|
|
16126
|
+
/**
|
|
16127
|
+
* The claim/dedup/lifecycle authority key: the work unit, not the surface it
|
|
16128
|
+
* arrived through.
|
|
16129
|
+
*
|
|
16130
|
+
* `issueKey()` composes the Relayfile sense path, so the same issue reaching
|
|
16131
|
+
* Factory GitHub-native and through its own Linear mirror hashed to two keys
|
|
16132
|
+
* and the lease never saw a conflict — both dispatched (#211, AR-448). Every
|
|
16133
|
+
* durable lifecycle read and write goes through this instead.
|
|
16134
|
+
*
|
|
16135
|
+
* `issueKey()` deliberately stays as-is for the surface-scoped correlation
|
|
16136
|
+
* maps — waiting clarifications, babysitter sessions, Slack threads, preview
|
|
16137
|
+
* ownership — which are keyed by surface on disk and would be orphaned by a
|
|
16138
|
+
* global redefinition.
|
|
16139
|
+
*/
|
|
16140
|
+
const dispatchLifecycleKey = (issue) => dispatchIssueIdentity(issue);
|
|
15823
16141
|
// Preserve the historical Linear state namespace while keeping GitHub-native
|
|
15824
16142
|
// issue numbers independent across repositories in the same workspace.
|
|
15825
16143
|
const issueStateKey = (issue) => githubIssuePathParts(issue.path) ? issueKey(issue) : issue.key;
|
|
@@ -16992,9 +17310,10 @@ const issuePrMatchScore = (pr, issue, marker, opts = {}) => {
|
|
|
16992
17310
|
return 0;
|
|
16993
17311
|
};
|
|
16994
17312
|
const hasTitlePrefix = (title, marker) => title === marker || title.startsWith(`${marker} `);
|
|
16995
|
-
|
|
16996
|
-
|
|
16997
|
-
|
|
17313
|
+
// Single definition shared with closure authority, so the branch rule that
|
|
17314
|
+
// grants a score-30 association and the one that authorises a close can never
|
|
17315
|
+
// drift apart.
|
|
17316
|
+
const factoryBranchMatchesIssue = branchImplementsIssue;
|
|
16998
17317
|
// Legacy Factory runs created GitHub-native branches as `<issue-number>-*`
|
|
16999
17318
|
// before the current `factory/<issue>-*` convention. Keep this matcher out of
|
|
17000
17319
|
// generic PR discovery: it is only enabled by orphan recovery, where the issue
|