Skip to content
Closed
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
2 changes: 1 addition & 1 deletion services/platform/backend/MIGRATION.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions services/platform/backend/core/tasks/mentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,9 @@ describe('addedMentions', () => {
expect(addedMentions([], next)).toEqual(next);
});

// Comment edits reuse the same helper as description edits
// (`editTaskDiscussionMessage` → `addedMentions` → `comment.mentioned`).
// The comment-edit wire: `editTaskComment` re-resolves the body, diffs it
// through this helper, and fans out `comment.mentioned` for the added ones
// alone (`domains/tasks/comments.ts`).
it('flags an @agent added when editing a comment that had none', () => {
const previous = extractMentions('already fixed', directory);
const next = extractMentions(
Expand Down
86 changes: 81 additions & 5 deletions services/platform/backend/domains/tasks/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { z } from 'zod';

import { parseTaskSubjectContract } from '../../../lib/shared/schemas/task_contract.ts';
import { TASK_AUDIT_ACTIONS } from '../../core/tasks/audit_actions.ts';
import {
addedMentions,
type ResolvedMention,
} from '../../core/tasks/mentions.ts';
import type { CommentEventComment } from '../../core/tasks/types.ts';
import { toJson } from '../../db/sql.ts';
import { addJobInTx } from '../../jobs/enqueue.ts';
Expand Down Expand Up @@ -147,6 +151,7 @@ export async function addTaskComment(
auth,
task,
mentions,
authorType: author.actorType,
});
// A comment that @-mentions one of the project's agent INSTANCES puts it
// to work: steering its RUNNING turn, or — when the task is idle —
Expand Down Expand Up @@ -345,12 +350,22 @@ export async function listTaskComments(
async function loadCommentMeta(
tx: TransactionSql | Sql,
messageId: string,
): Promise<{ taskId: string; authorType: string; authorId: string }> {
): Promise<{
taskId: string;
authorType: string;
authorId: string;
mentions: ResolvedMention[] | null;
}> {
const rows = await tx<
{ taskId: string; authorType: string; authorId: string }[]
{
taskId: string;
authorType: string;
authorId: string;
mentions: ResolvedMention[] | null;
}[]
>`
SELECT task_id AS "taskId", author_type AS "authorType",
author_id AS "authorId"
author_id AS "authorId", mentions
FROM app.task_discussion_message_meta WHERE message_id = ${messageId}
`;
const meta = rows[0];
Expand All @@ -375,6 +390,23 @@ function assertCommentOwnerOrAdmin(
}
}

/**
* Edit one comment's body — and RE-RESOLVE what it names.
*
* An edit is a second chance to mention someone: adding `@handle` to a
* comment has to reach them, and the stored mention set has to stay the
* truth of who the comment names (it is what the feed renders and what the
* next edit diffs against). Only the NEWLY added mentions fan out —
* rewording prose around an existing `@handle` must not re-notify, and
* re-notifying the whole set on every edit is exactly what the 0.4
* `addedMentions` diff existed to prevent.
*
* The fan-out is the MENTION half of {@link addTaskComment} only: the bell
* and auto-subscribe for the newly named, no subscriber re-alert (this is
* not a fresh comment), and `comment.mentioned` rather than
* `comment.created`. Editing never starts an engine — the automation
* trigger and the agent dispatch belong to a posted comment.
*/
export async function editTaskComment(
tx: TransactionSql,
auth: ProjectAuthContext,
Expand All @@ -389,12 +421,47 @@ export async function editTaskComment(
if (body.length === 0 || body.length > TASK_COMMENT_MAX) {
throw new TaskError('TASK_COMMENT_INVALID', 'Invalid comment body');
}
const resolved = await resolveSurfaceMentions(tx, {
organizationId: auth.organizationId,
body,
projectId: task.projectId,
});
const mentions = resolved.mentions;
const added = addedMentions(meta.mentions ?? [], mentions);
await updateMessageText(tx, args.messageId, body);
await tx`
UPDATE app.task_discussion_message_meta
SET edited_at_ms = ${Date.now()}
SET edited_at_ms = ${Date.now()},
mentions = ${mentions.length > 0 ? tx.json(toJson(mentions)) : null}
WHERE message_id = ${args.messageId}
`;
if (added.length > 0) {
await notifyTaskComment(tx, {
task,
commentId: args.messageId,
mentions: added,
actorType: 'user',
actorId: auth.userId,
notifySubscribers: false,
});
const comment: CommentEventComment = {
body,
projectId: task.projectId,
taskId: meta.taskId,
mentions: added,
};
await emitEvent(tx, {
organizationId: auth.organizationId,
eventType: 'comment.mentioned',
eventData: {
comment,
taskId: meta.taskId,
mentions: added,
actorType: 'user',
actorId: auth.userId,
},
});
}
await createAuditLog(tx, {
organizationId: auth.organizationId,
actorId: auth.userId,
Expand All @@ -405,7 +472,7 @@ export async function editTaskComment(
resourceType: 'task_comment',
resourceId: args.messageId,
resourceName: task.title,
metadata: { taskId: meta.taskId },
metadata: { taskId: meta.taskId, addedMentionCount: added.length },
status: 'success',
});
await emitHintInTx(tx, {
Expand Down Expand Up @@ -683,6 +750,13 @@ async function dispatchMentionedProjectAgent(
* run keeps it; `startWorkflowForTask`'s own duplicate guard backstops the
* pre-check.
*
* Only a HUMAN's comment starts anything — the same rule the agent lane's
* dispatcher keeps. An agent- or workflow-authored comment naming the owning
* automation would restart the very engine that wrote it, and each iteration
* is a metered agent turn; `startWorkflowForTask`'s one-live-run-per-task
* guard blocks a concurrent second start, not a sequential loop, so the
* author type is the only thing standing between a comment and that loop.
*
* Returns whether a start was scheduled, so the caller can skip the steer
* lane for the same comment.
*/
Expand All @@ -692,8 +766,10 @@ async function maybeTriggerOwningAutomation(
auth: ProjectAuthContext;
task: TaskRow;
mentions: { type: string; id: string }[];
authorType: string;
},
): Promise<boolean> {
if (args.authorType !== 'user') return false;
const mentioned = args.mentions.find(
(mention) => mention.type === 'automation',
);
Expand Down
91 changes: 90 additions & 1 deletion services/platform/backend/integration-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12149,13 +12149,51 @@ async function checkRunProvenance(
SELECT count(*)::text AS count FROM app.automation_runs
WHERE org_id = ${orgId} AND name = ${automationName}
`;
// An AGENT- or WORKFLOW-authored comment naming the OWNER starts nothing.
// Otherwise the automation restarts the engine that wrote the comment and
// every iteration is a metered agent turn; `startWorkflowForTask`'s
// one-live-run guard blocks a concurrent second start, not a sequential
// loop. 0.4 kept the trigger on the USER comment path alone.
const startJobsBeforeAgent = await sql<{ count: string }[]>`
SELECT count(*)::text AS count FROM pgboss.job
WHERE name = 'task.start_workflow'
`;
await sql.begin((tx) =>
addComment(tx, commentAuth, {
taskId: ownedTaskId,
body: `@${automationName} continuing my own work`,
author: { actorType: 'agent', actorId: 'workflow' },
}),
);
const startJobsAfterAgent = await sql<{ count: string }[]>`
SELECT count(*)::text AS count FROM pgboss.job
WHERE name = 'task.start_workflow'
`;
const runsAfterAgentAuthor = await sql<{ count: string }[]>`
SELECT count(*)::text AS count FROM app.automation_runs
WHERE org_id = ${orgId} AND name = ${automationName}
`;
// @-ing the OWNER starts its task workflow.
await sql.begin((tx) =>
addComment(tx, commentAuth, {
taskId: ownedTaskId,
body: `@${automationName} please pick this up`,
}),
);
// The same seam, the human lane: the start job IS enqueued. Read before
// the poll below so the count cannot be confused with a worker's doing.
const startJobsAfterUser = await sql<{ count: string }[]>`
SELECT count(*)::text AS count FROM pgboss.job
WHERE name = 'task.start_workflow'
`;
record(
'automations: an agent-authored @owner comment never starts the workflow',
startJobsAfterAgent[0]?.count === startJobsBeforeAgent[0]?.count &&
runsAfterAgentAuthor[0]?.count === runsBefore[0]?.count &&
Number(startJobsAfterUser[0]?.count ?? '0') ===
Number(startJobsBeforeAgent[0]?.count ?? '0') + 1,
`agentAuthored: startJobs=${startJobsAfterAgent[0]?.count} (unchanged from ${startJobsBeforeAgent[0]?.count}), runs=${runsAfterAgentAuthor[0]?.count} (want ${runsBefore[0]?.count}); humanAuthored: startJobs=${startJobsAfterUser[0]?.count} (want ${Number(startJobsBeforeAgent[0]?.count ?? '0') + 1})`,
);
// The start is ENQUEUED (the comment commits first); give the worker a
// moment, then assert the run itself.
let startedRuns: { id: string; input: unknown }[] = [];
Expand Down Expand Up @@ -13166,7 +13204,10 @@ async function checkCollabMentions(
`;
const taskId = taskRows[0]?.id ?? '';
const commented = z
.object({ unresolvedMentionTokens: z.array(z.string()) })
.object({
messageId: z.string(),
unresolvedMentionTokens: z.array(z.string()),
})
.loose()
.safeParse(
await (
Expand All @@ -13193,6 +13234,54 @@ async function checkCollabMentions(
`unresolved=${commented.success ? commented.data.unresolvedMentionTokens.join(',') : 'ERR'}, mentionBells=${bell[0]?.count}, autoSubscribed=${subscription[0]?.count}`,
);

// ---- editing a comment RE-RESOLVES its mentions -----------------------
// An edit is a second chance to name someone: the newly added mention has
// to reach them and the stored set has to catch up, while a mention that
// was already there must NOT fire again — mention rows never coalesce, so
// a re-notification would show up as a second bell.
const editedMessageId = commented.success ? commented.data.messageId : '';
const editStatus = (
await api(`/api/app/tasks/comments/${editedMessageId}`, {
body: {
body: '@mention-teammate-1 can you review? cc @mention-outsider-1',
},
})
).status;
const teammateBellsAfterEdit = await sql<{ count: string }[]>`
SELECT count(*)::text AS count FROM app.user_notifications
WHERE org_id = ${orgId} AND user_id = ${teammate} AND type = 'mention'
`;
const outsiderBellsAfterEdit = await sql<{ count: string }[]>`
SELECT count(*)::text AS count FROM app.user_notifications
WHERE org_id = ${orgId} AND user_id = ${outsider} AND type = 'mention'
AND resource_id = ${editedMessageId}
`;
const editedMeta = await sql<
{
mentions: { type: string; id: string }[] | null;
editedAt: number | null;
}[]
>`
SELECT mentions, edited_at_ms::float8 AS "editedAt"
FROM app.task_discussion_message_meta
WHERE message_id = ${editedMessageId}
`;
const storedMentionKeys = (editedMeta[0]?.mentions ?? []).map(
(mention) => `${mention.type}:${mention.id}`,
);
record(
'mentions: editing a comment notifies the ADDED mention only',
editStatus === 200 &&
Number(outsiderBellsAfterEdit[0]?.count ?? '0') === 1 &&
// The teammate was already named before the edit — no second bell.
teammateBellsAfterEdit[0]?.count === bell[0]?.count &&
// The stored set is the FULL truth of who the comment now names.
storedMentionKeys.includes(`user:${teammate}`) &&
storedMentionKeys.includes(`user:${outsider}`) &&
editedMeta[0]?.editedAt !== null,
`edit=${editStatus}, addedBell=${outsiderBellsAfterEdit[0]?.count} (want 1), alreadyMentionedBells=${teammateBellsAfterEdit[0]?.count} (unchanged from ${bell[0]?.count}), stored=${storedMentionKeys.join(',') || 'none'}, editedAt=${editedMeta[0]?.editedAt !== null}`,
);

// ---- attention summary ------------------------------------------------
await sql`
UPDATE app.tasks SET assignee_type = 'user', assignee_id = ${userId}
Expand Down
Loading