@agent-relay/factory 0.1.20 → 0.1.21
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/dispatch/templates.d.ts +16 -0
- package/dist/dispatch/templates.d.ts.map +1 -1
- package/dist/dispatch/templates.js +42 -16
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/fleet/internal-fleet-client.d.ts +1 -0
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +1 -0
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +1 -0
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +1 -0
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +311 -183
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/fleet.d.ts +2 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/state.d.ts +7 -1
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/testing/fakes.d.ts +2 -0
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js.map +1 -1
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import { normalizeLogger, normalizeLogValue, setSafeErrorStack, stringifyLogValu
|
|
|
11
11
|
import { isInFactoryScope } from '../safety/factory-scope.js';
|
|
12
12
|
import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
|
|
13
13
|
import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
|
|
14
|
-
import { renderAgentTask } from '../dispatch/templates.js';
|
|
14
|
+
import { parseGithubHumanInputRequest, renderAgentTask, } from '../dispatch/templates.js';
|
|
15
15
|
import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
|
|
16
16
|
import { agentNameForRole, sanitizeAgentSlug } from '../triage/agent-names.js';
|
|
17
17
|
import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
|
|
@@ -1483,8 +1483,18 @@ export class FactoryLoop {
|
|
|
1483
1483
|
// A valid label resolution clears any prior failure notice so a later
|
|
1484
1484
|
// regression posts a fresh, actionable comment instead of being deduped.
|
|
1485
1485
|
this.#labelDispatchFailures.delete(issueStateKey(dispatchDecision.issue));
|
|
1486
|
-
|
|
1487
|
-
|
|
1486
|
+
// Full task rendering is part of the durable spawn specification. It must
|
|
1487
|
+
// happen before a remote lifecycle is first claimed so takeover cannot
|
|
1488
|
+
// recover a persisted minimal triage task after a crash in this gap.
|
|
1489
|
+
const durableRemoteDispatch = !dryRun && this.#fleet.placementLocality === 'remote';
|
|
1490
|
+
const lifecycleRunId = durableRemoteDispatch ? randomUUID() : undefined;
|
|
1491
|
+
if (lifecycleRunId) {
|
|
1492
|
+
dispatchDecision = decisionWithLifecycleBranches(dispatchDecision, lifecycleRunId);
|
|
1493
|
+
}
|
|
1494
|
+
dispatchDecision = await this.#withRenderedDispatchTasks(dispatchDecision, liveIssue);
|
|
1495
|
+
if (durableRemoteDispatch) {
|
|
1496
|
+
const lifecycleClaim = await this.#claimDispatchLifecycle(dispatchDecision, dryRun, lifecycleRunId);
|
|
1497
|
+
this.#consumePendingDispatchClarifications(dispatchDecision.issue);
|
|
1488
1498
|
dispatchDecision = structuredClone(lifecycleClaim.lifecycle.decision);
|
|
1489
1499
|
if (lifecycleClaim.lifecycle.phase === 'waiting-for-human') {
|
|
1490
1500
|
return lifecycleClaim.lifecycle.result ?? { issue: dispatchDecision.issue, agents: [], dryRun };
|
|
@@ -1502,6 +1512,8 @@ export class FactoryLoop {
|
|
|
1502
1512
|
return restored.result;
|
|
1503
1513
|
}
|
|
1504
1514
|
}
|
|
1515
|
+
if (!durableRemoteDispatch)
|
|
1516
|
+
this.#consumePendingDispatchClarifications(dispatchDecision.issue);
|
|
1505
1517
|
await this.#recordDispatchAttempt(dispatchDecision.issue);
|
|
1506
1518
|
const record = batch.start(dispatchDecision, dryRun);
|
|
1507
1519
|
if (!record) {
|
|
@@ -1514,6 +1526,8 @@ export class FactoryLoop {
|
|
|
1514
1526
|
return record.result;
|
|
1515
1527
|
}
|
|
1516
1528
|
await this.#saveDispatchLifecycle(record, 'dispatching');
|
|
1529
|
+
if (!dryRun)
|
|
1530
|
+
await this.#ensureGithubAgentQuestionWatch(record, liveIssue);
|
|
1517
1531
|
const spawnedForReaperHandoff = [];
|
|
1518
1532
|
try {
|
|
1519
1533
|
const specs = dispatchSpecs(dispatchDecision);
|
|
@@ -1567,10 +1581,6 @@ export class FactoryLoop {
|
|
|
1567
1581
|
this.#emit('dispatched', { issue: dispatchDecision.issue, result });
|
|
1568
1582
|
if (!dryRun) {
|
|
1569
1583
|
await this.#ensureSlackDispatchThread(record, result);
|
|
1570
|
-
await this.#sendImplementerTask(record);
|
|
1571
|
-
await this.#sendCriticalReviewerMessage(record);
|
|
1572
|
-
await this.#injectPendingSlackClarification(record);
|
|
1573
|
-
await this.#injectPendingGithubClarification(record);
|
|
1574
1584
|
}
|
|
1575
1585
|
return result;
|
|
1576
1586
|
}
|
|
@@ -1713,10 +1723,10 @@ export class FactoryLoop {
|
|
|
1713
1723
|
this.#dispatchLifecycleRenewTimer = undefined;
|
|
1714
1724
|
}
|
|
1715
1725
|
}
|
|
1716
|
-
async #claimDispatchLifecycle(decision, dryRun) {
|
|
1726
|
+
async #claimDispatchLifecycle(decision, dryRun, preparedRunId) {
|
|
1717
1727
|
const key = issueKey(decision.issue);
|
|
1718
1728
|
const seed = {
|
|
1719
|
-
runId: randomUUID(),
|
|
1729
|
+
runId: preparedRunId ?? randomUUID(),
|
|
1720
1730
|
issue: { ...decision.issue },
|
|
1721
1731
|
decision: structuredClone(decision),
|
|
1722
1732
|
dryRun,
|
|
@@ -1725,7 +1735,8 @@ export class FactoryLoop {
|
|
|
1725
1735
|
invocationIds: [],
|
|
1726
1736
|
updatedAtMs: this.#clock.now(),
|
|
1727
1737
|
};
|
|
1728
|
-
|
|
1738
|
+
if (!preparedRunId)
|
|
1739
|
+
seed.decision = decisionWithLifecycleBranches(seed.decision, seed.runId);
|
|
1729
1740
|
const claim = await this.#state.claimDispatchLifecycle(this.#workspaceId, key, seed, this.#dispatchLifecycleOwner, this.#clock.now(), DISPATCH_LIFECYCLE_LEASE_MS);
|
|
1730
1741
|
if (!claim.acquired || !claim.lease) {
|
|
1731
1742
|
const reason = isTerminalDispatchLifecycle(claim.lifecycle)
|
|
@@ -1893,7 +1904,6 @@ export class FactoryLoop {
|
|
|
1893
1904
|
}
|
|
1894
1905
|
}
|
|
1895
1906
|
async #resumeDurableDispatch(record) {
|
|
1896
|
-
const hadResult = Boolean(record.result);
|
|
1897
1907
|
const agents = [];
|
|
1898
1908
|
const specs = dispatchSpecs(record.decision);
|
|
1899
1909
|
const plannedNames = new Set(specs.map((spec) => spec.name));
|
|
@@ -1912,6 +1922,7 @@ export class FactoryLoop {
|
|
|
1912
1922
|
const issue = await this.#readIssue(record.issue.path);
|
|
1913
1923
|
if (!issue)
|
|
1914
1924
|
throw new Error(`Unable to recover durable dispatch ${record.issue.key}: issue is no longer readable`);
|
|
1925
|
+
await this.#ensureGithubAgentQuestionWatch(record, issue);
|
|
1915
1926
|
if (isGithubIssue(issue)) {
|
|
1916
1927
|
await this.#githubWriteback.setStatus(issue, 'in-progress');
|
|
1917
1928
|
}
|
|
@@ -1928,10 +1939,6 @@ export class FactoryLoop {
|
|
|
1928
1939
|
if (!await this.#saveDispatchLifecycle(record, 'running'))
|
|
1929
1940
|
return;
|
|
1930
1941
|
if (!record.dryRun) {
|
|
1931
|
-
if (!hadResult) {
|
|
1932
|
-
await this.#sendImplementerTask(record);
|
|
1933
|
-
await this.#sendCriticalReviewerMessage(record);
|
|
1934
|
-
}
|
|
1935
1942
|
for (const tracked of record.agents.values()) {
|
|
1936
1943
|
const owned = tracked.spec.ownedPullRequest;
|
|
1937
1944
|
if (tracked.spec.role !== 'babysitter' || !owned)
|
|
@@ -2877,6 +2884,14 @@ export class FactoryLoop {
|
|
|
2877
2884
|
});
|
|
2878
2885
|
return;
|
|
2879
2886
|
}
|
|
2887
|
+
// The issue-comment subscription and the fleet exit callback are separate
|
|
2888
|
+
// event streams. Reconcile comments that are already durable in the mount
|
|
2889
|
+
// before interpreting a clean exit as task completion, so an agent that
|
|
2890
|
+
// writes its question and immediately exits cannot race the sync callback.
|
|
2891
|
+
if (await this.#reconcileGithubQuestionBeforeAgentExit(record, name)) {
|
|
2892
|
+
this.#increment('githubQuestionExitsSuppressed');
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2880
2895
|
const exiting = record.agents.get(name);
|
|
2881
2896
|
if (this.#fleet.placementLocality === 'remote') {
|
|
2882
2897
|
const lifecycle = await this.#state.getDispatchLifecycle(this.#workspaceId, issueKey(record.issue));
|
|
@@ -3502,6 +3517,9 @@ export class FactoryLoop {
|
|
|
3502
3517
|
return;
|
|
3503
3518
|
}
|
|
3504
3519
|
}
|
|
3520
|
+
// Compatibility for pre-durable prompts and for Linear-only tasks that
|
|
3521
|
+
// have no source GitHub issue to use as a durable record. New GitHub-source
|
|
3522
|
+
// tasks use the structured comment handled by #handleGithubIssueComment.
|
|
3505
3523
|
const question = parseAgentQuestion(message);
|
|
3506
3524
|
if (!question || !isFactoryQuestionTarget(message.target)) {
|
|
3507
3525
|
return;
|
|
@@ -3694,6 +3712,9 @@ export class FactoryLoop {
|
|
|
3694
3712
|
return true;
|
|
3695
3713
|
}
|
|
3696
3714
|
}
|
|
3715
|
+
if (!claimed.threadId) {
|
|
3716
|
+
return await this.#deliverClarificationQuestionToGithub(key, claimed, 'no Slack dispatch thread exists');
|
|
3717
|
+
}
|
|
3697
3718
|
if (await this.#shouldSkipSlackWriteback('agent-question')) {
|
|
3698
3719
|
return await this.#deliverClarificationQuestionToGithub(key, claimed, `Slack writeback is degraded${this.#slackDegradedReason ? `: ${this.#slackDegradedReason}` : ''}`);
|
|
3699
3720
|
}
|
|
@@ -3832,6 +3853,7 @@ export class FactoryLoop {
|
|
|
3832
3853
|
decision: structuredClone(record.decision),
|
|
3833
3854
|
dryRun: record.dryRun,
|
|
3834
3855
|
threadId,
|
|
3856
|
+
questionSource: 'slack',
|
|
3835
3857
|
askerName: question.agentName,
|
|
3836
3858
|
question: question.question,
|
|
3837
3859
|
askedAtMs: this.#clock.now(),
|
|
@@ -3851,6 +3873,66 @@ export class FactoryLoop {
|
|
|
3851
3873
|
this.#scheduleClarificationSweep(CLARIFICATION_STALE_WARN_MS);
|
|
3852
3874
|
return waiting;
|
|
3853
3875
|
}
|
|
3876
|
+
async #reserveGithubHumanClarification(record, question) {
|
|
3877
|
+
const key = issueKey(record.issue);
|
|
3878
|
+
const agents = [...record.agents].map(([name, tracked]) => ({
|
|
3879
|
+
name,
|
|
3880
|
+
tracked: structuredClone(tracked),
|
|
3881
|
+
}));
|
|
3882
|
+
if (agents.length === 0) {
|
|
3883
|
+
this.#increment('agentQuestionReleaseSkippedNoAgents');
|
|
3884
|
+
return false;
|
|
3885
|
+
}
|
|
3886
|
+
const nowMs = this.#clock.now();
|
|
3887
|
+
const waiting = {
|
|
3888
|
+
issue: { ...record.issue },
|
|
3889
|
+
decision: structuredClone(record.decision),
|
|
3890
|
+
dryRun: record.dryRun,
|
|
3891
|
+
threadId: await this.#state.getSlackThread(this.#workspaceId, key),
|
|
3892
|
+
questionSource: 'github',
|
|
3893
|
+
askerName: question.agentName,
|
|
3894
|
+
question: question.question,
|
|
3895
|
+
askedAtMs: nowMs,
|
|
3896
|
+
questionPostedAtMs: nowMs,
|
|
3897
|
+
agents,
|
|
3898
|
+
};
|
|
3899
|
+
if (!await this.#state.reserveWaitingClarification(this.#workspaceId, key, waiting)) {
|
|
3900
|
+
this.#increment('agentQuestionClarificationAlreadyReserved');
|
|
3901
|
+
this.#logger.info?.('[factory] ignored a second GitHub agent question while clarification is already reserved', {
|
|
3902
|
+
issue: record.issue.key,
|
|
3903
|
+
asker: question.agentName,
|
|
3904
|
+
});
|
|
3905
|
+
return false;
|
|
3906
|
+
}
|
|
3907
|
+
this.#scheduleClarificationSweep(CLARIFICATION_STALE_WARN_MS);
|
|
3908
|
+
return waiting;
|
|
3909
|
+
}
|
|
3910
|
+
async #mirrorGithubAgentQuestionToSlack(record, question) {
|
|
3911
|
+
if (!this.#slack || !this.#config.slack)
|
|
3912
|
+
return;
|
|
3913
|
+
if (await this.#shouldSkipSlackWriteback('agent-question-mirror')) {
|
|
3914
|
+
this.#increment('agentQuestionSlackMirrorsSkippedDegraded');
|
|
3915
|
+
return;
|
|
3916
|
+
}
|
|
3917
|
+
const threadId = await this.#state.getSlackThread(this.#workspaceId, issueKey(record.issue));
|
|
3918
|
+
if (!threadId) {
|
|
3919
|
+
this.#increment('agentQuestionSlackMirrorsSkippedMissingThread');
|
|
3920
|
+
return;
|
|
3921
|
+
}
|
|
3922
|
+
try {
|
|
3923
|
+
await this.#slack.reply(threadId, agentQuestionSlackText(record.issue, question, this.#config.slack.stakeholderUserIds));
|
|
3924
|
+
this.#increment('agentQuestionsMirroredToSlack');
|
|
3925
|
+
this.#recordSlackWritebackSuccess('agent-question-mirror');
|
|
3926
|
+
}
|
|
3927
|
+
catch (error) {
|
|
3928
|
+
this.#markSlackWritebackFailure('agent-question-mirror', error);
|
|
3929
|
+
this.#increment('agentQuestionSlackMirrorFailures');
|
|
3930
|
+
this.#logger.warn?.('[factory] optional Slack question mirror failed', {
|
|
3931
|
+
issue: record.issue.key,
|
|
3932
|
+
error: describeError(error).errorMessage,
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3854
3936
|
async #parkForHumanClarification(record, waiting) {
|
|
3855
3937
|
if (this.#stopping)
|
|
3856
3938
|
return;
|
|
@@ -4087,6 +4169,52 @@ export class FactoryLoop {
|
|
|
4087
4169
|
await this.#watchGithubIssueComments(watch);
|
|
4088
4170
|
return added;
|
|
4089
4171
|
}
|
|
4172
|
+
async #ensureGithubAgentQuestionWatch(record, issue) {
|
|
4173
|
+
const source = githubIssueSourceRef(issue);
|
|
4174
|
+
if (!source)
|
|
4175
|
+
return;
|
|
4176
|
+
const key = githubIssueSourceKey(source);
|
|
4177
|
+
let watch = this.#githubIssueCommentWatchStates.get(key);
|
|
4178
|
+
if (!watch) {
|
|
4179
|
+
const persisted = await this.#state.listGithubIssueCommentWatches(this.#workspaceId);
|
|
4180
|
+
watch = persisted.find(([persistedKey]) => persistedKey === key)?.[1];
|
|
4181
|
+
}
|
|
4182
|
+
if (!watch) {
|
|
4183
|
+
const sinceCommentId = await this.#latestGithubIssueCommentId(source);
|
|
4184
|
+
watch = {
|
|
4185
|
+
issue: { ...record.issue },
|
|
4186
|
+
source,
|
|
4187
|
+
pending: [],
|
|
4188
|
+
detectAgentQuestions: true,
|
|
4189
|
+
sinceCommentId,
|
|
4190
|
+
lastSeenCommentId: sinceCommentId,
|
|
4191
|
+
processedCommentIds: [],
|
|
4192
|
+
};
|
|
4193
|
+
}
|
|
4194
|
+
else {
|
|
4195
|
+
watch.issue = { ...record.issue };
|
|
4196
|
+
watch.detectAgentQuestions = true;
|
|
4197
|
+
}
|
|
4198
|
+
if (this.#githubIssueCommentWatchers.has(key)) {
|
|
4199
|
+
const normalizedWatch = normalizeGithubIssueCommentWatch(watch);
|
|
4200
|
+
this.#githubIssueCommentWatchStates.set(key, normalizedWatch);
|
|
4201
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, normalizedWatch);
|
|
4202
|
+
return;
|
|
4203
|
+
}
|
|
4204
|
+
await this.#watchGithubIssueComments(watch);
|
|
4205
|
+
if (!this.#githubIssueCommentWatchStates.has(key)) {
|
|
4206
|
+
throw new Error(`Unable to watch source GitHub issue comments for ${record.issue.key}`);
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
async #reconcileGithubQuestionBeforeAgentExit(record, agentName) {
|
|
4210
|
+
const watchEntry = [...this.#githubIssueCommentWatchStates].find(([, watch]) => (watch.detectAgentQuestions === true
|
|
4211
|
+
&& (watch.issue.uuid === record.issue.uuid || watch.issue.path === record.issue.path)));
|
|
4212
|
+
if (!watchEntry)
|
|
4213
|
+
return false;
|
|
4214
|
+
await this.#replayGithubIssueComments(watchEntry[0]);
|
|
4215
|
+
const waiting = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(record.issue));
|
|
4216
|
+
return waiting?.questionSource === 'github' && waiting.agents.some(({ name }) => name === agentName);
|
|
4217
|
+
}
|
|
4090
4218
|
async #watchGithubIssueComments(watch) {
|
|
4091
4219
|
watch = normalizeGithubIssueCommentWatch(watch);
|
|
4092
4220
|
const key = githubIssueSourceKey(watch.source);
|
|
@@ -4172,7 +4300,7 @@ export class FactoryLoop {
|
|
|
4172
4300
|
if (!watch)
|
|
4173
4301
|
return;
|
|
4174
4302
|
watch.pending = watch.pending.filter((pending) => pending.correlationId !== correlationId);
|
|
4175
|
-
if (watch.pending.length === 0) {
|
|
4303
|
+
if (watch.pending.length === 0 && !watch.detectAgentQuestions) {
|
|
4176
4304
|
await this.#stopGithubIssueCommentWatcher(source);
|
|
4177
4305
|
return;
|
|
4178
4306
|
}
|
|
@@ -4180,7 +4308,7 @@ export class FactoryLoop {
|
|
|
4180
4308
|
}
|
|
4181
4309
|
async #rearmGithubIssueCommentWatchers() {
|
|
4182
4310
|
for (const [, watch] of await this.#state.listGithubIssueCommentWatches(this.#workspaceId)) {
|
|
4183
|
-
if (watch.pending.length === 0)
|
|
4311
|
+
if (watch.pending.length === 0 && !watch.detectAgentQuestions)
|
|
4184
4312
|
continue;
|
|
4185
4313
|
try {
|
|
4186
4314
|
await this.#watchGithubIssueComments(watch);
|
|
@@ -4288,10 +4416,24 @@ export class FactoryLoop {
|
|
|
4288
4416
|
this.#increment('githubIssueCommentDuplicatesSuppressed');
|
|
4289
4417
|
return;
|
|
4290
4418
|
}
|
|
4419
|
+
const request = watch.detectAgentQuestions
|
|
4420
|
+
? parseGithubHumanInputRequest(comment.body)
|
|
4421
|
+
: undefined;
|
|
4422
|
+
if (request) {
|
|
4423
|
+
await this.#handleGithubAgentQuestionComment(watch, comment, request);
|
|
4424
|
+
processedCommentIds.add(normalizedCommentId);
|
|
4425
|
+
watch.processedCommentIds = [...processedCommentIds];
|
|
4426
|
+
watch.lastSeenCommentId = String(Math.max(commentId, githubCommentNumericId(watch.lastSeenCommentId)));
|
|
4427
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
4428
|
+
return;
|
|
4429
|
+
}
|
|
4291
4430
|
const reply = githubCorrelatedReply(comment.body);
|
|
4292
4431
|
const pending = reply
|
|
4293
4432
|
? watch.pending.find((candidate) => candidate.correlationId === reply.correlationId)
|
|
4294
|
-
:
|
|
4433
|
+
: watch.pending.find((candidate) => candidate.kind === 'agent-question' &&
|
|
4434
|
+
candidate.replyAfterCommentId !== undefined &&
|
|
4435
|
+
commentId > githubCommentNumericId(candidate.replyAfterCommentId));
|
|
4436
|
+
const answerText = reply?.text ?? comment.body.trim();
|
|
4295
4437
|
let resolved = false;
|
|
4296
4438
|
let discardClaimedPending = false;
|
|
4297
4439
|
if (pending?.claimedByCommentId === normalizedCommentId) {
|
|
@@ -4306,10 +4448,14 @@ export class FactoryLoop {
|
|
|
4306
4448
|
correlationId: pending.correlationId,
|
|
4307
4449
|
});
|
|
4308
4450
|
}
|
|
4309
|
-
else if (pending &&
|
|
4451
|
+
else if (pending &&
|
|
4452
|
+
answerText &&
|
|
4453
|
+
typeof pending.authorizedAuthor === 'string' &&
|
|
4454
|
+
pending.authorizedAuthor.length > 0 &&
|
|
4455
|
+
comment.author?.toLowerCase() === pending.authorizedAuthor.toLowerCase()) {
|
|
4310
4456
|
pending.claimedByCommentId = normalizedCommentId;
|
|
4311
4457
|
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
4312
|
-
resolved = await this.#routeGithubAnswerToImplementers(watch, pending, comment,
|
|
4458
|
+
resolved = await this.#routeGithubAnswerToImplementers(watch, pending, comment, answerText);
|
|
4313
4459
|
if (!resolved) {
|
|
4314
4460
|
delete pending.claimedByCommentId;
|
|
4315
4461
|
}
|
|
@@ -4341,13 +4487,88 @@ export class FactoryLoop {
|
|
|
4341
4487
|
if ((resolved || discardClaimedPending) && pending) {
|
|
4342
4488
|
watch.pending = watch.pending.filter((candidate) => candidate.correlationId !== pending.correlationId);
|
|
4343
4489
|
}
|
|
4344
|
-
if (watch.pending.length === 0) {
|
|
4490
|
+
if (watch.pending.length === 0 && !watch.detectAgentQuestions) {
|
|
4345
4491
|
await this.#stopGithubIssueCommentWatcher(watch.source);
|
|
4346
4492
|
}
|
|
4347
4493
|
else {
|
|
4348
4494
|
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
|
|
4349
4495
|
}
|
|
4350
4496
|
}
|
|
4497
|
+
async #handleGithubAgentQuestionComment(watch, comment, request) {
|
|
4498
|
+
const record = (await this.#batch()).getIssue(watch.issue);
|
|
4499
|
+
if (!record || record.dryRun || request.issueKey.toLowerCase() !== record.issue.key.toLowerCase()) {
|
|
4500
|
+
this.#increment('githubAgentQuestionsIgnoredNoInFlight');
|
|
4501
|
+
return;
|
|
4502
|
+
}
|
|
4503
|
+
const tracked = record.agents.get(request.agentName);
|
|
4504
|
+
if (!tracked || !['implementer', 'reviewer', 'babysitter'].includes(tracked.spec.role)) {
|
|
4505
|
+
this.#increment('githubAgentQuestionsIgnoredUnknownAgent');
|
|
4506
|
+
return;
|
|
4507
|
+
}
|
|
4508
|
+
// Agents write through the connected GitHub App, whose comments are
|
|
4509
|
+
// provider-authored bot records. Never let an arbitrary repository
|
|
4510
|
+
// commenter forge the predictable structured fields and park a live team.
|
|
4511
|
+
if (!comment.isBot) {
|
|
4512
|
+
this.#increment('githubAgentQuestionsIgnoredUntrustedAuthor');
|
|
4513
|
+
this.#logger.info?.('[factory] ignored GitHub agent question from an untrusted commenter', {
|
|
4514
|
+
issue: watch.issue,
|
|
4515
|
+
commentId: comment.commentId,
|
|
4516
|
+
author: comment.author,
|
|
4517
|
+
});
|
|
4518
|
+
return;
|
|
4519
|
+
}
|
|
4520
|
+
const question = {
|
|
4521
|
+
agentName: request.agentName,
|
|
4522
|
+
issueKey: request.issueKey,
|
|
4523
|
+
question: request.question,
|
|
4524
|
+
eventId: `github:${watch.source.owner}/${watch.source.repo}#${watch.source.number}:${comment.commentId}`,
|
|
4525
|
+
};
|
|
4526
|
+
const correlationId = githubEscalationCorrelationId('agent-question', record.issue, `${comment.commentId}:${question.question}`);
|
|
4527
|
+
const issue = await this.#readIssue(record.issue.path);
|
|
4528
|
+
const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
|
|
4529
|
+
if (!authorizedAuthor) {
|
|
4530
|
+
this.#increment('githubAgentQuestionsIgnoredMissingAuthorizedAuthor');
|
|
4531
|
+
this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'source GitHub issue has no identifiable reporter authorized to answer the durable question');
|
|
4532
|
+
return;
|
|
4533
|
+
}
|
|
4534
|
+
this.#clarificationIntents.set(question.agentName, (this.#clarificationIntents.get(question.agentName) ?? 0) + 1);
|
|
4535
|
+
let durableClarificationOwnsExit = false;
|
|
4536
|
+
try {
|
|
4537
|
+
const dedupeKey = agentQuestionDedupeKey(record.issue, question);
|
|
4538
|
+
if (!await this.#state.claimAgentQuestion(this.#workspaceId, dedupeKey)) {
|
|
4539
|
+
this.#increment('agentQuestionDuplicatesSuppressed');
|
|
4540
|
+
return;
|
|
4541
|
+
}
|
|
4542
|
+
const reserved = await this.#reserveGithubHumanClarification(record, question);
|
|
4543
|
+
if (reserved === false) {
|
|
4544
|
+
const existing = await this.#state.getWaitingClarification(this.#workspaceId, issueKey(record.issue));
|
|
4545
|
+
durableClarificationOwnsExit = Boolean(existing?.agents.some(({ name }) => name === question.agentName));
|
|
4546
|
+
return;
|
|
4547
|
+
}
|
|
4548
|
+
durableClarificationOwnsExit = true;
|
|
4549
|
+
if (!watch.pending.some((pending) => pending.correlationId === correlationId)) {
|
|
4550
|
+
watch.pending.push({
|
|
4551
|
+
correlationId,
|
|
4552
|
+
kind: 'agent-question',
|
|
4553
|
+
authorizedAuthor,
|
|
4554
|
+
replyAfterCommentId: comment.commentId,
|
|
4555
|
+
});
|
|
4556
|
+
await this.#state.setGithubIssueCommentWatch(this.#workspaceId, githubIssueSourceKey(watch.source), watch);
|
|
4557
|
+
}
|
|
4558
|
+
await this.#parkForHumanClarification(record, reserved);
|
|
4559
|
+
this.#increment('githubAgentQuestionsDetected');
|
|
4560
|
+
void this.#mirrorGithubAgentQuestionToSlack(record, question);
|
|
4561
|
+
}
|
|
4562
|
+
finally {
|
|
4563
|
+
if (!durableClarificationOwnsExit) {
|
|
4564
|
+
const remaining = (this.#clarificationIntents.get(question.agentName) ?? 1) - 1;
|
|
4565
|
+
if (remaining > 0)
|
|
4566
|
+
this.#clarificationIntents.set(question.agentName, remaining);
|
|
4567
|
+
else
|
|
4568
|
+
this.#clarificationIntents.delete(question.agentName);
|
|
4569
|
+
}
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4351
4572
|
async #routeGithubAnswerToImplementers(watch, pending, comment, text) {
|
|
4352
4573
|
if (pending.kind === 'triage' && pending.decision) {
|
|
4353
4574
|
return await this.#handleTriageEscalationGithubAnswer(escalationWatchRecord(pending.decision), text);
|
|
@@ -4371,26 +4592,8 @@ export class FactoryLoop {
|
|
|
4371
4592
|
await this.#wakeWaitingClarification(clarificationKey, claimed);
|
|
4372
4593
|
return true;
|
|
4373
4594
|
}
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
this.#increment('githubAnswersIgnoredNoInFlight');
|
|
4377
|
-
return false;
|
|
4378
|
-
}
|
|
4379
|
-
if (!this.#fleet.sendInput)
|
|
4380
|
-
return false;
|
|
4381
|
-
const recipients = [...liveRecord.agents.values()]
|
|
4382
|
-
.filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
|
|
4383
|
-
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
4384
|
-
.filter((name) => Boolean(name));
|
|
4385
|
-
if (recipients.length === 0) {
|
|
4386
|
-
this.#increment('githubAnswersIgnoredNoImplementer');
|
|
4387
|
-
return false;
|
|
4388
|
-
}
|
|
4389
|
-
for (const recipient of new Set(recipients)) {
|
|
4390
|
-
await this.#fleet.sendInput(recipient, githubReplyEvent(liveRecord.issue, text, comment.author));
|
|
4391
|
-
this.#increment('githubAnswersInjected');
|
|
4392
|
-
}
|
|
4393
|
-
return true;
|
|
4595
|
+
this.#increment('githubAnswersIgnoredNoDurableClarification');
|
|
4596
|
+
return false;
|
|
4394
4597
|
}
|
|
4395
4598
|
async #handleTriageEscalationGithubAnswer(record, text) {
|
|
4396
4599
|
const issue = await this.#readIssue(record.issue.path);
|
|
@@ -4460,57 +4663,46 @@ export class FactoryLoop {
|
|
|
4460
4663
|
latencyMs,
|
|
4461
4664
|
});
|
|
4462
4665
|
}
|
|
4463
|
-
async #
|
|
4464
|
-
if (
|
|
4465
|
-
return;
|
|
4466
|
-
|
|
4467
|
-
const
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4666
|
+
async #withRenderedDispatchTasks(decision, issue) {
|
|
4667
|
+
if (decision.scope === 'workflow')
|
|
4668
|
+
return decision;
|
|
4669
|
+
const key = issueKey(decision.issue);
|
|
4670
|
+
const slackClarification = this.#pendingSlackClarifications.get(key);
|
|
4671
|
+
const githubClarification = this.#pendingGithubClarifications.get(key);
|
|
4672
|
+
const templateIssue = templateIssueFromRecord({ issue: decision.issue }, issue);
|
|
4673
|
+
templateIssue.description = [
|
|
4674
|
+
templateIssue.description,
|
|
4675
|
+
slackClarification ? `Human clarification from Slack:\n${slackClarification}` : undefined,
|
|
4676
|
+
githubClarification ? `Human clarification from GitHub:\n${githubClarification}` : undefined,
|
|
4677
|
+
].filter((part) => Boolean(part)).join('\n\n');
|
|
4678
|
+
const implementerNames = decision.implementers.map((implementer) => implementer.name);
|
|
4679
|
+
const reviewerName = decision.reviewer.name;
|
|
4680
|
+
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
4681
|
+
const render = (spec) => ({
|
|
4682
|
+
...spec,
|
|
4683
|
+
task: renderAgentTask({
|
|
4684
|
+
issue: templateIssue,
|
|
4685
|
+
route: routeForSpec(decision, spec),
|
|
4686
|
+
role: spec.role,
|
|
4687
|
+
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
4688
|
+
reviewerName,
|
|
4689
|
+
implementerNames,
|
|
4690
|
+
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
4691
|
+
integrationInstructions,
|
|
4692
|
+
branchName: spec.branch,
|
|
4693
|
+
agentName: spec.name,
|
|
4694
|
+
}),
|
|
4695
|
+
});
|
|
4696
|
+
return {
|
|
4697
|
+
...decision,
|
|
4698
|
+
implementers: decision.implementers.map(render),
|
|
4699
|
+
reviewer: render(decision.reviewer),
|
|
4476
4700
|
};
|
|
4477
|
-
const ack = await this.#waitForInjectedAndSubmit(input);
|
|
4478
|
-
await this.#state.recordCritical(this.#workspaceId, ack.eventId, { issue: record.issue, input });
|
|
4479
4701
|
}
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
const implementers = [...record.agents.values()].filter((agent) => agent.spec.role === 'implementer');
|
|
4485
|
-
if (implementers.length === 0) {
|
|
4486
|
-
return;
|
|
4487
|
-
}
|
|
4488
|
-
const issue = await this.#readIssue(record.issue.path);
|
|
4489
|
-
const reviewer = [...record.agents.values()].find((agent) => agent.spec.role === 'reviewer');
|
|
4490
|
-
const reviewerName = reviewer?.result?.name ?? reviewer?.spec.name ?? 'reviewer';
|
|
4491
|
-
const implementerNames = implementers.map((agent) => agent.result?.name ?? agent.spec.name);
|
|
4492
|
-
const integrationInstructions = await this.#resolveIntegrationInstructions();
|
|
4493
|
-
for (const implementer of implementers) {
|
|
4494
|
-
const input = {
|
|
4495
|
-
to: implementer.result?.name ?? implementer.spec.name,
|
|
4496
|
-
text: renderAgentTask({
|
|
4497
|
-
issue: templateIssueFromRecord(record, issue),
|
|
4498
|
-
route: routeForImplementer(record, implementer.spec),
|
|
4499
|
-
role: 'implementer',
|
|
4500
|
-
config: { mergePolicy: this.#config.mergePolicy, terminalState: this.#config.terminalState },
|
|
4501
|
-
reviewerName,
|
|
4502
|
-
implementerNames,
|
|
4503
|
-
slackDispatchThread: await this.#slackDispatchThreadFor(record),
|
|
4504
|
-
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
4505
|
-
integrationInstructions,
|
|
4506
|
-
branchName: implementer.spec.branch,
|
|
4507
|
-
}),
|
|
4508
|
-
from: 'factory',
|
|
4509
|
-
data: { issue: record.issue },
|
|
4510
|
-
};
|
|
4511
|
-
const ack = await this.#waitForInjectedAndSubmit(input);
|
|
4512
|
-
await this.#state.recordCritical(this.#workspaceId, ack.eventId, { issue: record.issue, input });
|
|
4513
|
-
}
|
|
4702
|
+
#consumePendingDispatchClarifications(issue) {
|
|
4703
|
+
const key = issueKey(issue);
|
|
4704
|
+
this.#pendingSlackClarifications.delete(key);
|
|
4705
|
+
this.#pendingGithubClarifications.delete(key);
|
|
4514
4706
|
}
|
|
4515
4707
|
async #waitForInjectedAndSubmit(input) {
|
|
4516
4708
|
if (!this.#fleet.waitForInjected) {
|
|
@@ -5298,6 +5490,7 @@ export class FactoryLoop {
|
|
|
5298
5490
|
slackDispatchThread: await this.#slackDispatchThreadFor(record),
|
|
5299
5491
|
integrationsMountRoot: this.#integrationsMountRoot(),
|
|
5300
5492
|
integrationInstructions,
|
|
5493
|
+
agentName: spec.name,
|
|
5301
5494
|
});
|
|
5302
5495
|
const spawned = await this.#spawnAgent(record, {
|
|
5303
5496
|
...spec,
|
|
@@ -6170,6 +6363,8 @@ export class FactoryLoop {
|
|
|
6170
6363
|
}
|
|
6171
6364
|
await this.#sweepWaitingClarifications();
|
|
6172
6365
|
for (const [, waiting] of await this.#state.listWaitingClarifications(this.#workspaceId)) {
|
|
6366
|
+
if (!waiting.threadId)
|
|
6367
|
+
continue;
|
|
6173
6368
|
const key = issueKey(waiting.issue);
|
|
6174
6369
|
// stop() clears ephemeral thread lookup state, but the durable
|
|
6175
6370
|
// clarification record owns the canonical thread while parked. Restore
|
|
@@ -6240,6 +6435,8 @@ export class FactoryLoop {
|
|
|
6240
6435
|
continue;
|
|
6241
6436
|
if (waiting.reply || waiting.escalatedAtMs)
|
|
6242
6437
|
continue;
|
|
6438
|
+
if (!waiting.threadId)
|
|
6439
|
+
continue;
|
|
6243
6440
|
const waitingAgeMs = this.#clock.now() - waiting.askedAtMs;
|
|
6244
6441
|
const untilEscalationMs = CLARIFICATION_STALE_WARN_MS - waitingAgeMs;
|
|
6245
6442
|
if (untilEscalationMs > 0) {
|
|
@@ -6259,7 +6456,7 @@ export class FactoryLoop {
|
|
|
6259
6456
|
waitingAgeMs,
|
|
6260
6457
|
});
|
|
6261
6458
|
try {
|
|
6262
|
-
await this.#slack.reply(
|
|
6459
|
+
await this.#slack.reply(waiting.threadId, clarificationStaleSlackText(escalated, this.#config.slack.stakeholderUserIds));
|
|
6263
6460
|
const completed = await this.#state.completeClarificationEscalation(this.#workspaceId, key, this.#clarificationWakeOwner, this.#clock.now());
|
|
6264
6461
|
if (!completed) {
|
|
6265
6462
|
this.#increment('clarificationEscalationOwnershipLost');
|
|
@@ -6333,6 +6530,10 @@ export class FactoryLoop {
|
|
|
6333
6530
|
}
|
|
6334
6531
|
const clarificationKey = issueKey(record.issue);
|
|
6335
6532
|
const waiting = await this.#state.getWaitingClarification(this.#workspaceId, clarificationKey);
|
|
6533
|
+
if (waiting?.questionSource === 'github' && waiting.threadId === reply.threadTs) {
|
|
6534
|
+
this.#increment('slackClarificationRepliesIgnoredGithubRecord');
|
|
6535
|
+
return;
|
|
6536
|
+
}
|
|
6336
6537
|
if (waiting?.threadId === reply.threadTs) {
|
|
6337
6538
|
const claimed = await this.#state.claimClarificationReply(this.#workspaceId, clarificationKey, {
|
|
6338
6539
|
id: `${reply.threadTs}:${reply.messageTs}`,
|
|
@@ -6525,30 +6726,6 @@ export class FactoryLoop {
|
|
|
6525
6726
|
this.#assertClarificationWakeRunning();
|
|
6526
6727
|
await renewLease();
|
|
6527
6728
|
}
|
|
6528
|
-
const event = reply.source === 'github'
|
|
6529
|
-
? githubReplyEvent(waiting.issue, reply.text, reply.author)
|
|
6530
|
-
: slackReplyEvent(waiting.issue, reply.text);
|
|
6531
|
-
for (const [name] of resumed) {
|
|
6532
|
-
if (waiting.wake?.injectedAgents.includes(name))
|
|
6533
|
-
continue;
|
|
6534
|
-
this.#assertClarificationWakeRunning();
|
|
6535
|
-
await renewLease();
|
|
6536
|
-
if (this.#fleet.sendInput) {
|
|
6537
|
-
await this.#fleet.sendInput(name, event);
|
|
6538
|
-
}
|
|
6539
|
-
else {
|
|
6540
|
-
await this.#fleet.sendMessage({
|
|
6541
|
-
to: name,
|
|
6542
|
-
from: 'factory',
|
|
6543
|
-
text: event.replace(/\r$/u, ''),
|
|
6544
|
-
});
|
|
6545
|
-
}
|
|
6546
|
-
this.#assertClarificationWakeRunning();
|
|
6547
|
-
const marked = await this.#state.markClarificationAgentInjected(this.#workspaceId, key, this.#clarificationWakeOwner, name);
|
|
6548
|
-
if (!marked)
|
|
6549
|
-
throw new ClarificationWakeLeaseLostError('clarification wake lease lost after injection');
|
|
6550
|
-
this.#increment('clarificationReplyInjections');
|
|
6551
|
-
}
|
|
6552
6729
|
await renewLease();
|
|
6553
6730
|
await this.#writeInFlightRegistry();
|
|
6554
6731
|
await this.#saveDispatchLifecycle(record, 'running');
|
|
@@ -6557,7 +6734,7 @@ export class FactoryLoop {
|
|
|
6557
6734
|
if (!completed)
|
|
6558
6735
|
throw new ClarificationWakeLeaseLostError('clarification wake completion lost ownership');
|
|
6559
6736
|
this.#increment('clarificationTeamsWoken');
|
|
6560
|
-
this.#logger.info?.('[factory]
|
|
6737
|
+
this.#logger.info?.('[factory] restarted team after human clarification', {
|
|
6561
6738
|
issue: waiting.issue.key,
|
|
6562
6739
|
agents: resumed.map(([name]) => name),
|
|
6563
6740
|
coldStarts: resumed.filter(([, tracked]) => !tracked.sessionRef).length,
|
|
@@ -6667,6 +6844,7 @@ export class FactoryLoop {
|
|
|
6667
6844
|
return role === 'agentImplementing';
|
|
6668
6845
|
}
|
|
6669
6846
|
async #resumeOrColdStartClarificationAgent(name, tracked, waiting) {
|
|
6847
|
+
const task = clarificationResumeTask(tracked.spec.task, waiting);
|
|
6670
6848
|
if (tracked.sessionRef) {
|
|
6671
6849
|
try {
|
|
6672
6850
|
const resumed = await this.#fleet.resume({
|
|
@@ -6676,6 +6854,7 @@ export class FactoryLoop {
|
|
|
6676
6854
|
capability: tracked.spec.capability,
|
|
6677
6855
|
repo: tracked.spec.repo,
|
|
6678
6856
|
clonePath: tracked.spec.clonePath,
|
|
6857
|
+
task,
|
|
6679
6858
|
});
|
|
6680
6859
|
return {
|
|
6681
6860
|
...resumed,
|
|
@@ -6698,19 +6877,11 @@ export class FactoryLoop {
|
|
|
6698
6877
|
this.#increment('clarificationResumeFallbacks');
|
|
6699
6878
|
}
|
|
6700
6879
|
this.#assertClarificationWakeRunning();
|
|
6701
|
-
const reply = waiting.reply?.text ?? '';
|
|
6702
6880
|
return await this.#fleet.spawn({
|
|
6703
6881
|
name,
|
|
6704
6882
|
capability: tracked.spec.capability,
|
|
6705
6883
|
node: tracked.result?.node ?? tracked.spec.node ?? 'self',
|
|
6706
|
-
task
|
|
6707
|
-
tracked.spec.task,
|
|
6708
|
-
'',
|
|
6709
|
-
'Factory released this team while waiting for human input and could not restore the prior harness session.',
|
|
6710
|
-
`The blocked question was: ${waiting.question}`,
|
|
6711
|
-
`The human replied: ${reply}`,
|
|
6712
|
-
'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
|
|
6713
|
-
].join('\n'),
|
|
6884
|
+
task,
|
|
6714
6885
|
workflow: tracked.spec.workflow,
|
|
6715
6886
|
inputs: tracked.spec.inputs,
|
|
6716
6887
|
model: tracked.spec.model,
|
|
@@ -6800,61 +6971,7 @@ export class FactoryLoop {
|
|
|
6800
6971
|
this.#emit('issue-queued', { issue: decision.issue });
|
|
6801
6972
|
}
|
|
6802
6973
|
}
|
|
6803
|
-
|
|
6804
|
-
const key = issueKey(record.issue);
|
|
6805
|
-
const text = this.#pendingSlackClarifications.get(key);
|
|
6806
|
-
if (!text || !this.#fleet.sendInput) {
|
|
6807
|
-
return;
|
|
6808
|
-
}
|
|
6809
|
-
const recipients = [...record.agents.values()]
|
|
6810
|
-
.filter((agent) => agent.spec.role === 'implementer' ||
|
|
6811
|
-
agent.spec.role === 'workflow' ||
|
|
6812
|
-
agent.spec.role === 'babysitter')
|
|
6813
|
-
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
6814
|
-
.filter((name) => Boolean(name));
|
|
6815
|
-
for (const recipient of new Set(recipients)) {
|
|
6816
|
-
try {
|
|
6817
|
-
await this.#injectSlackReplyEvent(recipient, record.issue, text);
|
|
6818
|
-
this.#increment('slackTriageAnswersInjectedToAgents');
|
|
6819
|
-
}
|
|
6820
|
-
catch (error) {
|
|
6821
|
-
this.#logger.warn?.('[factory] failed to inject Slack triage clarification into agent', {
|
|
6822
|
-
issue: record.issue.key,
|
|
6823
|
-
recipient,
|
|
6824
|
-
error,
|
|
6825
|
-
});
|
|
6826
|
-
}
|
|
6827
|
-
}
|
|
6828
|
-
this.#pendingSlackClarifications.delete(key);
|
|
6829
|
-
}
|
|
6830
|
-
async #injectPendingGithubClarification(record) {
|
|
6831
|
-
const key = issueKey(record.issue);
|
|
6832
|
-
const text = this.#pendingGithubClarifications.get(key);
|
|
6833
|
-
if (!text || !this.#fleet.sendInput) {
|
|
6834
|
-
return;
|
|
6835
|
-
}
|
|
6836
|
-
const recipients = [...record.agents.values()]
|
|
6837
|
-
.filter((agent) => agent.spec.role === 'implementer' ||
|
|
6838
|
-
agent.spec.role === 'workflow' ||
|
|
6839
|
-
agent.spec.role === 'babysitter')
|
|
6840
|
-
.map((agent) => agent.result?.name ?? agent.spec.name)
|
|
6841
|
-
.filter((name) => Boolean(name));
|
|
6842
|
-
for (const recipient of new Set(recipients)) {
|
|
6843
|
-
try {
|
|
6844
|
-
await this.#fleet.sendInput(recipient, githubReplyEvent(record.issue, text));
|
|
6845
|
-
this.#increment('githubTriageAnswersInjectedToAgents');
|
|
6846
|
-
}
|
|
6847
|
-
catch (error) {
|
|
6848
|
-
this.#logger.warn?.('[factory] failed to inject GitHub triage clarification into agent', {
|
|
6849
|
-
issue: record.issue.key,
|
|
6850
|
-
recipient,
|
|
6851
|
-
error,
|
|
6852
|
-
});
|
|
6853
|
-
}
|
|
6854
|
-
}
|
|
6855
|
-
this.#pendingGithubClarifications.delete(key);
|
|
6856
|
-
}
|
|
6857
|
-
// Inject the human's Slack reply into the agent framed as the
|
|
6974
|
+
// Route ordinary Slack conversation into a live agent framed as the
|
|
6858
6975
|
// <integration-event> the spawn prompt tells it to expect (not an ambiguous
|
|
6859
6976
|
// "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
|
|
6860
6977
|
// event. (A broker confirmed-delivery path via waitForInjected is a possible
|
|
@@ -7611,9 +7728,10 @@ const templateIssueFromRecord = (record, issue) => ({
|
|
|
7611
7728
|
key: issue?.key ?? record.issue.key,
|
|
7612
7729
|
title: issue?.title ?? record.issue.key,
|
|
7613
7730
|
description: issue?.description ?? '',
|
|
7731
|
+
github: issue ? githubIssueSourceRef(issue) : undefined,
|
|
7614
7732
|
});
|
|
7615
|
-
const
|
|
7616
|
-
const route =
|
|
7733
|
+
const routeForSpec = (decision, spec) => {
|
|
7734
|
+
const route = decision.routes.find((candidate) => candidate.repo === spec.repo && candidate.clonePath === spec.clonePath) ?? decision.routes.find((candidate) => candidate.repo === spec.repo);
|
|
7617
7735
|
return {
|
|
7618
7736
|
repo: spec.repo,
|
|
7619
7737
|
clonePath: spec.clonePath,
|
|
@@ -8808,7 +8926,17 @@ const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text
|
|
|
8808
8926
|
// told (at spawn) to expect — a recognizable injected event, not an ambiguous
|
|
8809
8927
|
// keystroke. Trailing CR submits it to the agent's PTY.
|
|
8810
8928
|
const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
|
|
8811
|
-
const
|
|
8929
|
+
const clarificationResumeTask = (baseTask, waiting) => {
|
|
8930
|
+
const reply = waiting.reply;
|
|
8931
|
+
return [
|
|
8932
|
+
baseTask,
|
|
8933
|
+
'',
|
|
8934
|
+
'Factory released this team while waiting for human input and is now starting a fresh task after the durable issue-comment response.',
|
|
8935
|
+
`The blocked question was: ${waiting.question}`,
|
|
8936
|
+
`The human answered${reply?.author ? ` as @${reply.author}` : ''}: ${reply?.text ?? ''}`,
|
|
8937
|
+
'Re-hydrate from the issue, branch, worktree, and any open PR, then continue the task. Do not repeat completed work.',
|
|
8938
|
+
].join('\n');
|
|
8939
|
+
};
|
|
8812
8940
|
const isFactoryQuestionTarget = (target) => {
|
|
8813
8941
|
const normalized = target.trim().replace(/^@/u, '').toLowerCase();
|
|
8814
8942
|
return normalized === 'broker' || normalized === 'factory';
|