Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions src/orchestrator/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,19 +248,13 @@ class EscalatingTriage extends StaticTriage {
}
}

class SlackClarifiedTriage extends EscalatingTriage {
class SlackStillEscalatingTriage extends EscalatingTriage {
override async triage(issue: LinearIssue): Promise<TriageDecision> {
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)
}
Expand Down Expand Up @@ -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()
Expand All @@ -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: '<integration-event source="slack" issue="AR-23">\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</integration-event>\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()
})
Expand Down
59 changes: 58 additions & 1 deletion src/orchestrator/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()
readonly #pendingSlackClarifications = new Map<string, string>()
readonly #postMergeDoneAdvances = new Set<string>()
#slackDegraded = false
#slackDegradedReason: string | undefined
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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<void> {
const batch = await this.#batch()
if (batch.canStart()) {
await this.dispatch(decision, { dryRun: this.#config.dryRun })
this.#increment('slackTriageAnswersDispatched')
return
}

Expand All @@ -3742,6 +3756,36 @@ export class FactoryLoop implements Factory {
}
}

async #injectPendingSlackClarification(record: InFlightIssue): Promise<void> {
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
// <integration-event> the spawn prompt tells it to expect (not an ambiguous
// "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
Expand Down Expand Up @@ -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: [
Expand Down