diff --git a/src/orchestrator/factory.test.ts b/src/orchestrator/factory.test.ts index 11e01b3..f27f152 100644 --- a/src/orchestrator/factory.test.ts +++ b/src/orchestrator/factory.test.ts @@ -248,19 +248,13 @@ class EscalatingTriage extends StaticTriage { } } -class SlackClarifiedTriage extends EscalatingTriage { +class SlackStillEscalatingTriage extends EscalatingTriage { override async triage(issue: LinearIssue): Promise { if (issue.description.includes('Human clarification from Slack:')) { - const decision = await super.triage({ + return super.triage({ ...issue, description: `${issue.description}\nImplement the clarified behavior and verify it with tests.`, }) - return { - ...decision, - thin: false, - confidence: 'high', - rationale: 'Human Slack clarification supplied enough acceptance detail.', - } } return super.triage(issue) } @@ -6819,13 +6813,13 @@ describe('FactoryLoop', () => { expect(slack.roots).toEqual([]) }) - it('uses a human Slack answer to retry pre-dispatch triage and dispatch when clarified', async () => { + it('dispatches a matched agent to read a Slack triage answer even when factory still finds the issue thin', async () => { const mount = new CloudWritebackFakeMountClient({ [issuePath(23)]: issueFile(23) }) const fleet = new FakeFleetClient() const factory = createFactory(config({ slack: slackConfig() }), { mount, fleet, - triage: new SlackClarifiedTriage({ rationale: 'Matched repository from Linear label.' }), + triage: new SlackStillEscalatingTriage({ rationale: 'Matched repository from Linear label.' }), }) await factory.runOnce() @@ -6838,9 +6832,13 @@ describe('FactoryLoop', () => { user_is_bot: false, }) - await vi.waitFor(() => expect(factory.status().counters.slackTriageAnswersDispatched).toBe(1)) + await vi.waitFor(() => expect(factory.status().counters.slackTriageAnswersDispatchedWithRemainingEscalation).toBe(1)) expect(fleet.spawns.map((spawn) => spawn.name)).toEqual(['ar-23-impl-pear', 'ar-23-review']) - expect(factory.status().counters.slackTriageAnswersDispatched).toBe(1) + await vi.waitFor(() => expect(slackAnswerInputs(fleet)).toEqual([ + { name: 'ar-23-impl-pear', data: '\nHuman reply in the Slack thread:\nWhen deployed via ./workforce, one-click deploy in cloud should auto-join the configured Slack channel and ask there if blocked. Verify with tests.\n\r' }, + ])) + expect(factory.status().counters.slackTriageAnswersDispatchedWithRemainingEscalation).toBe(1) + expect(factory.status().counters.slackTriageAnswersInjectedToAgents).toBe(1) expect(factory.status().counters.slackTriageAnswersStillEscalated).toBeUndefined() expect(factory.status().counters.errors).toBeUndefined() }) diff --git a/src/orchestrator/factory.ts b/src/orchestrator/factory.ts index 2ef6620..bfd829b 100644 --- a/src/orchestrator/factory.ts +++ b/src/orchestrator/factory.ts @@ -204,6 +204,7 @@ export class FactoryLoop implements Factory { // issue (or the comment writeback's own change event) does not re-post the // same notice every cycle. Cleared once the issue dispatches successfully. readonly #labelDispatchFailures = new Map() + readonly #pendingSlackClarifications = new Map() readonly #postMergeDoneAdvances = new Set() #slackDegraded = false #slackDegradedReason: string | undefined @@ -1415,6 +1416,7 @@ export class FactoryLoop implements Factory { await this.#ensureSlackDispatchThread(record, result) await this.#sendImplementerTask(record) await this.#sendCriticalReviewerMessage(record) + await this.#injectPendingSlackClarification(record) } return result } catch (error) { @@ -3722,6 +3724,12 @@ export class FactoryLoop implements Factory { }) const escalationReason = triageEscalationReason(decision) if (escalationReason) { + if (hasDispatchableRoute(decision)) { + this.#pendingSlackClarifications.set(issueKey(decision.issue), text) + await this.#startOrQueueSlackClarifiedDecision(dispatchAfterSlackClarification(decision, escalationReason)) + this.#increment('slackTriageAnswersDispatchedWithRemainingEscalation') + return + } this.#increment('slackTriageAnswersStillEscalated') this.#logger.warn?.('[factory] Slack triage answer still leaves issue escalated', { issue: record.issue, @@ -3730,9 +3738,15 @@ export class FactoryLoop implements Factory { return } + this.#pendingSlackClarifications.set(issueKey(decision.issue), text) + await this.#startOrQueueSlackClarifiedDecision(decision) + this.#increment('slackTriageAnswersDispatched') + } + + async #startOrQueueSlackClarifiedDecision(decision: TriageDecision): Promise { + const batch = await this.#batch() if (batch.canStart()) { await this.dispatch(decision, { dryRun: this.#config.dryRun }) - this.#increment('slackTriageAnswersDispatched') return } @@ -3742,6 +3756,36 @@ export class FactoryLoop implements Factory { } } + async #injectPendingSlackClarification(record: InFlightIssue): Promise { + const key = issueKey(record.issue) + const text = this.#pendingSlackClarifications.get(key) + if (!text || !this.#fleet.sendInput) { + return + } + + const recipients = [...record.agents.values()] + .filter((agent) => + agent.spec.role === 'implementer' || + agent.spec.role === 'workflow' || + agent.spec.role === 'babysitter') + .map((agent) => agent.result?.name ?? agent.spec.name) + .filter((name): name is string => Boolean(name)) + + for (const recipient of new Set(recipients)) { + try { + await this.#injectSlackReplyEvent(recipient, record.issue, text) + this.#increment('slackTriageAnswersInjectedToAgents') + } catch (error) { + this.#logger.warn?.('[factory] failed to inject Slack triage clarification into agent', { + issue: record.issue.key, + recipient, + error, + }) + } + } + this.#pendingSlackClarifications.delete(key) + } + // Inject the human's Slack reply into the agent framed as the // the spawn prompt tells it to expect (not an ambiguous // "Slack reply for ..." keystroke), so the agent recognizes it as the awaited @@ -5358,6 +5402,19 @@ const triageEscalationQuestion = (decision: TriageDecision): string => { const isTriageEscalationWatchRecord = (record: InFlightIssue): boolean => record.agents.size === 0 && record.invocationIds.size === 0 && triageEscalationReason(record.decision) !== undefined +const hasDispatchableRoute = (decision: TriageDecision): boolean => + decision.routes.length > 0 && dispatchSpecs(decision).length > 0 + +const dispatchAfterSlackClarification = (decision: TriageDecision, escalationReason: string): TriageDecision => ({ + ...decision, + thin: false, + confidence: 'high', + rationale: [ + decision.rationale, + `Human answered the Slack triage escalation (${escalationReason}); dispatching to the matched agent so it can acknowledge the answer and ask follow-up questions if needed.`, + ].filter(Boolean).join(' '), +}) + const issueWithSlackClarification = (issue: LinearIssue, text: string): LinearIssue => ({ ...issue, description: [