From 07bcf985ac2076d7c5f2b90b666c94dde3d52a91 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:34:24 -0400 Subject: [PATCH 1/3] [Improve] Link task memories in the Brain to the member who started them Task memory pages carried no creator, while conversation and pull request pages already did. Standard-workflow tasks a linked Roomote member started now stamp initiated_by, roomote_user_id, and a link to the member's person page; unlinked humans keep only the surface-reported display name; automation runs name their key. Reviews, conflict resolution, scans, and snapshots never link a person, since whoever triggered a review is not its author. The collector id bumps so existing pages pick up the new shape, but the replay is scoped to linkable standard tasks completed in the last 90 days so a version bump no longer re-embeds every tenant's whole history at once. The superseded sync-state row is dropped so it cannot act as the history cutoff. --- .../__tests__/brain-outbox-drain.test.ts | 69 +++++++++++ .../src/scheduled-jobs/brain-outbox-drain.ts | 113 +++++++++++++++++- apps/docs/memory.mdx | 5 +- packages/db/src/lib/__tests__/brain.test.ts | 60 +++++++--- packages/db/src/lib/brain.ts | 17 ++- packages/types/src/brain.test.ts | 6 +- packages/types/src/brain.ts | 2 +- 7 files changed, 248 insertions(+), 24 deletions(-) diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts index b28f6d091f..d0953f412c 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts @@ -61,6 +61,7 @@ vi.mock('@roomote/db/server', async (importOriginal) => { releaseFastAgentMemoryEvents: mockReleaseFastEvents, getBrainSyncState: mockGetSyncState, upsertBrainSyncState: vi.fn(), + deleteBrainSyncStateFamily: vi.fn(), }; }); @@ -82,6 +83,7 @@ beforeEach(() => { }); }); +import { personIdentitySlug } from '../brain-collectors/identity'; import { brainCollectorsJob, brainOutboxDrainJob, @@ -443,9 +445,74 @@ describe('task memory page identity', () => { completedAt: new Date('2026-08-13T10:00:00Z'), environmentName: null, agentSummary: 'Used the durable approach.', + initiator: { kind: 'user' as const, userId: 'user-1', name: 'Sam Lee' }, + workflow: 'standard' as const, pullRequests: [], }; + it('links a linked member to their person page', () => { + const page = buildMemoryPage({ ...base, runId: 101 }); + const slug = personIdentitySlug('user-1'); + + expect(slug).toMatch(/^people\/roomote-member-[0-9a-f]{16}$/); + expect(page.content).toContain('\ninitiated_by: "Sam Lee"\n'); + expect(page.content).toContain('\nroomote_user_id: user-1\n'); + expect(page.content).toContain( + `\ninitiated_by_person: ${JSON.stringify(slug)}\n`, + ); + expect(page.content).toContain(`\nInitiated by [Sam Lee](${slug}).\n`); + }); + + it('names an unlinked human without inventing a person page', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + initiator: { kind: 'user', userId: null, name: 'octocat' }, + }); + + expect(page.content).toContain('\ninitiated_by: "octocat"\n'); + expect(page.content).not.toContain('roomote_user_id'); + expect(page.content).not.toContain('initiated_by_person'); + expect(page.content).toContain('\nInitiated by octocat.\n'); + }); + + it('names the automation that started a task', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + initiator: { kind: 'automation', automation: 'issue_fixer' }, + }); + + expect(page.content).toContain('\ninitiated_by_automation: issue_fixer\n'); + expect(page.content).not.toContain('initiated_by:'); + expect(page.content).toContain( + '\nInitiated by the issue_fixer automation.\n', + ); + }); + + it('never links a person to a review the member merely triggered', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + workflow: 'pr_review', + }); + + expect(page.content).not.toContain('Initiated by'); + expect(page.content).not.toContain('initiated_by'); + expect(page.content).not.toContain('roomote_user_id'); + }); + + it('omits the initiator line when nothing is known about them', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + initiator: { kind: 'user', userId: null, name: null }, + }); + + expect(page.content).not.toContain('Initiated by'); + expect(page.content).not.toContain('initiated_by'); + }); + it('keeps separate runs of the same task distinct', () => { const first = buildMemoryPage({ ...base, runId: 101 }); const followUp = buildMemoryPage({ ...base, runId: 102 }); @@ -492,6 +559,8 @@ describe('task memory pull request outcomes', () => { completedAt: new Date('2026-08-13T10:00:00Z'), environmentName: null, agentSummary: 'Opened a PR with the durable approach.', + initiator: { kind: 'automation' as const, automation: 'issue_fixer' }, + workflow: 'standard' as const, }; const pr = { repository: 'owner/repo', diff --git a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts index 0ca6cf9c76..a9a72b2a53 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts @@ -13,6 +13,7 @@ import { settleBrainMemoryEvent, releaseBrainMemoryEvents, releaseFastAgentMemoryEvents, + deleteBrainSyncStateFamily, settleFastAgentMemoryEvent, pullRequestFacts, taskPullRequests, @@ -38,12 +39,17 @@ import { BRAIN_PAGE_TYPES, type PullRequestStatus, RunStatus, + type TaskWorkflow, brainNamespacePrefix, getLinkedEnvironmentIdFromPayload, renderBrainFrontmatter, } from '@roomote/types'; import { runBrainCollectors } from './brain-collectors'; +import { + brainSafeIdentityValue, + personIdentitySlug, +} from './brain-collectors/identity'; import { drainMemoryOutboxBatch } from './memory-outbox-drain'; import { runSlackDayPageCensus, @@ -54,6 +60,19 @@ import { slackPublicChannelsCollector } from './brain-collectors/slack-public-ch const LOG_PREFIX = '[brainOutboxDrain]'; /** Sync-state key for the one-time task-history backfill. */ const TASK_MEMORY_COLLECTOR_ID = BRAIN_COLLECTOR_IDS.taskMemories; +/** + * Task-memory sync-state rows left behind by version bumps. A bump replays + * history under the new id (the backfill checkpoint lives on the row), and + * the old row would otherwise linger and count as the source's history + * cutoff forever. Extend when bumping again. + */ +const SUPERSEDED_TASK_MEMORY_COLLECTOR_IDS = ['task-memory:effective-date-v2']; +/** + * How far back a version bump re-puts memories that already reached the + * Brain. Older pages keep correct content and pick the new shape up if a + * linked pull request later changes state. + */ +const LINKABLE_REPLAY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; const CLAIM_BATCH_SIZE = 10; // Backfill can enqueue a deployment's whole task history at once; drain up // to this many batches per tick so the backlog clears in minutes, not hours. @@ -308,6 +327,71 @@ function describePullRequestOutcome( * timestamps, provenance). LLM distillation of decisions/rationale layers on * top of this later; it must never widen what raw data can reach the brain. */ +/** + * Who started the task. On a standard-workflow task, a linked Roomote member + * gets a link to their person page so recall can answer "what has X been + * working on"; an unlinked human from an integration surface (a Slack or + * GitHub user with no Roomote account) keeps only the display name the + * surface reported; an automation names its key. Other workflows (PR + * reviews, conflict resolution, scans, snapshots) never link a person: the + * human who happened to trigger a review is not its author, and a person + * page full of reviews says nothing about what they worked on. + */ +type TaskMemoryInitiator = + | { + kind: 'user'; + userId: string | null; + /** The Roomote member's name, or the surface-reported display name. */ + name: string | null; + } + | { kind: 'automation'; automation: string }; + +function describeInitiator( + initiator: TaskMemoryInitiator, + workflow: TaskWorkflow, +): { + fields: string[]; + line: string | null; +} { + if (initiator.kind === 'automation') { + return { + fields: [`initiated_by_automation: ${initiator.automation}`], + line: `Initiated by the ${initiator.automation} automation.`, + }; + } + + if (workflow !== 'standard') { + return { fields: [], line: null }; + } + + const name = initiator.name ? brainSafeIdentityValue(initiator.name) : ''; + + if (initiator.userId) { + const slug = personIdentitySlug(initiator.userId); + const title = name || 'Roomote member'; + + return { + fields: [ + `initiated_by: ${JSON.stringify(title)}`, + `roomote_user_id: ${initiator.userId}`, + // Same convention as person aliases: the person page's slug, so the + // Brain can walk from the task to the member and back. + `initiated_by_person: ${JSON.stringify(slug)}`, + ], + line: `Initiated by [${title}](${slug}).`, + }; + } + + if (name) { + return { + fields: [`initiated_by: ${JSON.stringify(name)}`], + line: `Initiated by ${name}.`, + }; + } + + return { fields: [], line: null }; +} + export function buildMemoryPage(input: { runId: number; taskId: string; @@ -315,6 +399,8 @@ export function buildMemoryPage(input: { completedAt: Date | null; environmentName: string | null; agentSummary: string | null; + initiator: TaskMemoryInitiator; + workflow: TaskWorkflow; pullRequests: Array<{ repository: string | null; prNumber: number | null; @@ -327,6 +413,7 @@ export function buildMemoryPage(input: { const completed = completedAtIso ?? 'unknown'; const completedDate = completedAtIso?.slice(0, 10); const outcome = summarizePullRequestOutcome(input.pullRequests); + const initiator = describeInitiator(input.initiator, input.workflow); const prLines = input.pullRequests.map((pr) => { const label = pr.repository && pr.prNumber @@ -346,6 +433,7 @@ export function buildMemoryPage(input: { fields: [ `roomote_task_id: ${input.taskId}`, `roomote_run_id: ${input.runId}`, + ...initiator.fields, // GBrain derives effective_date from this conventional field. Keep // the full timestamp below as provenance, but make backfilled pages // sort and filter by when the task completed rather than when it @@ -367,6 +455,7 @@ export function buildMemoryPage(input: { '', `# ${input.taskTitle}`, '', + ...(initiator.line ? [initiator.line, ''] : []), // The agent that did the work writes the substance when it can; the // deterministic completion line is the floor, not the ceiling. ...(input.agentSummary @@ -470,6 +559,10 @@ async function resolveReadyBrain(): Promise<{ * restarts (there is no connect action to hang this off anymore). */ async function backfillTaskHistoryOnce(): Promise { + for (const collectorId of SUPERSEDED_TASK_MEMORY_COLLECTOR_IDS) { + await deleteBrainSyncStateFamily(db, collectorId); + } + const state = await getBrainSyncState(db, TASK_MEMORY_COLLECTOR_ID); if (state?.backfillCompletedAt) { @@ -477,7 +570,9 @@ async function backfillTaskHistoryOnce(): Promise { } const enqueued = await backfillBrainMemoryEvents(db, { - requeueCompleted: true, + requeueLinkable: { + completedAfter: new Date(Date.now() - LINKABLE_REPLAY_WINDOW_MS), + }, }); await upsertBrainSyncState(db, TASK_MEMORY_COLLECTOR_ID, { @@ -613,7 +708,7 @@ async function drainOneBatch(connection: { async prepare(event) { const run = await db.query.taskRuns.findFirst({ where: eq(taskRuns.id, event.runId), - with: { task: true }, + with: { task: { with: { initiatorUser: true } } }, }); if (!run) { @@ -671,13 +766,25 @@ async function drainOneBatch(connection: { environmentName = environment?.name ?? null; } + const task = run.task; + const initiator: TaskMemoryInitiator = + task.initiatorKind === 'automation' && task.initiatorAutomation + ? { kind: 'automation', automation: task.initiatorAutomation } + : { + kind: 'user', + userId: task.initiatorUser?.id ?? null, + name: task.initiatorUser?.name ?? task.actorDisplayName, + }; + const page = buildMemoryPage({ environmentName, agentSummary: event.agentSummary, runId: run.id, taskId: run.taskId, - taskTitle: run.task.title, + taskTitle: task.title, completedAt: run.completedAt, + initiator, + workflow: task.workflow, pullRequests: prRows.map((pr) => ({ repository: pr.repository, prNumber: pr.prNumber, diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index 0926547d21..fc8b21be16 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -21,7 +21,10 @@ Roomote fills Memory from what it can already see: its own work: what it decided, why, and what is still open. When a pull request the task opened later merges or closes unmerged, the task's memory is refreshed with that outcome, so recall can tell work that shipped from work - that was abandoned + that was abandoned. Each memory also records who started the task: a linked + Roomote member is connected to their person page, so recall can answer what + someone has been working on. Automated work such as pull request reviews + names the automation instead of linking a person - **pull requests** from your connected source-control provider - **public Slack channels** the Roomote bot has been added to - **public Discord server channels and active public threads** the Roomote bot diff --git a/packages/db/src/lib/__tests__/brain.test.ts b/packages/db/src/lib/__tests__/brain.test.ts index 088e06f24c..3615526801 100644 --- a/packages/db/src/lib/__tests__/brain.test.ts +++ b/packages/db/src/lib/__tests__/brain.test.ts @@ -15,6 +15,7 @@ import { tasks, taskRuns, taskFactory, + userFactory, brainMemoryEvents, brainCollectorItems, brainSyncState, @@ -47,8 +48,11 @@ import { runMemoryOutboxLifecycleContract } from './memory-outbox-lifecycle.cont const createdTaskIds: string[] = []; -async function makeCompletedRun(completedAt?: Date) { - const task = await taskFactory.create({ state: 'active' }); +async function makeCompletedRun( + completedAt?: Date, + taskParams: Parameters[0] = {}, +) { + const task = await taskFactory.create({ state: 'active', ...taskParams }); createdTaskIds.push(task.id); const [run] = await db @@ -723,25 +727,53 @@ describe('backfillBrainMemoryEvents', () => { expect(runningEvents).toHaveLength(0); }); - it('requeues completed memories for a one-time metadata replay', async () => { - const completed = await makeCompletedRun(); - await saveBrainAgentSummary(db, completed.id, 'Keep this summary.'); - const claimed = await claimPendingBrainMemoryEvents(db, 10); - const event = claimed.find((row) => row.runId === completed.id); - await settleBrainMemoryEvent(db, event!.id, event!.revision, 'done'); + it('requeues only linkable recent memories for a one-time metadata replay', async () => { + const user = await userFactory.create(); + const now = Date.now(); + const recent = new Date(now - 24 * 60 * 60 * 1000); + const stale = new Date(now - 400 * 24 * 60 * 60 * 1000); + const linkable = await makeCompletedRun(recent, { + initiatorUserId: user.id, + }); + const tooOld = await makeCompletedRun(stale, { + initiatorUserId: user.id, + }); + const review = await makeCompletedRun(recent, { + initiatorUserId: user.id, + workflow: 'pr_review', + }); + const unlinked = await makeCompletedRun(recent); + await saveBrainAgentSummary(db, linkable.id, 'Keep this summary.'); + // Every fixture run reaches the Brain once before the replay is asked for. + await backfillBrainMemoryEvents(db); + const claimed = await claimPendingBrainMemoryEvents(db, 1_000); + for (const event of claimed) { + await settleBrainMemoryEvent(db, event.id, event.revision, 'done'); + } + + await backfillBrainMemoryEvents(db, { + requeueLinkable: { + completedAfter: new Date(now - 90 * 24 * 60 * 60 * 1000), + }, + }); - await backfillBrainMemoryEvents(db, { requeueCompleted: true }); + const statusOf = async (runId: number) => { + const [row] = await db + .select() + .from(brainMemoryEvents) + .where(eq(brainMemoryEvents.runId, runId)); + return row; + }; - const [requeued] = await db - .select() - .from(brainMemoryEvents) - .where(eq(brainMemoryEvents.runId, completed.id)); - expect(requeued).toMatchObject({ + expect(await statusOf(linkable.id)).toMatchObject({ status: 'pending', attempts: 0, lastError: null, agentSummary: 'Keep this summary.', }); + expect((await statusOf(tooOld.id))?.status).toBe('done'); + expect((await statusOf(review.id))?.status).toBe('done'); + expect((await statusOf(unlinked.id))?.status).toBe('done'); }); }); diff --git a/packages/db/src/lib/brain.ts b/packages/db/src/lib/brain.ts index f45570ccb0..c264d3aa53 100644 --- a/packages/db/src/lib/brain.ts +++ b/packages/db/src/lib/brain.ts @@ -25,6 +25,7 @@ import { brainMemoryEvents, brainSyncState, taskRuns, + tasks, } from '../schema'; import { runInTransactionIfAvailable } from './transaction-utils'; import { createMemoryOutboxLifecycle } from './memory-outbox-lifecycle'; @@ -465,19 +466,31 @@ export async function requeueBrainMemoryEventsForTasks( * connecting the brain sucks in the deployment's task history rather than * only learning from tasks completed after enablement. Idempotent via the * unique(runId) constraint; the drainer distills the backlog batch by batch. + * + * `requeueLinkable` re-puts memories already in the Brain so a page-shape + * change reaches them. It is deliberately narrow: only standard-workflow + * tasks a linked Roomote member started, completed after the given time. + * Reviews, conflict resolution, scans, and automation-started work gain + * nothing from the replay, and a deployment's whole history re-embedded at + * once is a burst every managed tenant would land on the shared embedder + * together. */ export async function backfillBrainMemoryEvents( database: DatabaseOrTransaction, - options: { requeueCompleted?: boolean } = {}, + options: { requeueLinkable?: { completedAfter: Date } } = {}, ): Promise { - const requeued = options.requeueCompleted + const requeued = options.requeueLinkable ? ((await database.execute( sql`UPDATE ${brainMemoryEvents} AS event SET status = 'pending', attempts = 0, last_error = NULL, updated_at = now() FROM ${taskRuns} AS run + JOIN ${tasks} AS task ON task.id = run.task_id WHERE event.run_id = run.id AND event.status = 'done' AND run.status = 'completed' + AND run.completed_at > ${options.requeueLinkable.completedAfter.toISOString()}::timestamptz + AND task.workflow = 'standard' + AND task.initiator_user_id IS NOT NULL RETURNING event.id`, )) as unknown as Array<{ id: string }>) : []; diff --git a/packages/types/src/brain.test.ts b/packages/types/src/brain.test.ts index 7e799ca8f9..1cc57b8bac 100644 --- a/packages/types/src/brain.test.ts +++ b/packages/types/src/brain.test.ts @@ -91,9 +91,9 @@ describe('resolveBrainSourceIdForCollector', () => { }); it('maps the outbox-fed checkpoints back to their sources', () => { - expect( - resolveBrainSourceIdForCollector('task-memory:effective-date-v2'), - ).toBe('task-memories'); + expect(resolveBrainSourceIdForCollector('task-memory:initiator-v3')).toBe( + 'task-memories', + ); expect( resolveBrainSourceIdForCollector('pull-request-facts:occurrence-date-v3'), ).toBe('pull-request-facts'); diff --git a/packages/types/src/brain.ts b/packages/types/src/brain.ts index 6686faee78..4a43de4497 100644 --- a/packages/types/src/brain.ts +++ b/packages/types/src/brain.ts @@ -114,7 +114,7 @@ export function brainNamespaceLabel(id: BrainNamespaceBucketId): string { * superseded version's rows. */ export const BRAIN_COLLECTOR_IDS = { - taskMemories: 'task-memory:effective-date-v2', + taskMemories: 'task-memory:initiator-v3', pullRequestFacts: 'pull-request-facts:occurrence-date-v3', personIdentities: 'person-identities:members:occurrence-date-v2', ripplingWorkers: 'rippling-workers', From 137dc65ab19c4d56c9606f98bf9fdd6b018f5a6c Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:38:11 -0400 Subject: [PATCH 2/3] Carry the user's request on task memory pages The title alone is a weak handle for recall and the agent summary only exists when the agent wrote one. Standard-workflow tasks now carry the visible launch prompt, bounded to 1,500 characters and stripped of surface wrappers, ahead of the outcome. Generated prompts (reviews, conflict resolution), hidden bootstrap prompts, and harness-injected text stay out. --- .../__tests__/brain-outbox-drain.test.ts | 65 +++++++++++++++++++ .../src/scheduled-jobs/brain-outbox-drain.ts | 62 ++++++++++++++++++ apps/docs/memory.mdx | 6 +- 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts index d0953f412c..5183d32b75 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts @@ -90,6 +90,7 @@ import { buildFastMemoryPage, buildPullRequestFactPage, buildMemoryPage, + resolveTaskMemoryRequest, callBrainWriteTool, isBrainUnreachable, drainBrainHistoricalIngestion, @@ -447,6 +448,7 @@ describe('task memory page identity', () => { agentSummary: 'Used the durable approach.', initiator: { kind: 'user' as const, userId: 'user-1', name: 'Sam Lee' }, workflow: 'standard' as const, + request: null, pullRequests: [], }; @@ -502,6 +504,23 @@ describe('task memory page identity', () => { expect(page.content).not.toContain('roomote_user_id'); }); + it('carries the request the member made, ahead of the outcome', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + request: 'Make the flaky upload test deterministic.', + }); + + const request = page.content.indexOf('## Request'); + const summary = page.content.indexOf('Used the durable approach.'); + + expect(page.content).toContain( + '## Request\n\nMake the flaky upload test deterministic.\n', + ); + expect(request).toBeGreaterThan(-1); + expect(request).toBeLessThan(summary); + }); + it('omits the initiator line when nothing is known about them', () => { const page = buildMemoryPage({ ...base, @@ -551,6 +570,51 @@ describe('task memory page identity', () => { }); }); +describe('resolveTaskMemoryRequest', () => { + it('reads the visible prompt from the launch payload', () => { + expect( + resolveTaskMemoryRequest( + { description: ' Fix the login redirect loop. ' }, + 'standard', + ), + ).toBe('Fix the login redirect loop.'); + expect( + resolveTaskMemoryRequest({ text: 'Ship the banner.' }, 'standard'), + ).toBe('Ship the banner.'); + }); + + it('leaves out generated, hidden, and non-standard prompts', () => { + expect( + resolveTaskMemoryRequest({ description: 'Review this PR.' }, 'pr_review'), + ).toBeNull(); + expect( + resolveTaskMemoryRequest( + { description: 'Set up.', visibleInTranscript: false }, + 'standard', + ), + ).toBeNull(); + expect( + resolveTaskMemoryRequest( + { description: 'bootstrap go' }, + 'standard', + ), + ).toBeNull(); + expect(resolveTaskMemoryRequest({}, 'standard')).toBeNull(); + }); + + it('bounds a long request and says where the rest lives', () => { + const request = resolveTaskMemoryRequest( + { description: 'x'.repeat(2_000) }, + 'standard', + ); + + expect(request).toHaveLength( + 1_500 + '\n\n_Request truncated; open the task for the rest._'.length, + ); + expect(request?.endsWith('open the task for the rest._')).toBe(true); + }); +}); + describe('task memory pull request outcomes', () => { const base = { runId: 7, @@ -561,6 +625,7 @@ describe('task memory pull request outcomes', () => { agentSummary: 'Opened a PR with the durable approach.', initiator: { kind: 'automation' as const, automation: 'issue_fixer' }, workflow: 'standard' as const, + request: null, }; const pr = { repository: 'owner/repo', diff --git a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts index a9a72b2a53..1addab744c 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts @@ -40,6 +40,9 @@ import { type PullRequestStatus, RunStatus, type TaskWorkflow, + ACP_ENVELOPE_EVENT_TYPES, + isSystemInjectedAcpPromptText, + normalizeTranscriptUserText, brainNamespacePrefix, getLinkedEnvironmentIdFromPayload, renderBrainFrontmatter, @@ -73,6 +76,12 @@ const SUPERSEDED_TASK_MEMORY_COLLECTOR_IDS = ['task-memory:effective-date-v2']; * linked pull request later changes state. */ const LINKABLE_REPLAY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; +/** + * Bound on the request excerpt a task memory carries. The ask is usually a + * few sentences; a pasted log or spec should not dominate the page's + * embedding, and the task itself remains the place to read the rest. + */ +const TASK_REQUEST_CHAR_CAP = 1_500; const CLAIM_BATCH_SIZE = 10; // Backfill can enqueue a deployment's whole task history at once; drain up // to this many batches per tick so the backlog clears in minutes, not hours. @@ -321,6 +330,52 @@ function describePullRequestOutcome( } } +/** + * The user's own request, as the web transcript would show it: the launch + * payload's visible prompt with Roomote's surface wrappers stripped. Only + * standard-workflow tasks carry one; a review or conflict-resolution run's + * prompt is generated, not asked. Bootstrap prompts the harness injected and + * prompts the launch path marked hidden are not the user's words and are + * left out. Treated as evidence like every other ingested text, never as + * instructions. + */ +export function resolveTaskMemoryRequest( + payload: Record, + workflow: TaskWorkflow, +): string | null { + if (workflow !== 'standard') { + return null; + } + + if (payload.visibleInTranscript === false) { + return null; + } + + const raw = + typeof payload.description === 'string' + ? payload.description + : typeof payload.text === 'string' + ? payload.text + : null; + + if (!raw?.trim() || isSystemInjectedAcpPromptText(raw)) { + return null; + } + + const text = normalizeTranscriptUserText( + raw, + ACP_ENVELOPE_EVENT_TYPES.UserPrompt, + )?.trim(); + + if (!text) { + return null; + } + + return text.length > TASK_REQUEST_CHAR_CAP + ? `${text.slice(0, TASK_REQUEST_CHAR_CAP)}\n\n_Request truncated; open the task for the rest._` + : text; +} + /** * Build the memory page for a completed run. Deliberately deterministic and * conservative: only structured, known-safe fields (title, repos, PRs, @@ -401,6 +456,8 @@ export function buildMemoryPage(input: { agentSummary: string | null; initiator: TaskMemoryInitiator; workflow: TaskWorkflow; + /** Already bounded and workflow-gated; see resolveTaskMemoryRequest. */ + request: string | null; pullRequests: Array<{ repository: string | null; prNumber: number | null; @@ -456,6 +513,7 @@ export function buildMemoryPage(input: { `# ${input.taskTitle}`, '', ...(initiator.line ? [initiator.line, ''] : []), + ...(input.request ? ['## Request', '', input.request, ''] : []), // The agent that did the work writes the substance when it can; the // deterministic completion line is the floor, not the ceiling. ...(input.agentSummary @@ -785,6 +843,10 @@ async function drainOneBatch(connection: { completedAt: run.completedAt, initiator, workflow: task.workflow, + request: resolveTaskMemoryRequest( + run.payload as Record, + task.workflow, + ), pullRequests: prRows.map((pr) => ({ repository: pr.repository, prNumber: pr.prNumber, diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index fc8b21be16..bfba7dfa41 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -17,8 +17,10 @@ Roomote task. Roomote fills Memory from what it can already see: -- **completed Roomote tasks**, including a short memory the agent writes about - its own work: what it decided, why, and what is still open. When a pull +- **completed Roomote tasks**, including the request that started the task + and a short memory the agent writes about its own work: what it decided, + why, and what is still open. The request is bounded and only recorded for + tasks a person asked for, never for generated work such as reviews. When a pull request the task opened later merges or closes unmerged, the task's memory is refreshed with that outcome, so recall can tell work that shipped from work that was abandoned. Each memory also records who started the task: a linked From 658785bd06e68fe63e348bd5ef256a5fd71430a7 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:43:14 -0400 Subject: [PATCH 3/3] Read Linear-launched requests from the fields the agent prompt used A Linear agent session task is standard workflow but stores its visible request in commentBody, issueDescription, or issueTitle, so the request resolver returned null for every one of them. Match getInitialTaskPrompt's precedence. --- .../__tests__/brain-outbox-drain.test.ts | 20 ++++++++++++++++ .../src/scheduled-jobs/brain-outbox-drain.ts | 23 +++++++++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts index 5183d32b75..2efa82e8b8 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts @@ -583,6 +583,26 @@ describe('resolveTaskMemoryRequest', () => { ).toBe('Ship the banner.'); }); + it('reads a Linear-launched request the way the agent prompt did', () => { + const issue = { + issueTitle: 'Login redirect loop', + issueDescription: 'Users bounce between /login and /home.', + }; + + expect( + resolveTaskMemoryRequest( + { ...issue, commentBody: '@roomote please fix this' }, + 'standard', + ), + ).toBe('@roomote please fix this'); + expect(resolveTaskMemoryRequest(issue, 'standard')).toBe( + 'Users bounce between /login and /home.', + ); + expect( + resolveTaskMemoryRequest({ issueTitle: issue.issueTitle }, 'standard'), + ).toBe('Login redirect loop'); + }); + it('leaves out generated, hidden, and non-standard prompts', () => { expect( resolveTaskMemoryRequest({ description: 'Review this PR.' }, 'pr_review'), diff --git a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts index 1addab744c..96c3fb7991 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts @@ -351,14 +351,23 @@ export function resolveTaskMemoryRequest( return null; } + // Same precedence as the prompt the agent actually received + // (getInitialTaskPrompt): web and chat launches carry `description` or + // `text`; a Linear-launched task carries the triggering comment, else the + // issue body, else its title. const raw = - typeof payload.description === 'string' - ? payload.description - : typeof payload.text === 'string' - ? payload.text - : null; - - if (!raw?.trim() || isSystemInjectedAcpPromptText(raw)) { + [ + payload.description, + payload.text, + payload.commentBody, + payload.issueDescription, + payload.issueTitle, + ].find( + (value): value is string => + typeof value === 'string' && value.trim() !== '', + ) ?? null; + + if (!raw || isSystemInjectedAcpPromptText(raw)) { return null; }