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
7 changes: 7 additions & 0 deletions src/dispatch/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ describe('renderAgentTask', () => {
expect(task).toContain('Do not run `gh pr create` or require local GitHub CLI authentication.')
expect(task).toContain('Factory will hand the opened PR to reviewer `ar-123-review`.')
expect(task).toContain('DM `factory` with `[factory-needs-input]`')
expect(task).toContain('source GitHub issue when available')
expect(task).toContain('operator-visible delivery error')
expect(task).toContain('No durable Slack dispatch thread was established')
expect(task).toContain('Keep the session available after asking')
expect(task).not.toContain('release the whole team and resume it from session memory')
expect(task).toContain('DM `broker` when fully done.')
expect(task).toContain('Do NOT auto-merge.')
expect(task).toContain('Merge policy: never')
Expand Down Expand Up @@ -257,6 +262,8 @@ describe('renderAgentTask', () => {

expect(task).toContain('DM `factory` with `[factory-needs-input]`')
expect(task).toContain('/work/.integrations/slack/channels/C123/messages/169_000/replies/question.json')
expect(task).toContain('source GitHub issue when available')
expect(task).toContain('operator-visible delivery error')
expect(task).toContain('Do not wait or poll')
expect(task).toContain('release the whole team and resume it from session memory only after the first human reply')
expect(task).toContain('cold-start the team with the issue, question, reply, branch, and PR context')
Expand Down
18 changes: 11 additions & 7 deletions src/dispatch/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,22 +94,26 @@ export function renderAgentTask(input: RenderAgentTaskInput): string {
'When implementation is complete, finish your session normally; Factory will open the PR targeting the repository default branch through the connected GitHub workspace.',
'Do not run `gh pr create` or require local GitHub CLI authentication.',
`Factory will hand the opened PR to reviewer \`${input.reviewerName}\`.`,
'If blocked and you need human input, DM `factory` with `[factory-needs-input]`, the issue key, and one concrete question. Factory will relay it to the issue conversation.',
'DM `broker` when fully done.',
'Do NOT auto-merge.',
mergePolicyLine(input.config.mergePolicy),
]
const questionInstructions = input.slackDispatchThread
? [
'',
'If you are blocked or need a human answer mid-task, finish any safe reversible work first, then DM `factory` with `[factory-needs-input]`, the issue key, and one concrete question.',
const questionInstructions = [
'',
'If you are blocked or need a human answer mid-task, finish any safe reversible work first, then DM `factory` with `[factory-needs-input]`, the issue key, and one concrete question.',
'Factory will route the question through the issue Slack thread when available and healthy, otherwise through the source GitHub issue when available. If neither write path works, Factory will emit an operator-visible delivery error instead of silently discarding the question.',
...(input.slackDispatchThread
? [
// Absolute path: the agent runs in its repo clone, not the daemon cwd
// where .integrations lives, so a relative path would be unreachable.
`Factory will durably post and, if necessary, retry the question to the Slack thread represented at ${input.slackDispatchThread.mountRoot}/slack/channels/${input.slackDispatchThread.channel}/messages/${input.slackDispatchThread.threadId.replaceAll('.', '_')}/replies/question.json.`,
`Factory will durably post and, if necessary, retry the question through that routing policy; the primary Slack thread is represented at ${input.slackDispatchThread.mountRoot}/slack/channels/${input.slackDispatchThread.channel}/messages/${input.slackDispatchThread.threadId.replaceAll('.', '_')}/replies/question.json.`,
'After sending the marker, stop work and finish your session normally. Do not wait or poll: Factory will release the whole team and resume it from session memory only after the first human reply.',
'If session resume is unavailable, Factory will cold-start the team with the issue, question, reply, branch, and PR context so work can be re-hydrated explicitly.',
]
: []
: [
'No durable Slack dispatch thread was established for this task. Keep the session available after asking so Factory can inject a GitHub reply if that fallback is available.',
]),
]

if (input.role === 'babysitter') {
const prRef = input.pr
Expand Down
73 changes: 73 additions & 0 deletions src/mount/relayfile-cloud-mount-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ describe('RelayfileCloudMountClient', () => {
status: 'ready',
lastEventAt: '2026-06-12T10:00:00.000Z',
lagSeconds: 42,
webhookHealthy: true,
}],
}))
const mount = new RelayfileCloudMountClient({ workspaceId: 'rw_test', client: fake })
Expand All @@ -440,10 +441,81 @@ describe('RelayfileCloudMountClient', () => {
lastEventAtMs: Date.parse('2026-06-12T10:00:00.000Z'),
watermarkTs: '2026-06-12T10:00:00.000Z',
lagSeconds: 42,
webhookHealthy: true,
})
expect(fake.getSyncStatus).toHaveBeenCalledWith('rw_test', { provider: 'slack' })
})

it('normalizes snake-case webhook health as independent provider freshness', async () => {
const fake = new FakeRelayFileClient()
fake.getSyncStatus = vi.fn(async () => ({
status: 'ready',
connections: [{
provider: 'slack',
status: 'lagging',
last_event_at: '2026-06-06T12:05:00.000Z',
webhook_healthy: true,
}],
}))
const mount = new RelayfileCloudMountClient({ workspaceId: 'rw_test', client: fake })

await expect(mount.getSyncStatus?.('slack')).resolves.toEqual({
provider: 'slack',
status: 'lagging',
lastEventAt: '2026-06-06T12:05:00.000Z',
lastEventAtMs: Date.parse('2026-06-06T12:05:00.000Z'),
watermarkTs: '2026-06-06T12:05:00.000Z',
lagSeconds: undefined,
webhookHealthy: true,
})
})

it.each([
{ webhookHealthy: true, label: 'healthy' },
{ webhookHealthy: false, label: 'unhealthy' },
])('preserves nested sync freshness when wrapper webhook delivery is $label', async ({ webhookHealthy }) => {
const fake = new FakeRelayFileClient()
fake.getSyncStatus = vi.fn(async () => ({
webhookHealthy,
connections: [{
provider: 'slack',
status: 'lagging',
lastEventAt: '2026-06-06T12:05:00.000Z',
lagSeconds: 86_400,
}],
}))
const mount = new RelayfileCloudMountClient({ workspaceId: 'rw_test', client: fake })

await expect(mount.getSyncStatus?.('slack')).resolves.toEqual({
provider: 'slack',
status: 'lagging',
lastEventAt: '2026-06-06T12:05:00.000Z',
lastEventAtMs: Date.parse('2026-06-06T12:05:00.000Z'),
watermarkTs: '2026-06-06T12:05:00.000Z',
lagSeconds: 86_400,
webhookHealthy,
})
})

it('lets an explicit unhealthy signal win across split provider metadata', async () => {
const fake = new FakeRelayFileClient()
fake.getSyncStatus = vi.fn(async () => ({
webhookHealthy: true,
connections: [{
provider: 'slack',
status: 'ready',
last_event_at: '2026-06-12T10:00:00.000Z',
webhook_healthy: false,
}],
}))
const mount = new RelayfileCloudMountClient({ workspaceId: 'rw_test', client: fake })

await expect(mount.getSyncStatus?.('slack')).resolves.toMatchObject({
lastEventAt: '2026-06-12T10:00:00.000Z',
webhookHealthy: false,
})
})

it('prefers nested provider freshness over wrapper status metadata', async () => {
const fake = new FakeRelayFileClient()
fake.getSyncStatus = vi.fn(async () => ({
Expand All @@ -463,6 +535,7 @@ describe('RelayfileCloudMountClient', () => {
lastEventAtMs: 1_781_267_200_000,
watermarkTs: undefined,
lagSeconds: 12,
webhookHealthy: undefined,
})
})

Expand Down
13 changes: 13 additions & 0 deletions src/mount/relayfile-cloud-mount-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,13 +494,23 @@ const normalizeProviderSyncStatus = (value: unknown, provider: string): Provider
numberField(source, 'last_event_at_ms') ??
(lastEventAt ? Date.parse(lastEventAt) : undefined)
const watermarkTs = stringField(source, 'watermarkTs') ?? stringField(source, 'watermark_ts') ?? lastEventAt
// Webhook health is independent of sync freshness and may be reported on a
// wrapper while the provider watermark lives on a nested connection. Keep
// freshness source selection focused on sync fields, then reconcile health
// across every applicable shape. A reported false wins conservatively: a
// false-positive healthy result would send questions to an unread Slack.
const webhookHealthySignals = [source, ...candidates]
.map((candidate) =>
booleanField(candidate, 'webhookHealthy') ?? booleanField(candidate, 'webhook_healthy'))
.filter((signal): signal is boolean => signal !== undefined)
return {
provider: stringField(source, 'provider') ?? provider,
status: stringField(source, 'status'),
lastEventAt,
lastEventAtMs: Number.isFinite(lastEventAtMs) ? lastEventAtMs : undefined,
watermarkTs,
lagSeconds: numberField(source, 'lagSeconds') ?? numberField(source, 'lag_seconds'),
webhookHealthy: webhookHealthySignals.includes(false) ? false : webhookHealthySignals[0],
}
}

Expand All @@ -523,6 +533,9 @@ const stringField = (record: Record<string, unknown>, key: string): string | und
const numberField = (record: Record<string, unknown>, key: string): number | undefined =>
typeof record[key] === 'number' ? record[key] : undefined

const booleanField = (record: Record<string, unknown>, key: string): boolean | undefined =>
typeof record[key] === 'boolean' ? record[key] : undefined

const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined

Expand Down
Loading