From 77e76904f79f886299bad6c13d7446601c8234ef Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:06:07 -0700 Subject: [PATCH 1/3] feat(onboarding): an agent's first answer to the workspace owner completes "Give your agent its first task" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA walk 3 (fresh stranger, production 47ba879b): TASK-002 "Give your agent its first task" stayed pending after the hosted agent answered four asks. Nothing completed it: TASK-001 has a kernel event (a runtime token's first use, or the native provision), and TASK-002 had none. - agentAnswerService.humansAnsweredBy: the "who is this agent answering" rule from #2258, moved out of the attention writer so a second reader shares it. An agent's post answers the authors of the messages it holds a live claim on in the pod, plus the author of a message it explicitly replies to. attentionItemService now calls it, with behaviour unchanged. - starterTaskService.completeFirstTaskOnAnswer: when an agent's post lands in a pod whose "Give your agent its first task" card is still open, and that post answers the workspace owner, the card is done ("Completed automatically — answered your first request"). Scout is excluded: it is the guide, not "your agent". The open-card read comes first, so a pod with no open card costs one indexed read. It never throws. - AgentMessageService.postMessage offers each landed post to it, fire-and-forget, so a board write never delays or fails a post. A refused post offers nothing. - The two starter cards share one completion writer. Not "completed" claims: a released claim's completed outcome also covers skips (CLI no-prompt / claim-held) and silent mention runs, so it doesn't mean the agent answered. Tests: the owner answered completes the card (filter by sourceRef, open states only, task_updated emitted); someone else or nobody answered leaves it; Scout never counts; no open card stops at one read; a failing read never throws; a landed post is offered with its reply edge and a refused one is not. #2258's attention arms pass unchanged on the shared helper. Mutations: the owner check, the Scout exclusion, the open-card short-circuit, the post hook, and the helper's claims and reply halves each fail an arm. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../agentMessageService.threadWrites.test.js | 24 +++++ .../unit/services/starterTaskService.test.js | 67 ++++++++++++++ backend/services/agentAnswerService.ts | 55 +++++++++++ backend/services/agentMessageService.ts | 8 ++ backend/services/attentionItemService.ts | 36 +++----- backend/services/starterTaskService.ts | 91 +++++++++++++++---- 6 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 backend/services/agentAnswerService.ts diff --git a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js index be93789078..cec6083ab0 100644 --- a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js +++ b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js @@ -92,6 +92,10 @@ jest.mock('../../../services/threadRootResolver', () => { jest.mock('../../../services/installable/eventHandlers', () => ({ dispatch: (...args) => mockDispatch(...args), })); +const mockFirstTask = jest.fn(); +jest.mock('../../../services/starterTaskService', () => ({ + completeFirstTaskOnAnswer: (...args) => mockFirstTask(...args), +})); const POD = '6a0da39bae757028b39f87a6'; let emitted; @@ -265,6 +269,24 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => { }); }); + // QA walk 3: "Give your agent its first task" completes on the first answer + // an agent gives the workspace owner. Every landed agent post offers it to + // the starter-task check, which decides; a refused post never does. + it('offers each landed post to the first-task check, with the reply edge', async () => { + await AgentMessageService.postMessage({ + agentName: 'lee-agent', + instanceId: 'default', + podId: POD, + content: 'Done: the build is green.', + displayName: 'Lee Agent', + replyToMessageId: '57577', + }); + expect(mockFirstTask).toHaveBeenCalledTimes(1); + expect(mockFirstTask).toHaveBeenCalledWith({ + podId: POD, agentName: 'lee-agent', instanceId: 'default', replyToMessageId: '57577', agentLabel: 'Lee Agent', + }); + }); + it('a ThreadRootError propagates — never swallowed into the Mongo fallback', async () => { threadRootResolver.resolveThreadRoot.mockRejectedValue( new threadRootResolver.ThreadRootError('root is in another pod', 'thread_root_other_pod'), @@ -292,5 +314,7 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => { content: 'in-thread continuation', threadRootId: '57577', })).rejects.toThrow('threadRootId posts require the PostgreSQL message store'); + // Nothing landed, so nothing is offered to the first-task check. + expect(mockFirstTask).not.toHaveBeenCalled(); }); }); diff --git a/backend/__tests__/unit/services/starterTaskService.test.js b/backend/__tests__/unit/services/starterTaskService.test.js index 92f149ae84..bdea90257a 100644 --- a/backend/__tests__/unit/services/starterTaskService.test.js +++ b/backend/__tests__/unit/services/starterTaskService.test.js @@ -9,9 +9,15 @@ */ const mockFindOneAndUpdate = jest.fn(); +const mockTaskExists = jest.fn(); jest.mock('../../../models/Task', () => ({ findOneAndUpdate: (...args) => mockFindOneAndUpdate(...args), + exists: (...args) => mockTaskExists(...args), })); +const mockPodFindById = jest.fn(); +jest.mock('../../../models/Pod', () => ({ findById: (...args) => mockPodFindById(...args) })); +const mockAnswered = jest.fn(); +jest.mock('../../../services/agentAnswerService', () => ({ humansAnsweredBy: (...args) => mockAnswered(...args) })); const mockEmitTaskUpdated = jest.fn(); jest.mock('../../../services/taskEventService', () => ({ @@ -20,7 +26,9 @@ jest.mock('../../../services/taskEventService', () => ({ const { completeConnectAgentStarterTask, + completeFirstTaskOnAnswer, CONNECT_AGENT_SOURCE_REF, + FIRST_TASK_SOURCE_REF, } = require('../../../services/starterTaskService'); const POD_A = 'aaaaaaaaaaaaaaaaaaaaaaaa'; @@ -91,3 +99,62 @@ describe('completeConnectAgentStarterTask', () => { expect(mockEmitTaskUpdated).not.toHaveBeenCalled(); }); }); + +// QA walk 3 (2026-10-16): "Give your agent its first task" stayed pending after +// four answered asks. It completes on the first answer an agent gives the +// workspace owner; who was answered is agentAnswerService's rule. +describe('completeFirstTaskOnAnswer', () => { + const OWNER = 'cccccccccccccccccccccccc'; + const answer = (extra = {}) => completeFirstTaskOnAnswer({ + podId: POD_A, agentName: 'lee-agent', instanceId: 'default', agentLabel: 'lee-agent', ...extra, + }); + + beforeEach(() => { + mockTaskExists.mockReset().mockResolvedValue({ _id: 't2' }); + mockPodFindById.mockReset().mockReturnValue({ select: () => ({ lean: async () => ({ createdBy: OWNER }) }) }); + mockAnswered.mockReset().mockResolvedValue(new Set([OWNER])); + mockFindOneAndUpdate.mockResolvedValue({ taskId: 'TASK-002', status: 'done' }); + }); + + test("the agent's first answer to the workspace owner completes the card", async () => { + await answer({ replyToMessageId: 77 }); + expect(mockAnswered).toHaveBeenCalledWith({ + podId: POD_A, agentName: 'lee-agent', instanceId: 'default', replyToMessageId: 77, + }); + const [filter, update] = mockFindOneAndUpdate.mock.calls[0]; + expect(String(filter.podId)).toBe(POD_A); + expect(filter.sourceRef).toBe(FIRST_TASK_SOURCE_REF); + expect(filter.status).toEqual({ $in: ['pending', 'claimed'] }); + expect(update.$push.updates.text).toContain('lee-agent answered your first request'); + expect(mockEmitTaskUpdated).toHaveBeenCalledWith(POD_A, expect.objectContaining({ taskId: 'TASK-002' }), 'updated'); + }); + + test('a post that answers someone else, or nobody, leaves it open', async () => { + mockAnswered.mockResolvedValue(new Set(['dddddddddddddddddddddddd'])); + await answer(); + mockAnswered.mockResolvedValue(new Set()); + await answer(); + expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); + }); + + test('Scout answering is the guide, not "your agent"', async () => { + await answer({ agentName: 'Scout' }); + expect(mockTaskExists).not.toHaveBeenCalled(); + expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); + }); + + test('with no open card it stops at one read, before any answer check', async () => { + mockTaskExists.mockResolvedValue(null); + await answer(); + expect(mockTaskExists).toHaveBeenCalledWith(expect.objectContaining({ sourceRef: FIRST_TASK_SOURCE_REF })); + expect(mockAnswered).not.toHaveBeenCalled(); + expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); + }); + + test('never throws: a failing board read is logged and swallowed', async () => { + mockTaskExists.mockRejectedValue(new Error('mongo down')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(answer()).resolves.toBeUndefined(); + warn.mockRestore(); + }); +}); diff --git a/backend/services/agentAnswerService.ts b/backend/services/agentAnswerService.ts new file mode 100644 index 0000000000..cd840745e0 --- /dev/null +++ b/backend/services/agentAnswerService.ts @@ -0,0 +1,55 @@ +/** + * agentAnswerService: who is an agent's post ANSWERING? + * + * One rule, read by two writers (stickiness Phase 1b, ux-lead's ruling in + * Sharpen 78222-78224, 2026-10-16): + * - attention: an answer to the person who asked opens no needs-you row + * (attentionItemService); + * - onboarding: the first answer an agent gives the workspace owner completes + * "Give your agent its first task" (starterTaskService). + * + * An agent's post answers the authors of the messages it holds a LIVE claim on + * in that pod (a native turn claims the message that woke it and carries no + * reply edge), plus the author of a message it explicitly replies to. Reply + * edges are never added to make this fire: that would thread every answer. + * + * Callers decide what the answer means; this only names who was answered. Any + * failure answers nobody, so every caller falls back to its old behaviour. + */ +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const PGMessage = require('../models/pg/Message'); + +export const humansAnsweredBy = async ({ + podId, agentName, instanceId, replyToMessageId, +}: { + podId: unknown; + agentName: string; + instanceId?: string; + replyToMessageId?: unknown; +}): Promise> => { + const answered = new Set(); + if (!podId || !agentName) return answered; + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const MessageClaimService = require('./messageClaimService'); + const claimed: string[] = await MessageClaimService.liveClaimsHeldBy({ + podId: String(podId), agentName, instanceId: instanceId || 'default', + }); + const sourceIds = [...new Set([...claimed, ...(replyToMessageId ? [String(replyToMessageId)] : [])])] + .filter((id) => /^\d{1,10}$/.test(id)); + const sources = await Promise.all(sourceIds.map((id) => PGMessage.findById(id))); + sources.forEach((source: any) => { + const author = source?.userId?._id ?? source?.userId; + if (author) answered.add(String(author)); + }); + } catch (error) { + // Nothing is added before the last await, so a failure leaves the set empty. + console.warn('[agent-answer] answer check failed; treating as answering nobody:', (error as Error).message); + } + return answered; +}; + +export default { humansAnsweredBy }; +// CJS compat: let require() return the default export directly +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports.default; Object.assign(module.exports, exports); diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index d94045b47a..8ef88c5b86 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -1570,6 +1570,14 @@ class AgentMessageService { noOutboundRelay, }); + // The first answer an agent gives the workspace owner completes "Give your + // agent its first task" (starterTaskService). Fire-and-forget: a board + // write never delays or fails a post, and it never throws. + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + void require('./starterTaskService').completeFirstTaskOnAnswer({ + podId: String(podId), agentName, instanceId, replyToMessageId, agentLabel: displayName || agentName, + }); + return { success: true, message: posted.message, diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index 4d271e6b71..b0efcdbcee 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -14,6 +14,8 @@ const PGMessage = require('../models/pg/Message'); const Task = require('../models/Task'); // eslint-disable-next-line global-require const { ESCALATION_MARKERS } = require('./connectorRelayPolicy'); +// eslint-disable-next-line global-require +const { humansAnsweredBy } = require('./agentAnswerService'); type SourceType = 'message' | 'approval' | 'approval_action' | 'decision_request' | 'task'; type Kind = 'mention' | 'approval' | 'decision' | 'handoff'; @@ -87,12 +89,11 @@ const resolveAuthorName = async (authorId: unknown): Promise => { * the message and its @mention stay. Before this, almost every agent-mention * row was bulk-acknowledged or left open, and well under 1% was answered. * - * An answer is a post by an agent that holds a live claim on a message that - * person wrote in this pod (native turns claim the message that woke them and - * carry no reply edge), or that explicitly replies to one. Asks still reach - * them: decisions and approvals have their own writers, and an answer that - * carries an escalation marker ([BLOCKED] ...) is not treated as an answer. - * Any failure reads as "answering nobody": an extra row beats a lost ask. + * Who counts as answered is agentAnswerService's rule, shared with the + * onboarding checklist. Asks still reach them: decisions and approvals have + * their own writers, and an answer that carries an escalation marker + * ([BLOCKED] ...) is not treated as an answer. Any failure reads as + * "answering nobody": an extra row beats a lost ask. */ const peopleThisAgentIsAnswering = async ( message: any, @@ -100,31 +101,20 @@ const peopleThisAgentIsAnswering = async ( content: string, podId: unknown, ): Promise> => { - const answered = new Set(); - if (!authorId || ESCALATION_MARKERS.test(content)) return answered; + if (!authorId || ESCALATION_MARKERS.test(content)) return new Set(); try { const author = await User.findById(authorId).select('isBot username botMetadata').lean(); - if (!author?.isBot) return answered; - // eslint-disable-next-line global-require - const MessageClaimService = require('./messageClaimService'); - const claimed: string[] = await MessageClaimService.liveClaimsHeldBy({ - podId: String(podId), + if (!author?.isBot) return new Set(); + return await humansAnsweredBy({ + podId, agentName: String(author.botMetadata?.agentName || author.username || ''), instanceId: String(author.botMetadata?.instanceId || 'default'), - }); - const replyTo = message?.reply_to_message_id ?? message?.replyToMessageId; - const sourceIds = [...new Set([...claimed, ...(replyTo ? [String(replyTo)] : [])])] - .filter((id) => /^\d{1,10}$/.test(id)); - const sources = await Promise.all(sourceIds.map((id) => PGMessage.findById(id))); - sources.forEach((source: any) => { - const sourceAuthor = source?.userId?._id ?? source?.userId; - if (sourceAuthor) answered.add(String(sourceAuthor)); + replyToMessageId: message?.reply_to_message_id ?? message?.replyToMessageId, }); } catch (error) { - // Nothing is added before the last await, so a failure leaves the set empty. console.warn('[attention] answer check failed; recording the mention:', (error as Error).message); + return new Set(); } - return answered; }; export const recordMentionedUsers = async (message: any, options: MentionOptions = {}): Promise => { diff --git a/backend/services/starterTaskService.ts b/backend/services/starterTaskService.ts index a2bc2c86e3..8b3be2729a 100644 --- a/backend/services/starterTaskService.ts +++ b/backend/services/starterTaskService.ts @@ -14,14 +14,44 @@ * hosted provision (routes/hosted.ts), where no token exists to be first-used * and the agent is live once provisioned. Never throws; a board write must not * be able to fail an auth or provision request. + * + * TASK-002 "Give your agent its first task" completes on the first ANSWER an + * agent gives the workspace owner (QA walk 3, 2026-10-16: it stayed pending + * after four answered asks). Who was answered is agentAnswerService's rule, + * the same one attention uses. Called fire-and-forget from + * AgentMessageService.postMessage after a post lands; same contract. */ import mongoose from 'mongoose'; import Task from '../models/Task'; +import { humansAnsweredBy } from './agentAnswerService'; + +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const Pod = require('../models/Pod'); // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { emitTaskUpdated } = require('./taskEventService'); export const CONNECT_AGENT_SOURCE_REF = 'onboarding:connect-agent'; +export const FIRST_TASK_SOURCE_REF = 'onboarding:first-task'; + +const OPEN = { $in: ['pending', 'claimed'] }; + +/** Marks one pod's starter card done with a system update. Idempotent: only an open card matches. */ +const completeStarterTask = async (podId: string, sourceRef: string, text: string): Promise => { + const task = await Task.findOneAndUpdate( + { podId: mongoose.Types.ObjectId.createFromHexString(podId), sourceRef, status: OPEN }, + { + $set: { status: 'done', completedAt: new Date() }, + $push: { + updates: { + text, author: 'system', authorId: null, createdAt: new Date(), + }, + }, + }, + { new: true }, + ); + if (task) emitTaskUpdated(podId, task, 'updated'); +}; export const completeConnectAgentStarterTask = async ({ podIds, @@ -33,26 +63,9 @@ export const completeConnectAgentStarterTask = async ({ const uniquePodIds = Array.from(new Set((podIds || []).filter(Boolean).map(String))); for (const podId of uniquePodIds) { try { - const task = await Task.findOneAndUpdate( - { - podId: mongoose.Types.ObjectId.createFromHexString(podId), - sourceRef: CONNECT_AGENT_SOURCE_REF, - status: { $in: ['pending', 'claimed'] }, - }, - { - $set: { status: 'done', completedAt: new Date() }, - $push: { - updates: { - text: `Completed automatically — ${agentLabel} connected and is listening`, - author: 'system', - authorId: null, - createdAt: new Date(), - }, - }, - }, - { new: true }, + await completeStarterTask( + podId, CONNECT_AGENT_SOURCE_REF, `Completed automatically — ${agentLabel} connected and is listening`, ); - if (task) emitTaskUpdated(podId, task, 'updated'); } catch (err) { console.warn( `[starter-task] connect-agent auto-complete failed for pod ${podId}:`, @@ -62,7 +75,45 @@ export const completeConnectAgentStarterTask = async ({ } }; -export default { CONNECT_AGENT_SOURCE_REF, completeConnectAgentStarterTask }; +/** + * An agent's post just landed in podId. If the pod's "Give your agent its first + * task" card is still open and the post answers the workspace owner, the card + * is done. Scout is excluded: it is the guide, not "your agent". The open-card + * check comes first, so a pod with no open card costs one indexed read. + */ +export const completeFirstTaskOnAnswer = async ({ + podId, agentName, instanceId, replyToMessageId, agentLabel, +}: { + podId: string; + agentName: string; + instanceId?: string; + replyToMessageId?: unknown; + agentLabel: string; +}): Promise => { + try { + if (!podId || !agentName || String(agentName).toLowerCase() === 'scout') return; + const open = await Task.exists({ + podId: mongoose.Types.ObjectId.createFromHexString(String(podId)), sourceRef: FIRST_TASK_SOURCE_REF, status: OPEN, + }); + if (!open) return; + const pod = await Pod.findById(podId).select('createdBy').lean(); + const owner = pod?.createdBy ? String(pod.createdBy) : ''; + if (!owner) return; + const answered = await humansAnsweredBy({ + podId, agentName, instanceId, replyToMessageId, + }); + if (!answered.has(owner)) return; + await completeStarterTask( + String(podId), FIRST_TASK_SOURCE_REF, `Completed automatically — ${agentLabel} answered your first request`, + ); + } catch (err) { + console.warn(`[starter-task] first-task auto-complete failed for pod ${podId}:`, (err as Error).message); + } +}; + +export default { + CONNECT_AGENT_SOURCE_REF, FIRST_TASK_SOURCE_REF, completeConnectAgentStarterTask, completeFirstTaskOnAnswer, +}; // CJS compat: let require() return the default export directly // eslint-disable-next-line @typescript-eslint/no-require-imports module.exports = exports["default"]; Object.assign(module.exports, exports); From ee012fac1b6fc91e7298392b1d000de32142731f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:11:55 -0700 Subject: [PATCH 2/3] fix(onboarding): an answer that says it is blocked doesn't complete "Give your agent its first task" Sprint Review on #2260 (review 5542972145): ESCALATION_MARKERS reached only the attention caller. A first answer like "[BLOCKED] @owner I need the staging key", holding a live claim on the owner's message, kept the needs-you row (correct: it's still an ask) but also completed the card with "answered your first request". The marker means the task isn't done, so completeFirstTaskOnAnswer now takes the post's content and stops on it. This is a caller-side check, the same as attention's, kept out of the shared answer rule because the two callers read the marker for different reasons. postMessage passes the sanitized content it stored. Test: a [BLOCKED] answer leaves the card open; dropping the check fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- .../agentMessageService.threadWrites.test.js | 1 + .../unit/services/starterTaskService.test.js | 7 +++++++ backend/services/agentMessageService.ts | 3 ++- backend/services/starterTaskService.ts | 12 ++++++++++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js index cec6083ab0..63abf97ed1 100644 --- a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js +++ b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js @@ -284,6 +284,7 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => { expect(mockFirstTask).toHaveBeenCalledTimes(1); expect(mockFirstTask).toHaveBeenCalledWith({ podId: POD, agentName: 'lee-agent', instanceId: 'default', replyToMessageId: '57577', agentLabel: 'Lee Agent', + content: 'Done: the build is green.', }); }); diff --git a/backend/__tests__/unit/services/starterTaskService.test.js b/backend/__tests__/unit/services/starterTaskService.test.js index bdea90257a..e95c43accd 100644 --- a/backend/__tests__/unit/services/starterTaskService.test.js +++ b/backend/__tests__/unit/services/starterTaskService.test.js @@ -137,6 +137,13 @@ describe('completeFirstTaskOnAnswer', () => { expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); }); + // Sprint Review on #2260: attention keeps a [BLOCKED] answer as an ask, so the + // checklist must not call the first task done at that moment either. + test('an answer that says it is blocked does not complete the card', async () => { + await answer({ content: '[BLOCKED] I need the staging key to go on.' }); + expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); + }); + test('Scout answering is the guide, not "your agent"', async () => { await answer({ agentName: 'Scout' }); expect(mockTaskExists).not.toHaveBeenCalled(); diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index 8ef88c5b86..48a8ba4557 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -1575,7 +1575,8 @@ class AgentMessageService { // write never delays or fails a post, and it never throws. // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports void require('./starterTaskService').completeFirstTaskOnAnswer({ - podId: String(podId), agentName, instanceId, replyToMessageId, agentLabel: displayName || agentName, + podId: String(podId), agentName, instanceId, replyToMessageId, + agentLabel: displayName || agentName, content: sanitizedContent, }); return { diff --git a/backend/services/starterTaskService.ts b/backend/services/starterTaskService.ts index 8b3be2729a..cfa5297eb2 100644 --- a/backend/services/starterTaskService.ts +++ b/backend/services/starterTaskService.ts @@ -25,6 +25,9 @@ import mongoose from 'mongoose'; import Task from '../models/Task'; import { humansAnsweredBy } from './agentAnswerService'; +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const { ESCALATION_MARKERS } = require('./connectorRelayPolicy'); + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const Pod = require('../models/Pod'); @@ -78,20 +81,25 @@ export const completeConnectAgentStarterTask = async ({ /** * An agent's post just landed in podId. If the pod's "Give your agent its first * task" card is still open and the post answers the workspace owner, the card - * is done. Scout is excluded: it is the guide, not "your agent". The open-card + * is done, unless the post carries an escalation marker. Scout is excluded: it + * is the guide, not "your agent". The open-card * check comes first, so a pod with no open card costs one indexed read. */ export const completeFirstTaskOnAnswer = async ({ - podId, agentName, instanceId, replyToMessageId, agentLabel, + podId, agentName, instanceId, replyToMessageId, agentLabel, content = '', }: { podId: string; agentName: string; instanceId?: string; replyToMessageId?: unknown; agentLabel: string; + content?: string; }): Promise => { try { if (!podId || !agentName || String(agentName).toLowerCase() === 'scout') return; + // An answer that says it is blocked ([BLOCKED], [DECISION] ...) has not done + // the task yet. Attention reads the same marker the other way: still an ask. + if (ESCALATION_MARKERS.test(String(content))) return; const open = await Task.exists({ podId: mongoose.Types.ObjectId.createFromHexString(String(podId)), sourceRef: FIRST_TASK_SOURCE_REF, status: OPEN, }); From ca36f71c12f1aebef60f0866694bff507db94226 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:00:59 -0700 Subject: [PATCH 3/3] fix(onboarding): the first-task warn uses a constant format string (CodeQL js/tainted-format-string) CodeQL on #2260 (check 116807142321, 1 high): starterTaskService's new catch put podId in console.warn's format-string position, and podId reaches this path from a request (postMessage). A podId carrying %s or %o could reshape the log line. The format string is now constant and podId is an argument. The connect-card warn beside it is unchanged: CodeQL doesn't flag it, because its pod ids come from installations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya --- backend/services/starterTaskService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/services/starterTaskService.ts b/backend/services/starterTaskService.ts index cfa5297eb2..8a4b6bfaa4 100644 --- a/backend/services/starterTaskService.ts +++ b/backend/services/starterTaskService.ts @@ -115,7 +115,8 @@ export const completeFirstTaskOnAnswer = async ({ String(podId), FIRST_TASK_SOURCE_REF, `Completed automatically — ${agentLabel} answered your first request`, ); } catch (err) { - console.warn(`[starter-task] first-task auto-complete failed for pod ${podId}:`, (err as Error).message); + // Constant format string: podId reaches here from a request (CodeQL js/tainted-format-string). + console.warn('[starter-task] first-task auto-complete failed for pod %s:', String(podId), (err as Error).message); } };