diff --git a/.changeset/quiet-owner-handoff.md b/.changeset/quiet-owner-handoff.md
new file mode 100644
index 00000000000..4f01419cd3a
--- /dev/null
+++ b/.changeset/quiet-owner-handoff.md
@@ -0,0 +1,6 @@
+---
+'@nocobase/studio': patch
+'@nocobase/app-plugin-projects': patch
+---
+
+Pause agent work after an owner transfer until the new owner accepts. Add owner acceptance and rejection in issue pages and inbox decisions, with transfer activities and localized waiting reasons.
diff --git a/packages/apps/studio/client/follow-ups/issue-panel.tsx b/packages/apps/studio/client/follow-ups/issue-panel.tsx
index 5357adfab5c..cb0bc17bc05 100644
--- a/packages/apps/studio/client/follow-ups/issue-panel.tsx
+++ b/packages/apps/studio/client/follow-ups/issue-panel.tsx
@@ -40,7 +40,10 @@ import {
} from '../../shared/project-manager.js';
import { useIssuePullRequests, usePullRequestMark } from '../git/api.js';
import { PR_ACTIVITY_LABELS } from '../git/lib.js';
-import { actionLabel } from '../issues/detail/activity-labels.js';
+import {
+ actionLabel,
+ isOwnerHandoffActivity,
+} from '../issues/detail/activity-labels.js';
import {
issueProgressKey,
progressSentence,
@@ -526,7 +529,9 @@ function describe(
const activity = event.activity;
if (!activity) return { what: '', detail: null };
return {
- what: projects(actionLabel(activity.action, activity.details), {
+ what: (isOwnerHandoffActivity(activity.action, activity.details)
+ ? t
+ : projects)(actionLabel(activity.action, activity.details), {
name: text(activity.details.name) ?? projects('common.unknown'),
}),
detail: null,
diff --git a/packages/apps/studio/client/inbox/contributions/projects-parts.tsx b/packages/apps/studio/client/inbox/contributions/projects-parts.tsx
index f3402e71e23..c483705af0b 100644
--- a/packages/apps/studio/client/inbox/contributions/projects-parts.tsx
+++ b/packages/apps/studio/client/inbox/contributions/projects-parts.tsx
@@ -8,6 +8,7 @@ import { Link } from 'react-router';
import { Skeleton } from '@/components/ui/skeleton';
import { RecentActivity } from './recent-activity.js';
+import { OwnerHandoffActions } from '../../issues/detail/owner-handoff.js';
import type { InboxPartProps } from '@/extensions/nocobase-inbox/registry';
import type { ProjectsModel } from './projects.js';
import { useParamsOf } from './projects-wording.js';
@@ -30,6 +31,12 @@ export function ProjectsBody({
{params.excerpt}
) : null}
+ {entry.notice?.type === 'owner_handoff' && issueId ? (
+
+ ) : null}
{entry.notice?.type === 'comment_runs_limited' && issueId ? (
> = {
+ owner_handoff: UserCheckIcon,
+ owner_handoff_rejected: CircleXIcon,
approval_requested: ShieldCheckIcon,
approval_decided: ShieldCheckIcon,
approval_stale: ShieldAlertIcon,
diff --git a/packages/apps/studio/client/issues/detail/activity-labels.ts b/packages/apps/studio/client/issues/detail/activity-labels.ts
index 47aaa5d3207..308fa6d6b1c 100644
--- a/packages/apps/studio/client/issues/detail/activity-labels.ts
+++ b/packages/apps/studio/client/issues/detail/activity-labels.ts
@@ -6,6 +6,9 @@ import type { Activity } from '@nocobase/app-plugin-projects/shared/issues';
/** The server's activity actions and the keys of their wording. */
const ACTION_LABELS: Readonly> = {
+ studio_owner_transferred: 'ownerTransferred',
+ studio_owner_accepted: 'ownerAccepted',
+ studio_owner_rejected: 'ownerRejected',
issue_created: 'created',
issue_deleted: 'deleted',
issue_restored: 'restored',
@@ -61,10 +64,23 @@ export function actionLabel(
key = 'stageActionBlocked';
else if (action === 'work_skipped')
key =
- reason === 'dormant'
- ? 'workDormant'
- : reason === 'limited'
- ? 'workLimited'
- : 'workBlocked';
+ reason === 'waitingOwner'
+ ? 'waitingOwner'
+ : reason === 'dormant'
+ ? 'workDormant'
+ : reason === 'limited'
+ ? 'workLimited'
+ : 'workBlocked';
return `activity.actions.${key}`;
}
+
+/** Handoff activities use Studio's wording rather than the projects plugin's. */
+export function isOwnerHandoffActivity(
+ action: string,
+ details: Activity['details'],
+): boolean {
+ return (
+ action.startsWith('studio_owner_') ||
+ (action === 'work_skipped' && details.reason === 'waitingOwner')
+ );
+}
diff --git a/packages/apps/studio/client/issues/detail/activity-row.tsx b/packages/apps/studio/client/issues/detail/activity-row.tsx
index 9d2ed39c653..0de9179677d 100644
--- a/packages/apps/studio/client/issues/detail/activity-row.tsx
+++ b/packages/apps/studio/client/issues/detail/activity-row.tsx
@@ -25,7 +25,7 @@ import {
import { PR_ACTIVITY_LABELS } from '../../git/lib.js';
import type { Translate } from './labels.js';
-import { actionLabel } from './activity-labels.js';
+import { actionLabel, isOwnerHandoffActivity } from './activity-labels.js';
import { usePlanLinkTo } from './plan-links.js';
import { StatusBadge } from './status-badge.js';
@@ -254,7 +254,9 @@ export function ActivityRow({
text(activity.details.name) ??
t('common.unknown'),
})
- : t(actionLabel(activity.action, activity.details), {
+ : (isOwnerHandoffActivity(activity.action, activity.details)
+ ? studioT
+ : t)(actionLabel(activity.action, activity.details), {
name: text(activity.details.name) ?? t('common.unknown'),
})}
diff --git a/packages/apps/studio/client/issues/detail/issue-progress.tsx b/packages/apps/studio/client/issues/detail/issue-progress.tsx
index 191a1bd0162..3c733ecc513 100644
--- a/packages/apps/studio/client/issues/detail/issue-progress.tsx
+++ b/packages/apps/studio/client/issues/detail/issue-progress.tsx
@@ -21,6 +21,7 @@ import { Spinner } from '@/components/ui/spinner';
import type { IssueProgress } from '../../../shared/project-manager.js';
import { useNotify } from '../../access/notify.js';
import { issueProgressKey, progressSentence } from './progress-text.js';
+import { OwnerHandoffActions } from './owner-handoff.js';
/** Refetches on its own too: a pull request's checks change with no issue change. */
const PROGRESS_POLL_MS = 15_000;
@@ -74,7 +75,10 @@ export function IssueProgressLine({
});
const data = progress.data;
- const canStart = canEdit && issue.statusKey === BACKLOG_STATUS;
+ const canStart =
+ canEdit &&
+ issue.statusKey === BACKLOG_STATUS &&
+ data?.waiting?.reason !== 'waitingOwner';
const canHandOver =
canEdit && data?.handOver === true && issue.statusKey !== BACKLOG_STATUS;
return (
@@ -117,6 +121,7 @@ export function IssueProgressLine({
) : null}
+
{canStart ? (
diff --git a/packages/apps/studio/client/issues/detail/owner-handoff.tsx b/packages/apps/studio/client/issues/detail/owner-handoff.tsx
new file mode 100644
index 00000000000..6bc894083d9
--- /dev/null
+++ b/packages/apps/studio/client/issues/detail/owner-handoff.tsx
@@ -0,0 +1,172 @@
+import { ApiClientError, useApiClient } from '@nocobase/app-client';
+import { useTranslation } from '@nocobase/i18n/client';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button';
+import { Spinner } from '@/components/ui/spinner';
+import { Skeleton } from '@/components/ui/skeleton';
+import { inboxKeys } from '@nocobase/app-plugin-notification-in-app/client/inbox';
+import { useNotify } from '../../access/notify.js';
+
+const ownerHandoffKey = (issueId: string) =>
+ ['studio', 'ownerHandoff', issueId] as const;
+interface HandoffView {
+ readonly handoff: {
+ readonly waiting: boolean;
+ readonly notificationKey: string;
+ readonly rejectedAt: string | null;
+ readonly ownerUserId: string;
+ } | null;
+ readonly canDecide: boolean;
+ readonly canClaim: boolean;
+}
+
+/** Shared by the issue page and its inbox decision. An old inbox item never borrows the current decision. */
+export function OwnerHandoffActions({
+ issueId,
+ notificationKey,
+}: {
+ readonly issueId: string;
+ readonly notificationKey?: string;
+}) {
+ const { t } = useTranslation();
+ const api = useApiClient();
+ const queries = useQueryClient();
+ const notify = useNotify();
+ const handoff = useQuery({
+ queryKey: ownerHandoffKey(issueId),
+ queryFn: async () =>
+ (
+ await api.request<{ data: HandoffView }>({
+ path: `issueOwnerHandoffs/${encodeURIComponent(issueId)}`,
+ })
+ ).data,
+ refetchInterval: 15_000,
+ });
+ const decide = useMutation({
+ mutationFn: (decision: 'accept' | 'reject') =>
+ api.request({
+ method: 'POST',
+ path: `issueOwnerHandoffs/${encodeURIComponent(issueId)}/${decision}`,
+ json: {
+ notificationKey:
+ notificationKey ?? handoff.data?.handoff?.notificationKey,
+ },
+ }),
+ onSuccess: (_data, decision) =>
+ notify.success(
+ t(`ownerHandoff.${decision === 'accept' ? 'accepted' : 'rejected'}`),
+ ),
+ onError: (error) =>
+ notify.error(
+ null,
+ t(
+ error instanceof ApiClientError && error.status === 409
+ ? 'ownerHandoff.stale'
+ : 'ownerHandoff.failed',
+ ),
+ ),
+ onSettled: async () => {
+ await Promise.all(
+ [
+ ownerHandoffKey(issueId),
+ ['studio', 'issueProgress', issueId],
+ ['pm', 'issue'],
+ inboxKeys.all,
+ ].map((queryKey) => queries.invalidateQueries({ queryKey })),
+ );
+ },
+ });
+ const claim = useMutation({
+ mutationFn: async () => {
+ const issue = await api.request<{ data: { revision: number } }>({
+ path: `projects/issues/${encodeURIComponent(issueId)}`,
+ });
+ return api.request({
+ method: 'PATCH',
+ path: `projects/issues/${encodeURIComponent(issueId)}`,
+ json: {
+ revision: issue.data.revision,
+ ownerUserId: handoff.data?.handoff?.ownerUserId,
+ },
+ });
+ },
+ onSuccess: () => notify.success(t('ownerHandoff.accepted')),
+ onError: () => notify.error(null, t('ownerHandoff.failed')),
+ onSettled: async () => {
+ await Promise.all(
+ [
+ ownerHandoffKey(issueId),
+ ['studio', 'issueProgress', issueId],
+ ['pm', 'issue'],
+ inboxKeys.all,
+ ].map((queryKey) => queries.invalidateQueries({ queryKey })),
+ );
+ },
+ });
+ if (handoff.isPending)
+ return notificationKey ?
: null;
+ if (handoff.isError)
+ return (
+
+ {t('ownerHandoff.loadFailed')}
+
+ );
+ const current = handoff.data?.handoff;
+ if (
+ notificationKey &&
+ (!current?.waiting ||
+ current.notificationKey !== notificationKey ||
+ current.rejectedAt)
+ )
+ return (
+
{t('ownerHandoff.stale')}
+ );
+ if (!current?.waiting) return null;
+ if (current.rejectedAt)
+ return (
+
+
+ {t('ownerHandoff.rejectedWaiting')}
+
+ {handoff.data?.canClaim ? (
+
+ ) : null}
+
+ );
+ if (!handoff.data?.canDecide) return null;
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/apps/studio/client/locales/en-US.ts b/packages/apps/studio/client/locales/en-US.ts
index 7403c1b78ed..c16b8663422 100644
--- a/packages/apps/studio/client/locales/en-US.ts
+++ b/packages/apps/studio/client/locales/en-US.ts
@@ -13,6 +13,32 @@ import inboxEnUS from '@/extensions/nocobase-inbox/locales/en-US';
import planCardEnUS from '@/extensions/nocobase-plan-card/locales/en-US';
const enUS = {
+ 'ownerHandoff.title': 'Owner handoff',
+ 'ownerHandoff.accept': 'Take over',
+ 'ownerHandoff.claim': 'Claim',
+ 'ownerHandoff.reject': 'Reject',
+ 'ownerHandoff.accepted': 'Handoff accepted',
+ 'ownerHandoff.rejected': 'Handoff rejected; agent work remains paused',
+ 'ownerHandoff.rejectedWaiting':
+ 'Handoff rejected. Waiting for a new assignment or a claim by the owner.',
+ 'ownerHandoff.stale':
+ 'This handoff is no longer awaiting a decision. Refresh the issue.',
+ 'ownerHandoff.failed':
+ 'Could not decide this handoff. Check your permission and refresh the issue.',
+ 'ownerHandoff.loadFailed': 'Could not load the owner handoff.',
+ 'activity.actions.ownerTransferred': 'transferred responsibility',
+ 'activity.actions.ownerAccepted': 'accepted responsibility',
+ 'activity.actions.ownerRejected': 'rejected the handoff',
+ 'activity.actions.waitingOwner':
+ 'skipped agent work: waiting for the new owner to take over',
+ 'inbox.types.owner_handoff': 'Owner handoff',
+ 'inbox.types.owner_handoff_rejected': 'Owner handoff rejected',
+ 'inbox.text.owner_handoff': 'Take over {{identifier}}',
+ 'inbox.text.owner_handoff_rejected': 'Owner handoff rejected: {{identifier}}',
+ 'inbox.body.owner_handoff':
+ 'Accept or reject this handoff. Agent work is paused until the new owner accepts.',
+ 'inbox.body.owner_handoff_rejected':
+ 'The new owner declined. The owner stays assigned and agent work remains paused.',
'issueRuns.model': 'Model: {{model}}',
'issueRuns.configuredModel': 'Configured model: {{model}}',
'issueRuns.modelUnknown': 'Model not reported',
@@ -1083,6 +1109,7 @@ const enUS = {
comment: 'Comment',
statusChange: 'Status change',
ownerChanged: 'Owner change',
+ ownerAccepted: 'Owner accepted',
projectChanged: 'Moved to another project',
unblocked: 'Unblocked',
subtasksFinished: 'Sub-issues done',
@@ -1391,6 +1418,7 @@ const enUS = {
issueStart: { title: 'Start now?', later: "Don't start now", start: 'Start' },
issueProgress: {
waiting: {
+ waitingOwner: 'Waiting for {{owner}} to take over',
noOwner: 'Waiting for a valid issue owner',
notConfigured:
'{{agent}} waits: no preferred or fallback model is configured',
diff --git a/packages/apps/studio/client/locales/zh-CN.ts b/packages/apps/studio/client/locales/zh-CN.ts
index d5d877b4d7a..89e6da7c41f 100644
--- a/packages/apps/studio/client/locales/zh-CN.ts
+++ b/packages/apps/studio/client/locales/zh-CN.ts
@@ -13,6 +13,28 @@ import inboxZhCN from '@/extensions/nocobase-inbox/locales/zh-CN';
import planCardZhCN from '@/extensions/nocobase-plan-card/locales/zh-CN';
const zhCN: AppResource = {
+ 'ownerHandoff.title': '负责人接手',
+ 'ownerHandoff.accept': '接手',
+ 'ownerHandoff.claim': '认领',
+ 'ownerHandoff.reject': '拒绝',
+ 'ownerHandoff.accepted': '已接手',
+ 'ownerHandoff.rejected': '已拒绝接手,agent 运行保持暂停',
+ 'ownerHandoff.rejectedWaiting': '已拒绝接手,等待重新转交或负责人自行认领。',
+ 'ownerHandoff.stale': '此接手请求已失效,请刷新任务。',
+ 'ownerHandoff.failed': '未能处理接手请求,请检查权限并刷新任务。',
+ 'ownerHandoff.loadFailed': '未能加载接手请求。',
+ 'activity.actions.ownerTransferred': '转交了负责人',
+ 'activity.actions.ownerAccepted': '接手了任务',
+ 'activity.actions.ownerRejected': '拒绝了接手',
+ 'activity.actions.waitingOwner': '跳过 agent 运行:等待新负责人接手',
+ 'inbox.types.owner_handoff': '等待接手',
+ 'inbox.types.owner_handoff_rejected': '接手已拒绝',
+ 'inbox.text.owner_handoff': '接手 {{identifier}}',
+ 'inbox.text.owner_handoff_rejected': '{{identifier}} 的接手已拒绝',
+ 'inbox.body.owner_handoff':
+ '请选择接手或拒绝,新负责人接手前 agent 运行保持暂停。',
+ 'inbox.body.owner_handoff_rejected':
+ '新负责人拒绝了接手,负责人保持不变,agent 运行保持暂停。',
'issueRuns.model': '模型:{{model}}',
'issueRuns.configuredModel': '配置模型:{{model}}',
'issueRuns.modelUnknown': '模型尚未报告',
@@ -998,6 +1020,7 @@ const zhCN: AppResource = {
comment: '评论',
statusChange: '状态变更',
ownerChanged: '负责人变更',
+ ownerAccepted: '负责人接手',
projectChanged: '转项目',
unblocked: '阻塞解除',
subtasksFinished: '子任务完成',
@@ -1278,6 +1301,7 @@ const zhCN: AppResource = {
issueStart: { title: '现在开始?', later: '暂不开始', start: '开始' },
issueProgress: {
waiting: {
+ waitingOwner: '等待 {{owner}} 接手',
noOwner: '正在等待有效的任务负责人',
notConfigured: '{{agent}} 正在等待:尚未配置首选或备选模型',
noRunner: '{{agent}} 正在等待:没有能运行首选或备选模型的在线执行机',
diff --git a/packages/apps/studio/database/main/migrations/202610240040_studio_issue_owner_handoffs.ts b/packages/apps/studio/database/main/migrations/202610240040_studio_issue_owner_handoffs.ts
new file mode 100644
index 00000000000..8bfc007be1f
--- /dev/null
+++ b/packages/apps/studio/database/main/migrations/202610240040_studio_issue_owner_handoffs.ts
@@ -0,0 +1,22 @@
+import { defineMigration, type MigrationDefinition } from '@nocobase/db';
+
+const migration: MigrationDefinition = defineMigration({
+ name: '202610240040_studio_issue_owner_handoffs',
+ async up({ builder }) {
+ await builder.createCollection('studioIssueOwnerHandoffs', (collection) => {
+ collection.string('issueId', { length: 64 }).primary().notNull();
+ collection.boolean('waiting').notNull().defaultTo(false);
+ collection.string('ownerUserId', { length: 64 }).notNull();
+ collection.string('previousOwnerUserId', { length: 64 }).notNull();
+ collection.string('transferredByUserId', { length: 64 }).notNull();
+ collection.string('notificationKey', { length: 64 }).notNull();
+ collection.datetimeTz('transferredAt').notNull();
+ collection.datetimeTz('rejectedAt').nullable();
+ });
+ },
+ async down({ builder }) {
+ await builder.dropCollection('studioIssueOwnerHandoffs');
+ },
+});
+
+export default migration;
diff --git a/packages/apps/studio/e2e/owner-handoff.test.ts b/packages/apps/studio/e2e/owner-handoff.test.ts
new file mode 100644
index 00000000000..183a677b645
--- /dev/null
+++ b/packages/apps/studio/e2e/owner-handoff.test.ts
@@ -0,0 +1,110 @@
+import {
+ apiAs,
+ expect,
+ open,
+ signIn,
+ test,
+ unique,
+ horizontalOverflow,
+} from './support/fixtures.ts';
+import { FakeRunner, type Issue } from './support/runner.ts';
+
+test('owner handoff controls in the issue and inbox, in Chinese and English', async ({
+ page,
+ api,
+ browser,
+ context,
+}) => {
+ test.setTimeout(120_000);
+ const admin = await apiAs(browser, 'admin');
+ const lisa = await apiAs(browser, 'lisa');
+ const me = await api.get<{ userId: string }>('projects/me');
+ const next = await lisa.api.get<{ userId: string }>('projects/me');
+ const settings = await admin.api.get<{
+ firstChoiceAgentId: string | null;
+ fallbackAgentId: string | null;
+ }>('projectManager');
+ try {
+ const runner = new FakeRunner(admin.api, `handoff-${unique()}`);
+ const agent = await runner.agent();
+ await admin.api.patch('projectManager', {
+ firstChoiceAgentId: agent,
+ fallbackAgentId: null,
+ });
+ const issue = await api.post
('projects/issues', {
+ title: `负责人接手 ${unique()}`,
+ ownerUserId: me.userId,
+ statusKey: 'in_progress',
+ start: false,
+ });
+ await api.post(`projectManager/issues/${issue.id}/start`);
+ const before = await api.get(`projects/issues/${issue.id}`);
+ await api.patch(`projects/issues/${issue.id}`, {
+ revision: before.revision,
+ ownerUserId: next.userId,
+ });
+ await open(page, `/issues/${issue.identifier}`);
+ await expect(
+ page.getByText('等待 Lisa Nguyen 接手', { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: '接手', exact: true }),
+ ).toHaveCount(0);
+ await signIn(context, 'lisa');
+ await open(page, `/issues/${issue.identifier}`);
+ await expect(
+ page.getByRole('button', { name: '接手', exact: true }),
+ ).toBeVisible();
+ await page.screenshot({
+ path: test.info().outputPath('owner-handoff-zh.png'),
+ });
+ await open(page, '/inbox');
+ await page
+ .getByRole('button', { name: new RegExp(issue.identifier) })
+ .first()
+ .click();
+ const card = page
+ .getByRole('article')
+ .filter({ hasText: issue.title })
+ .first();
+ await expect(
+ card.getByRole('button', { name: '接手', exact: true }),
+ ).toBeVisible();
+ await expect(
+ card.getByRole('button', { name: '拒绝', exact: true }),
+ ).toBeVisible();
+ await page.screenshot({
+ path: test.info().outputPath('owner-handoff-inbox-zh.png'),
+ });
+ await lisa.api.patch('users/me/preferences', { locale: 'en-US' });
+ await page.evaluate(() => localStorage.setItem('nocobase.locale', 'en-US'));
+ await page.setViewportSize({ width: 375, height: 812 });
+ await open(page, `/issues/${issue.identifier}`);
+ await expect(
+ page.getByText('Waiting for Lisa Nguyen to take over', { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: 'Take over', exact: true }),
+ ).toBeVisible();
+ expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1);
+ await page.screenshot({
+ path: test.info().outputPath('owner-handoff-en-mobile.png'),
+ });
+ await page.getByRole('button', { name: 'Take over', exact: true }).click();
+ await expect(
+ page.getByText('Waiting for Lisa Nguyen to take over', { exact: true }),
+ ).toHaveCount(0);
+ const current = await lisa.api.get<{ handoff: { waiting: boolean } }>(
+ `issueOwnerHandoffs/${issue.id}`,
+ );
+ expect(current.handoff.waiting).toBe(false);
+ } finally {
+ await lisa.api.patch('users/me/preferences', { locale: 'zh-CN' });
+ await admin.api.patch('projectManager', {
+ firstChoiceAgentId: settings.firstChoiceAgentId,
+ fallbackAgentId: settings.fallbackAgentId,
+ });
+ await lisa.close();
+ await admin.close();
+ }
+});
diff --git a/packages/apps/studio/server/agents/bind.ts b/packages/apps/studio/server/agents/bind.ts
index cfb942f661e..f8eeb80854d 100644
--- a/packages/apps/studio/server/agents/bind.ts
+++ b/packages/apps/studio/server/agents/bind.ts
@@ -96,6 +96,12 @@ import { createAgentWork } from './work.js';
import { commentRunNoticeRule } from './comment-run-limit.js';
import { reportCommentRuns } from './run-report.js';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
+import {
+ OWNER_HANDOFF_NOTICE,
+ ownerHandoffNoticeRule,
+ settleOwnerHandoffNotice,
+ type HandoffAnnouncement,
+} from '../issues/owner-handoff-notices.js';
export interface StudioAgentsDeps {
readonly agents: Agents;
@@ -155,11 +161,11 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
const { agents } = deps;
const onError =
deps.onError ?? ((message, error) => console.error(message, error));
- const work = createAgentWork({ agents, projects: deps.projects });
const askerOf = createAskerLookup(agents);
const permissions = createPermissionSource(deps.access, askerOf);
const viewerOf = (userId: string) =>
permissions.viewerOf({ kind: 'user', userId, displayName: userId });
+ const work = createAgentWork({ agents, projects: deps.projects, viewerOf });
const direct = createDirectWrites({ agents, projects: deps.projects });
const principal = createRunPrincipal({
askerOf,
@@ -175,6 +181,14 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
inbox,
});
const releases: (() => void)[] = [
+ deps.projects().events.on('work.announced', async (event) => {
+ const port = deps.inboxPort?.();
+ if (port && event.kind === OWNER_HANDOFF_NOTICE)
+ await settleOwnerHandoffNotice(
+ port,
+ event.payload as HandoffAnnouncement,
+ );
+ }),
deps.kinds.add(createAgentKind({ agents, work })),
agents.subjects.register({
...issueBinding(
@@ -287,6 +301,7 @@ export function bindStudioAgents(deps: StudioAgentsDeps): () => void {
deps.noticeRules.add(runFailedRule(agents, deps.projects)),
deps.noticeRules.add(agentBlockedRule(deps.projects, agents)),
deps.noticeRules.add(commentRunNoticeRule()),
+ deps.noticeRules.add(ownerHandoffNoticeRule()),
);
// The project manager runs with the tool and model of the agents it names: one of them changing changes it.
const manager = deps.manager;
diff --git a/packages/apps/studio/server/agents/catalog/triggers.ts b/packages/apps/studio/server/agents/catalog/triggers.ts
index 1910a8c3be8..4426275fe56 100644
--- a/packages/apps/studio/server/agents/catalog/triggers.ts
+++ b/packages/apps/studio/server/agents/catalog/triggers.ts
@@ -20,6 +20,7 @@ export const ISSUE_TRIGGERS = [
'comment',
'statusChange',
'ownerChanged',
+ 'ownerAccepted',
// Moved to another project: the work starts again in its working directories (`work.ts`).
'projectChanged',
'unblocked',
diff --git a/packages/apps/studio/server/agents/comment-run-limit.ts b/packages/apps/studio/server/agents/comment-run-limit.ts
index ac2d8a8537e..047a943dfa4 100644
--- a/packages/apps/studio/server/agents/comment-run-limit.ts
+++ b/packages/apps/studio/server/agents/comment-run-limit.ts
@@ -21,6 +21,7 @@ import {
type HeldCommentWake,
} from './comment-guard.js';
import { createAgentWork } from './work.js';
+import { ownerHandoffWaiting } from '../issues/owner-handoff.js';
/** The notice type of the owner's card. */
export const COMMENT_RUNS_LIMITED = 'comment_runs_limited';
@@ -91,6 +92,7 @@ export async function pendingCommentRun(
} | null> {
const issue = await projects.issueQueries.detail(viewer, issueId);
if (!mayContinue(viewer) || viewer.userId !== issue.ownerUserId) return null;
+ if (await ownerHandoffWaiting(projects.tx.read(), issue)) return null;
const guard = await commentGuards(projects.tx.read()).findOne({
filter: { id: commentGuardId(issue.id) },
});
@@ -126,6 +128,12 @@ export async function continueCommentRun(
'The current issue owner must continue this work.',
);
const id = commentGuardId(issue.id);
+ if (await ownerHandoffWaiting(tx.conn, issue))
+ throw studioError(
+ 'FAILED_PRECONDITION',
+ 'ISSUE_OWNER_HANDOFF_PENDING',
+ 'Accept the owner handoff before continuing this work.',
+ );
const stale = () =>
studioError(
'FAILED_PRECONDITION',
diff --git a/packages/apps/studio/server/agents/execution-identity.ts b/packages/apps/studio/server/agents/execution-identity.ts
index 5e9ee91ecc1..4b17caae0a3 100644
--- a/packages/apps/studio/server/agents/execution-identity.ts
+++ b/packages/apps/studio/server/agents/execution-identity.ts
@@ -7,12 +7,15 @@ import type { Projects } from '@nocobase/app-plugin-projects/server/tokens';
import type { DatabaseConnection } from '@nocobase/db';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
import { AGENT_KIND } from './tx.js';
+import { readOwnerHandoff } from '../issues/owner-handoff.js';
export function issueExecutionIdentity(
agents: Pick,
projects: () => Pick,
): NonNullable {
return async (conn, work) => {
+ if ((await readOwnerHandoff(conn, work.subjectId))?.waiting)
+ return { actorUserId: null, reason: 'waitingOwner' };
const seen = new Set();
const comments = await projects().commentQueries.list(conn, work.subjectId);
const byId = new Map(comments.map((comment) => [comment.id, comment]));
diff --git a/packages/apps/studio/server/agents/issue-subject.ts b/packages/apps/studio/server/agents/issue-subject.ts
index 644c0631434..40fb6cc135a 100644
--- a/packages/apps/studio/server/agents/issue-subject.ts
+++ b/packages/apps/studio/server/agents/issue-subject.ts
@@ -50,6 +50,7 @@ import {
} from './catalog/triggers.js';
import { AGENT_KIND } from './tx.js';
import { issueExecutionIdentity } from './execution-identity.js';
+import { readOwnerHandoff } from '../issues/owner-handoff.js';
const SUBJECT_NOUN = 'issue';
@@ -418,6 +419,11 @@ export function createIssueContextProvider(
): ContextProvider {
return {
async admit(conn, claim) {
+ if ((await readOwnerHandoff(conn, claim.run.subject.id))?.waiting)
+ return {
+ reason: 'waitingOwner',
+ detail: 'Waiting for the new issue owner to take over.',
+ };
const context = await projects().issueContext.contextFor(
conn,
claim.run.subject.id,
diff --git a/packages/apps/studio/server/agents/manager/progress.ts b/packages/apps/studio/server/agents/manager/progress.ts
index 54fbe7202bd..a4e69a51c8b 100644
--- a/packages/apps/studio/server/agents/manager/progress.ts
+++ b/packages/apps/studio/server/agents/manager/progress.ts
@@ -25,6 +25,7 @@ import {
validIssueOwner,
} from '../execution-identity.js';
import { userName } from '../work.js';
+import { ownerHandoffWaiting } from '../../issues/owner-handoff.js';
/** How much of a step or a question the line carries. */
const LINE_LENGTH = 160;
@@ -129,6 +130,18 @@ export async function issueProgress(
handOver: managed && open && managerRuns.length === 0,
};
+ if (await ownerHandoffWaiting(conn, issue))
+ return {
+ ...base,
+ handOver: false,
+ waiting: {
+ reason: 'waitingOwner',
+ owner: {
+ id: issue.ownerUserId,
+ name: await userName(conn, issue.ownerUserId),
+ },
+ },
+ };
const runs = await deps.agents.runs.openOn(conn, subject);
const held = runs.filter((run) => HELD.has(run.status));
const queued = runs.filter((run) => run.status === 'queued');
diff --git a/packages/apps/studio/server/agents/manager/schemas.ts b/packages/apps/studio/server/agents/manager/schemas.ts
index 843b48cdbd6..47ac64304fb 100644
--- a/packages/apps/studio/server/agents/manager/schemas.ts
+++ b/packages/apps/studio/server/agents/manager/schemas.ts
@@ -42,6 +42,7 @@ export const IssueProgressSchema: z.ZodType = z
waiting: z
.object({
reason: z.enum([
+ 'waitingOwner',
'noOwner',
'notConfigured',
'noRunner',
diff --git a/packages/apps/studio/server/agents/work.ts b/packages/apps/studio/server/agents/work.ts
index 0545fafe577..2deec3b572d 100644
--- a/packages/apps/studio/server/agents/work.ts
+++ b/packages/apps/studio/server/agents/work.ts
@@ -50,6 +50,7 @@ import type {
IssueWorkHandler,
Projects,
ProjectsTx,
+ Viewer,
RunAttempt,
} from '@nocobase/app-plugin-projects/server/tokens';
import type { Priority } from '@nocobase/app-plugin-projects/shared/common';
@@ -86,6 +87,12 @@ import {
import { readManagerSettings } from './manager/settings.js';
import { COMMENT_RUN_WINDOW_HOURS } from '../../shared/project-manager.js';
import { AGENT_KIND, agentsTx } from './tx.js';
+import {
+ ownerHandoffWaiting,
+ readOwnerHandoff,
+ recordOwnerHandoff,
+} from '../issues/owner-handoff.js';
+import { announceOwnerHandoff } from '../issues/owner-handoff-notices.js';
import { issueExecutionIdentity } from './execution-identity.js';
interface Wake {
@@ -144,6 +151,12 @@ const agentOf = (issue: Issue | null): string | null =>
/** The work handler, and "Start" on an issue its agent executes. */
export interface AgentWork extends IssueWorkHandler {
+ takeOver(
+ tx: ProjectsTx,
+ issue: Issue,
+ actor: Actor,
+ viewer?: Viewer,
+ ): Promise;
/**
* Starts the agent executing `issue` for `actor` (trigger `started`): unless the issue is dormant, waits for
* unfinished issues, or the actor may not wake the agent (`skipped`).
@@ -170,8 +183,14 @@ export function createAgentWork(deps: {
readonly agents: Pick;
readonly projects: () => Pick<
Projects,
- 'subtasks' | 'issueContext' | 'commentQueries' | 'activity'
+ | 'subtasks'
+ | 'issueContext'
+ | 'commentQueries'
+ | 'activity'
+ | 'issues'
+ | 'tx'
>;
+ readonly viewerOf?: (userId: string) => Promise;
}): AgentWork {
const { runs } = deps.agents;
const subjectOf = (issue: Issue) => ({ kind: ISSUE_SUBJECT, id: issue.id });
@@ -183,6 +202,8 @@ export function createAgentWork(deps: {
subjectId: request.issue.id,
triggerType: request.triggerType,
};
+ if (await ownerHandoffWaiting(tx.conn, request.issue))
+ return { ...attempt, started: false, skipped: 'waitingOwner' };
const agent = await deps.agents.agents.findWorkable(
tx.conn,
request.agentId,
@@ -566,9 +587,54 @@ export function createAgentWork(deps: {
});
}
+ async function takeOver(
+ tx: ProjectsTx,
+ issue: Issue,
+ actor: Actor,
+ viewer?: Viewer,
+ ): Promise {
+ requireOwnerStart(issue, actor);
+ if (
+ agentOf(issue) !== PROJECT_MANAGER_ID &&
+ (await deps.agents.agents.findWorkable(tx.conn, PROJECT_MANAGER_ID))
+ ) {
+ const claimant =
+ viewer ?? (actor.id ? await deps.viewerOf?.(actor.id) : undefined);
+ if (claimant)
+ issue = {
+ ...issue,
+ ...(await deps.projects().tx.savepoint(tx, () =>
+ deps.projects().issues.update(claimant, issue.id, {
+ revision: issue.revision,
+ executor: { type: AGENT_KIND, id: PROJECT_MANAGER_ID },
+ start: false,
+ }),
+ )),
+ };
+ }
+ if (dormant(issue))
+ return skipped(PROJECT_MANAGER_ID, issue, 'ownerChanged', 'dormant');
+ const who = await actorOf(tx, actor, issue);
+ return wake(tx, {
+ agentId: PROJECT_MANAGER_ID,
+ issue,
+ actorUserId: issue.ownerUserId,
+ triggerType: 'ownerAccepted',
+ input: {
+ type: 'signal',
+ actor: who.ref,
+ text: `${who.ref.name} accepted responsibility for ${issue.identifier}. Continue as the current owner and read all comments since your last run.`,
+ },
+ });
+ }
+
return {
+ observesComments: true,
+ observesOwnerClaims: true,
+ takeOver,
async onRunFinished(tx, run) {
if (run.subject.kind !== ISSUE_SUBJECT) return;
+ if ((await readOwnerHandoff(tx.conn, run.subject.id))?.waiting) return;
// An owner transfer cancels project-move continuation too; it must not restart work on its own.
const previous = await runs.identityInputs(tx.conn, run.id);
if (
@@ -667,6 +733,12 @@ export function createAgentWork(deps: {
async startNow(tx, issue, actor) {
requireOwnerStart(issue, actor);
+ if (await ownerHandoffWaiting(tx.conn, issue))
+ throw studioError(
+ 'FAILED_PRECONDITION',
+ 'ISSUE_OWNER_HANDOFF_PENDING',
+ 'The issue is waiting for its new owner to take over.',
+ );
const agentId = agentOf(issue);
if (!agentId) throw new Error('No agent executes the issue.');
return assigned(
@@ -680,8 +752,43 @@ export function createAgentWork(deps: {
async onIssueChanged(tx, change) {
const { before, after } = change;
- // The owner-change hook withdrew queued work and requested cancellation; the waiting-for-takeover flow controls when work resumes.
- if (before && before.ownerUserId !== after.ownerUserId) return [];
+ if (change.ownerClaimed && !tx.rehearsal) {
+ const pending = await readOwnerHandoff(tx.conn, after.id);
+ if (pending?.waiting) {
+ const claimed = await recordOwnerHandoff(tx.conn, {
+ ...pending,
+ authenticatedPersonUserId: change.actor.id,
+ transferredByUserId: after.ownerUserId,
+ });
+ await deps.projects().activity.record(tx.conn, {
+ issueId: after.id,
+ actor: change.actor,
+ action: 'studio_owner_accepted',
+ details: { ownerUserId: after.ownerUserId },
+ });
+ announceOwnerHandoff(tx, after, claimed, pending.notificationKey);
+ if (change.start) return [await takeOver(tx, after, change.actor)];
+ }
+ }
+ // The owner-change hook already withdrew/stopped work. The handoff controls when the new owner resumes it.
+ if (before && before.ownerUserId !== after.ownerUserId) {
+ if (
+ change.start &&
+ change.actor.type === 'user' &&
+ change.actor.via !== 'agent' &&
+ change.actor.id === after.ownerUserId
+ )
+ return [
+ await assigned(
+ tx,
+ after,
+ PROJECT_MANAGER_ID,
+ await actorOf(tx, change.actor, after),
+ 'started',
+ ),
+ ];
+ return [];
+ }
const from = agentOf(before);
const to = agentOf(after);
if (
@@ -691,6 +798,28 @@ export function createAgentWork(deps: {
(!before || dormant(before) || to !== from)
)
requireOwnerStart(after, change.actor);
+ if (await ownerHandoffWaiting(tx.conn, after)) {
+ if (
+ change.start &&
+ change.actor.type === 'user' &&
+ change.actor.via !== 'agent' &&
+ after.statusKey === IN_PROGRESS_STATUS &&
+ before?.statusKey !== IN_PROGRESS_STATUS
+ )
+ throw studioError(
+ 'FAILED_PRECONDITION',
+ 'ISSUE_OWNER_HANDOFF_PENDING',
+ 'Accept the owner handoff before starting this issue.',
+ );
+ return [
+ skipped(
+ to ?? PROJECT_MANAGER_ID,
+ after,
+ 'statusChange',
+ 'waitingOwner',
+ ),
+ ];
+ }
const who = await actorOf(tx, change.actor, after);
if (from && from !== to)
await stop(
@@ -762,6 +891,21 @@ export function createAgentWork(deps: {
async onCommentCreated(tx, change) {
const { comment, issue, parent } = change;
+ if (await ownerHandoffWaiting(tx.conn, issue)) {
+ const targets = new Set(
+ change.mentions
+ .filter((ref) => ref.kind === AGENT_KIND)
+ .map((ref) => ref.id),
+ );
+ const executor = agentOf(issue);
+ if (executor) targets.add(executor);
+ if (parent?.authorType === AGENT_KIND && parent.authorId)
+ targets.add(parent.authorId);
+ if (targets.size === 0) targets.add(PROJECT_MANAGER_ID);
+ return [...targets].map((id) =>
+ skipped(id, issue, 'comment', 'waitingOwner'),
+ );
+ }
if (statusCategory(issue.statusKey) === 'unstarted') return [];
const authorId = change.actor.id;
if (!authorId) return [];
@@ -832,6 +976,28 @@ export function createAgentWork(deps: {
async onOwnerChanged(tx, { issue, from, to, actor }) {
if (tx.rehearsal) return [];
+ const previous = await readOwnerHandoff(tx.conn, issue.id);
+ const source = actor?.trace?.runId
+ ? await runs.identityInputs(tx.conn, actor.trace.runId)
+ : null;
+ const handoff = await recordOwnerHandoff(tx.conn, {
+ issueId: issue.id,
+ ownerUserId: to,
+ previousOwnerUserId: from,
+ transferredByUserId:
+ actor?.type === 'user'
+ ? (actor.id ?? from)
+ : (source?.run.actorUserId ?? from),
+ authenticatedPersonUserId:
+ actor?.type === 'user' && actor.via !== 'agent' ? actor.id : null,
+ });
+ await deps.projects().activity.record(tx.conn, {
+ issueId: issue.id,
+ actor: actor ?? { type: 'system', id: null },
+ action: 'studio_owner_transferred',
+ details: { from, to, waiting: handoff.waiting },
+ });
+ announceOwnerHandoff(tx, issue, handoff, previous?.notificationKey);
const subject = subjectOf(issue);
const who = await actorOf(
tx,
@@ -861,6 +1027,14 @@ export function createAgentWork(deps: {
for (const run of await runs.openOn(tx.conn, subject))
if (run.status !== 'queued')
await runs.cancel(run.id, who.userId, agentsTx(tx));
+ if (!handoff.waiting) {
+ await deps.projects().activity.record(tx.conn, {
+ issueId: issue.id,
+ actor: actor ?? { type: 'system', id: null },
+ action: 'studio_owner_accepted',
+ details: { ownerUserId: to },
+ });
+ }
return [];
},
@@ -884,6 +1058,15 @@ export function createAgentWork(deps: {
},
async onUnblocked(tx, { issue, releasedBy }) {
+ if (await ownerHandoffWaiting(tx.conn, issue))
+ return [
+ skipped(
+ agentOf(issue) ?? PROJECT_MANAGER_ID,
+ issue,
+ 'unblocked',
+ 'waitingOwner',
+ ),
+ ];
const agentId = agentOf(issue);
if (!agentId) return [];
if (dormant(issue))
@@ -905,6 +1088,15 @@ export function createAgentWork(deps: {
},
async onSubtasksFinished(tx, { parent, stage, childIssueIds }) {
+ if (await ownerHandoffWaiting(tx.conn, parent))
+ return [
+ skipped(
+ agentOf(parent) ?? PROJECT_MANAGER_ID,
+ parent,
+ 'subtasksFinished',
+ 'waitingOwner',
+ ),
+ ];
const agentId = agentOf(parent);
if (!agentId) return [];
if (dormant(parent))
diff --git a/packages/apps/studio/server/follow-ups/service.ts b/packages/apps/studio/server/follow-ups/service.ts
index 9d83f735085..676c243c24c 100644
--- a/packages/apps/studio/server/follow-ups/service.ts
+++ b/packages/apps/studio/server/follow-ups/service.ts
@@ -36,8 +36,11 @@ export interface FollowUpPreferences {
}
export interface FollowUpsDeps {
- readonly projects: Pick;
- readonly agents: Pick;
+ readonly projects: Pick<
+ Projects,
+ 'issueQueries' | 'commentQueries' | 'issueContext' | 'tx'
+ >;
+ readonly agents: Pick;
readonly preferences: FollowUpPreferences;
readonly now?: () => Date;
}
diff --git a/packages/apps/studio/server/git/flow.ts b/packages/apps/studio/server/git/flow.ts
index c7edf6eb983..365fceea954 100644
--- a/packages/apps/studio/server/git/flow.ts
+++ b/packages/apps/studio/server/git/flow.ts
@@ -44,6 +44,7 @@ import { ISSUE_SUBJECT } from '../agents/catalog/triggers.js';
import { AGENT_KIND, agentsTx } from '../agents/tx.js';
import { validIssueOwner } from '../agents/execution-identity.js';
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
+import { readOwnerHandoff } from '../issues/owner-handoff.js';
import type { StudioInboxPort } from '../inbox/port.js';
import {
IN_REVIEW,
@@ -368,10 +369,20 @@ export function createGitFlow(deps: GitFlowDeps): GitFlow {
if (
!issue ||
issue.status.category === 'done' ||
- issue.status.category === 'closed' ||
- issue.executor?.type !== AGENT_KIND
+ issue.status.category === 'closed'
)
continue;
+ if ((await readOwnerHandoff(tx.conn, issue.id))?.waiting) {
+ await deps.projects().activity.record(tx.conn, {
+ issueId: issue.id,
+ actor: { type: 'system', id: null },
+ action: 'work_skipped',
+ details: { reason: 'waitingOwner', trigger: signal },
+ });
+ tx.emit({ type: 'issue.changed', issueId: issue.id });
+ continue;
+ }
+ if (issue.executor?.type !== AGENT_KIND) continue;
if (signal !== 'passed' && count > SIGNAL_WAKE_LIMIT) {
const port = deps.inbox();
const card = await cardIssue(tx.conn, issue.id);
diff --git a/packages/apps/studio/server/issues/owner-handoff-api.ts b/packages/apps/studio/server/issues/owner-handoff-api.ts
new file mode 100644
index 00000000000..0879d077579
--- /dev/null
+++ b/packages/apps/studio/server/issues/owner-handoff-api.ts
@@ -0,0 +1,79 @@
+import type { Agents } from '@nocobase/app-plugin-agents/server/tokens';
+import type {
+ Projects,
+ Viewer,
+} from '@nocobase/app-plugin-projects/server/tokens';
+import { createAgentWork } from '../agents/work.js';
+import { studioError } from '../http/errors.js';
+import { decideOwnerHandoff, readOwnerHandoff } from './owner-handoff.js';
+import { announceOwnerHandoff } from './owner-handoff-notices.js';
+
+export function createOwnerHandoffApi(projects: Projects, agents: Agents) {
+ const work = createAgentWork({ agents, projects: () => projects });
+ return {
+ async read(viewer: Viewer, key: string, personUserId: string | null) {
+ const issue = await projects.issueQueries.detail(viewer, key);
+ const handoff = await readOwnerHandoff(projects.tx.read(), issue.id);
+ const canClaim = Boolean(
+ handoff?.waiting &&
+ handoff.ownerUserId === issue.ownerUserId &&
+ personUserId === issue.ownerUserId &&
+ viewer.permissions.scopes['pm.issues/edit'] !== 'none',
+ );
+ return {
+ handoff,
+ canClaim,
+ canDecide: Boolean(
+ handoff?.waiting &&
+ !handoff.rejectedAt &&
+ handoff.ownerUserId === issue.ownerUserId &&
+ personUserId === issue.ownerUserId &&
+ viewer.permissions.scopes['pm.issues/edit'] !== 'none',
+ ),
+ };
+ },
+ async decide(
+ viewer: Viewer,
+ key: string,
+ personUserId: string | null,
+ notificationKey: string,
+ decision: 'accept' | 'reject',
+ ) {
+ if (
+ !personUserId ||
+ viewer.actor.type !== 'user' ||
+ viewer.permissions.scopes['pm.issues/edit'] === 'none'
+ )
+ throw studioError(
+ 'PERMISSION_DENIED',
+ 'ISSUE_OWNER_HANDOFF_REQUIRED',
+ 'Only a person with issue editing permission may decide this handoff.',
+ );
+ return projects.tx.unit(async (tx) => {
+ const issue = await projects.issueQueries.detail(viewer, key);
+ const handoff = await decideOwnerHandoff(tx.conn, {
+ issue,
+ authenticatedPersonUserId: personUserId,
+ notificationKey,
+ decision,
+ });
+ await projects.activity.record(tx.conn, {
+ issueId: issue.id,
+ actor: viewer.actor,
+ action:
+ decision === 'accept'
+ ? 'studio_owner_accepted'
+ : 'studio_owner_rejected',
+ details: { ownerUserId: issue.ownerUserId },
+ });
+ announceOwnerHandoff(tx, issue, handoff, undefined, decision);
+ const run =
+ decision === 'accept'
+ ? await work.takeOver(tx, issue, viewer.actor, viewer)
+ : null;
+ tx.emit({ type: 'issue.changed', issueId: issue.id });
+ return { handoff, runId: run?.runId ?? null };
+ });
+ },
+ };
+}
diff --git a/packages/apps/studio/server/issues/owner-handoff-notices.ts b/packages/apps/studio/server/issues/owner-handoff-notices.ts
new file mode 100644
index 00000000000..391786b14d8
--- /dev/null
+++ b/packages/apps/studio/server/issues/owner-handoff-notices.ts
@@ -0,0 +1,91 @@
+import type {
+ NoticeRule,
+ ProjectsTx,
+} from '@nocobase/app-plugin-projects/server/tokens';
+import type { Issue } from '@nocobase/app-plugin-projects/shared/issues';
+import type { StudioInboxPort } from '../inbox/port.js';
+import {
+ ownerHandoffRejectionRecipients,
+ type OwnerHandoff,
+} from './owner-handoff.js';
+
+export const OWNER_HANDOFF_NOTICE = 'studio.ownerHandoff';
+export interface HandoffAnnouncement {
+ readonly issue: Pick;
+ readonly handoff: OwnerHandoff;
+ readonly previousNotificationKey?: string;
+ readonly decision?: 'accept' | 'reject';
+}
+export const ownerHandoffNoticeKey = (key: string) => `owner-handoff:${key}`;
+export function announceOwnerHandoff(
+ tx: ProjectsTx,
+ issue: HandoffAnnouncement['issue'],
+ handoff: OwnerHandoff,
+ previousNotificationKey?: string,
+ decision?: HandoffAnnouncement['decision'],
+): void {
+ tx.emit({
+ type: 'work.announced',
+ kind: OWNER_HANDOFF_NOTICE,
+ payload: {
+ issue: { id: issue.id, identifier: issue.identifier, title: issue.title },
+ handoff,
+ previousNotificationKey,
+ decision,
+ } satisfies HandoffAnnouncement,
+ });
+}
+export function ownerHandoffNoticeRule(): NoticeRule {
+ return async ({ events }) =>
+ events.flatMap((event) => {
+ if (
+ event.type !== 'work.announced' ||
+ event.kind !== OWNER_HANDOFF_NOTICE
+ )
+ return [];
+ const { issue, handoff, decision } = event.payload as HandoffAnnouncement;
+ if (decision === 'accept' || (!decision && !handoff.waiting)) return [];
+ const rejected = decision === 'reject';
+ const title = rejected
+ ? `Owner handoff rejected: ${issue.identifier}`
+ : `Take over ${issue.identifier}`;
+ const body = rejected
+ ? 'The owner declined the handoff. The issue remains paused.'
+ : 'Accept or reject the handoff on the issue. Agent work is paused until you accept.';
+ return [
+ {
+ key: `${ownerHandoffNoticeKey(handoff.notificationKey)}${rejected ? ':rejected' : ''}`,
+ kind: rejected ? ('info' as const) : ('decision' as const),
+ type: rejected ? 'owner_handoff_rejected' : 'owner_handoff',
+ issue,
+ userIds: rejected
+ ? ownerHandoffRejectionRecipients(handoff)
+ : [handoff.ownerUserId],
+ actor: { type: 'system' as const, id: null, name: 'Studio' },
+ slot: 'change' as const,
+ params: {
+ title,
+ body,
+ identifier: issue.identifier,
+ notificationKey: handoff.notificationKey,
+ },
+ },
+ ];
+ });
+}
+export async function settleOwnerHandoffNotice(
+ port: StudioInboxPort,
+ notice: HandoffAnnouncement,
+): Promise {
+ if (notice.previousNotificationKey)
+ await port.withdraw({
+ source: 'projects',
+ decisionKey: ownerHandoffNoticeKey(notice.previousNotificationKey),
+ });
+ if (notice.decision)
+ await port.resolve({
+ source: 'projects',
+ decisionKey: ownerHandoffNoticeKey(notice.handoff.notificationKey),
+ outcome: notice.decision === 'accept' ? 'accepted' : 'rejected',
+ });
+}
diff --git a/packages/apps/studio/server/issues/owner-handoff-routes.ts b/packages/apps/studio/server/issues/owner-handoff-routes.ts
new file mode 100644
index 00000000000..9726267481c
--- /dev/null
+++ b/packages/apps/studio/server/issues/owner-handoff-routes.ts
@@ -0,0 +1,141 @@
+import { agentsToken } from '@nocobase/app-plugin-agents/server/tokens';
+import { authenticationToken } from '@nocobase/app-plugin-authentication';
+import { authorizationToken } from '@nocobase/app-plugin-authorization';
+import {
+ projectsAccessToken,
+ projectsToken,
+} from '@nocobase/app-plugin-projects/server/tokens';
+import type { Application } from '@nocobase/app-server/application';
+import {
+ apiErrorResponse,
+ apiErrorResponses,
+ apiValidator,
+ cliRoute,
+ dataResponse,
+ defineApiRoutes,
+ describeRoute,
+ type AppApiRouteContribution,
+} from '@nocobase/app-server/router';
+import { Hono, type Context } from 'hono';
+import { z } from 'zod';
+import { createPermissionSource } from '../agents/commands/permissions.js';
+import { createAskerLookup } from '../agents/conversation/acting.js';
+import { callerOfRequest } from '../agents/run-principal.js';
+import { studioError, studioErrorHandler } from '../http/errors.js';
+import { createOwnerHandoffApi } from './owner-handoff-api.js';
+
+const Params = z.object({ issueId: z.string().min(1) });
+const Input = z.strictObject({ notificationKey: z.string().uuid() });
+const Handoff = z.object({
+ issueId: z.string(),
+ waiting: z.boolean(),
+ ownerUserId: z.string(),
+ previousOwnerUserId: z.string(),
+ transferredByUserId: z.string(),
+ notificationKey: z.string(),
+ transferredAt: z.string(),
+ rejectedAt: z.string().nullable(),
+});
+export const ownerHandoffRoutes: AppApiRouteContribution =
+ defineApiRoutes(({ container }) => {
+ const agents = container.resolve(agentsToken);
+ const projects = container.resolve(projectsToken);
+ const permissions = createPermissionSource(
+ () => container.resolve(projectsAccessToken),
+ createAskerLookup(agents),
+ );
+ const api = createOwnerHandoffApi(projects, agents);
+ const router = new Hono();
+ router.onError(studioErrorHandler);
+ router.use(
+ '*',
+ container.resolve(authenticationToken).required({ scopedKeys: true }),
+ );
+ router.use('*', container.resolve(authorizationToken).middleware());
+ router.use('*', async (context, next) => {
+ const identity = callerOfRequest(context as unknown as Context);
+ const action =
+ context.req.method === 'GET' ? 'pm.issues/view' : 'pm.issues/edit';
+ if (!identity || !(await agents.gate.allowed(identity)).has(action))
+ throw studioError(
+ 'PERMISSION_DENIED',
+ 'ACTION_FORBIDDEN',
+ 'This action is not allowed.',
+ );
+ if (context.req.method !== 'GET' && identity.kind !== 'user')
+ throw studioError(
+ 'PERMISSION_DENIED',
+ 'ISSUE_OWNER_HANDOFF_REQUIRED',
+ 'An agent cannot consent to an owner handoff.',
+ );
+ await next();
+ });
+ router.get(
+ '/:issueId',
+ describeRoute({
+ tags: ['Studio'],
+ operationId: 'issueOwnerHandoffGet',
+ summary: 'Get an issue owner handoff',
+ ...cliRoute(false),
+ security: [{ cookieAuth: [] }, { apiKeyAuth: [] }],
+ responses: {
+ 200: dataResponse(
+ z.object({
+ handoff: Handoff.nullable(),
+ canDecide: z.boolean(),
+ canClaim: z.boolean(),
+ }),
+ ),
+ 404: apiErrorResponse(404, 'Issue not found.'),
+ ...apiErrorResponses,
+ },
+ }),
+ apiValidator('param', Params),
+ async (context) => {
+ const identity = callerOfRequest(context as unknown as Context)!;
+ return context.json({
+ data: await api.read(
+ await permissions.viewerOf(identity),
+ context.req.valid('param').issueId,
+ identity.kind === 'user' ? identity.userId : null,
+ ),
+ });
+ },
+ );
+ for (const decision of ['accept', 'reject'] as const)
+ router.post(
+ `/:issueId/${decision}`,
+ describeRoute({
+ tags: ['Studio'],
+ operationId: `issueOwnerHandoff${decision === 'accept' ? 'Accept' : 'Reject'}`,
+ summary: `${decision === 'accept' ? 'Accept' : 'Reject'} an issue owner handoff`,
+ ...cliRoute(false),
+ security: [{ cookieAuth: [] }, { apiKeyAuth: [] }],
+ description:
+ 'Only the current owner in person, with issue editing permission, may decide the current handoff. Rejection keeps the issue paused. A stale notification returns a conflict.',
+ responses: {
+ 200: dataResponse(
+ z.object({ handoff: Handoff, runId: z.string().nullable() }),
+ ),
+ 409: apiErrorResponse(409, 'The handoff is no longer current.'),
+ 404: apiErrorResponse(404, 'Issue not found.'),
+ ...apiErrorResponses,
+ },
+ }),
+ apiValidator('param', Params),
+ apiValidator('json', Input),
+ async (context) => {
+ const identity = callerOfRequest(context as unknown as Context)!;
+ return context.json({
+ data: await api.decide(
+ await permissions.viewerOf(identity),
+ context.req.valid('param').issueId,
+ identity.kind === 'user' ? identity.userId : null,
+ context.req.valid('json').notificationKey,
+ decision,
+ ),
+ });
+ },
+ );
+ return new Hono().route('/issueOwnerHandoffs', router);
+ });
diff --git a/packages/apps/studio/server/issues/owner-handoff.ts b/packages/apps/studio/server/issues/owner-handoff.ts
new file mode 100644
index 00000000000..36e639a0756
--- /dev/null
+++ b/packages/apps/studio/server/issues/owner-handoff.ts
@@ -0,0 +1,142 @@
+/** One current handoff per issue; this is not an execution authorization or a request history. */
+import { randomUUID } from 'node:crypto';
+
+import type { DatabaseConnection } from '@nocobase/db';
+
+import { studioError } from '../http/errors.js';
+
+export const OWNER_HANDOFFS = 'studioIssueOwnerHandoffs';
+
+export interface OwnerHandoff {
+ readonly issueId: string;
+ readonly waiting: boolean;
+ readonly ownerUserId: string;
+ readonly previousOwnerUserId: string;
+ readonly transferredByUserId: string;
+ /** Correlates an inbox item with the current transfer, including a transfer back to the same owner. */
+ readonly notificationKey: string;
+ readonly transferredAt: string;
+ readonly rejectedAt: string | null;
+}
+
+export async function readOwnerHandoff(
+ conn: DatabaseConnection,
+ issueId: string,
+): Promise {
+ return (
+ (await conn.repository(OWNER_HANDOFFS).findOne({
+ filter: { issueId },
+ })) ?? null
+ );
+}
+
+/** Write in the owner change's transaction, before any wake or synchronous cancellation callback. */
+export async function recordOwnerHandoff(
+ conn: DatabaseConnection,
+ change: {
+ readonly issueId: string;
+ readonly ownerUserId: string;
+ readonly previousOwnerUserId: string;
+ readonly transferredByUserId: string;
+ /** Null for a run or an automatic change, even when it acts for the target owner. */
+ readonly authenticatedPersonUserId: string | null;
+ },
+): Promise {
+ const handoff: OwnerHandoff = {
+ issueId: change.issueId,
+ ownerUserId: change.ownerUserId,
+ previousOwnerUserId: change.previousOwnerUserId,
+ transferredByUserId: change.transferredByUserId,
+ waiting: change.authenticatedPersonUserId !== change.ownerUserId,
+ notificationKey: randomUUID(),
+ transferredAt: new Date().toISOString(),
+ rejectedAt: null,
+ };
+ await conn.repository(OWNER_HANDOFFS).upsertOne({
+ filter: { issueId: change.issueId },
+ create: { ...handoff },
+ update: { ...handoff },
+ });
+ return handoff;
+}
+
+/** A stale row never grants consent for a different current owner. */
+export async function ownerHandoffWaiting(
+ conn: DatabaseConnection,
+ issue: { readonly id: string; readonly ownerUserId: string },
+): Promise {
+ const handoff = await readOwnerHandoff(conn, issue.id);
+ return (
+ handoff !== null &&
+ (handoff.waiting || handoff.ownerUserId !== issue.ownerUserId)
+ );
+}
+
+/**
+ * The caller has already checked issue access in this transaction. A route must pass the actual human identity,
+ * never a run's mapped userId. Decisions, activities and manager wakes belong to the same caller transaction.
+ */
+export async function decideOwnerHandoff(
+ conn: DatabaseConnection,
+ input: {
+ readonly issue: { readonly id: string; readonly ownerUserId: string };
+ readonly authenticatedPersonUserId: string | null;
+ readonly notificationKey: string;
+ readonly decision: 'accept' | 'reject';
+ },
+): Promise {
+ const { issue } = input;
+ if (
+ !input.authenticatedPersonUserId ||
+ input.authenticatedPersonUserId !== issue.ownerUserId
+ )
+ throw studioError(
+ 'PERMISSION_DENIED',
+ 'ISSUE_OWNER_HANDOFF_REQUIRED',
+ 'Only the current issue owner may accept or reject this handoff in person.',
+ );
+ const current = await readOwnerHandoff(conn, issue.id);
+ if (
+ !current?.waiting ||
+ current.rejectedAt !== null ||
+ current.ownerUserId !== issue.ownerUserId ||
+ current.notificationKey !== input.notificationKey
+ )
+ throw studioError(
+ 'ABORTED',
+ 'ISSUE_OWNER_HANDOFF_STALE',
+ 'This handoff is no longer awaiting a decision. Refresh the issue.',
+ );
+ const next: OwnerHandoff = {
+ ...current,
+ waiting: input.decision === 'reject',
+ rejectedAt: input.decision === 'reject' ? new Date().toISOString() : null,
+ };
+ const updated = await conn.repository(OWNER_HANDOFFS).updateMany({
+ filter: (f) =>
+ f.and([
+ f.string('issueId').eq(issue.id),
+ f.string('ownerUserId').eq(issue.ownerUserId),
+ f.string('notificationKey').eq(input.notificationKey),
+ f.boolean('waiting').isTrue(),
+ f.date('rejectedAt').empty(),
+ ]),
+ values: { waiting: next.waiting, rejectedAt: next.rejectedAt },
+ });
+ if (updated.updatedCount !== 1)
+ throw studioError(
+ 'ABORTED',
+ 'ISSUE_OWNER_HANDOFF_STALE',
+ 'This handoff is no longer awaiting a decision. Refresh the issue.',
+ );
+ return next;
+}
+
+/** Rejection informs both people, once each, without resuming any work. */
+export function ownerHandoffRejectionRecipients(
+ handoff: OwnerHandoff,
+): string[] {
+ return [
+ ...new Set([handoff.previousOwnerUserId, handoff.transferredByUserId]),
+ ];
+}
diff --git a/packages/apps/studio/server/routes/index.ts b/packages/apps/studio/server/routes/index.ts
index 921a1014b63..c5803bf629c 100644
--- a/packages/apps/studio/server/routes/index.ts
+++ b/packages/apps/studio/server/routes/index.ts
@@ -2,6 +2,7 @@ import type { Application } from '@nocobase/app-server/application';
import type { AppRouteContribution } from '@nocobase/app-server/router';
import { accessRoutes } from '../access/routes.js';
+import { ownerHandoffRoutes } from '../issues/owner-handoff-routes.js';
import { ciRunRoutes } from '../builds/ci-run-routes.js';
import { buildsRoutes } from '../builds/routes.js';
import { studioAgentsRoutes } from '../agents/routes.js';
@@ -19,6 +20,7 @@ import { reportsRoutes } from '../reports/routes.js';
const routes: readonly AppRouteContribution[] = [
accessRoutes,
+ ownerHandoffRoutes,
inboxRoutes,
studioAgentsRoutes,
issueRunsRoutes,
diff --git a/packages/apps/studio/shared/project-manager.ts b/packages/apps/studio/shared/project-manager.ts
index 5dabbc5def1..bf8b30a43b6 100644
--- a/packages/apps/studio/shared/project-manager.ts
+++ b/packages/apps/studio/shared/project-manager.ts
@@ -82,6 +82,7 @@ export type IssueProgressState =
export interface IssueProgress {
readonly waiting?: {
readonly reason:
+ | 'waitingOwner'
| 'noOwner'
| 'notConfigured'
| 'noRunner'
diff --git a/packages/apps/studio/tests/agents/bridge-harness.ts b/packages/apps/studio/tests/agents/bridge-harness.ts
index ba3bd18a212..d6a0a96963f 100644
--- a/packages/apps/studio/tests/agents/bridge-harness.ts
+++ b/packages/apps/studio/tests/agents/bridge-harness.ts
@@ -117,6 +117,7 @@ import {
} from '../../server/agents/conversation/delegation.js';
import { issueRunsRoutes } from '../../server/agents/run-views.js';
import { projectManagerRoutes } from '../../server/agents/manager/routes.js';
+import { ownerHandoffRoutes } from '../../server/issues/owner-handoff-routes.js';
import { followUpsRoutes } from '../../server/follow-ups/routes.js';
import { createProjectManager } from '../../server/agents/manager/service.js';
import { studioProjectManagerToken } from '../../server/agents/manager/token.js';
@@ -1215,6 +1216,10 @@ export async function createBridgeHarness(
await studioAgentsRoutes.createRouter(appLike as unknown as never),
);
api.route('/', await inboxRoutes.createRouter(appLike as unknown as never));
+ api.route(
+ '/',
+ await ownerHandoffRoutes.createRouter(appLike as unknown as never),
+ );
api.route(
'/',
await projectManagerRoutes.createRouter(appLike as unknown as never),
diff --git a/packages/apps/studio/tests/agents/manager-identity.test.ts b/packages/apps/studio/tests/agents/manager-identity.test.ts
index 78fcc6cd509..302efe9fc9d 100644
--- a/packages/apps/studio/tests/agents/manager-identity.test.ts
+++ b/packages/apps/studio/tests/agents/manager-identity.test.ts
@@ -348,6 +348,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
+ start: false,
});
expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice');
expect((await h.agents.runs.get(run.id)).cancelRequestedAt).not.toBeNull();
@@ -383,6 +384,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
+ start: false,
executor: null,
});
const after = await runs(task.id);
@@ -586,6 +588,7 @@ describe('manager and delegated identities', () => {
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
+ start: false,
});
const retry = await h.agents.runs.retry(previous.id, 'bob');
expect(retry.actorUserId).toBe('bob');
diff --git a/packages/apps/studio/tests/agents/owner-handoff.test.ts b/packages/apps/studio/tests/agents/owner-handoff.test.ts
new file mode 100644
index 00000000000..83ee912d326
--- /dev/null
+++ b/packages/apps/studio/tests/agents/owner-handoff.test.ts
@@ -0,0 +1,456 @@
+// @vitest-environment node
+import { RUNNER_ROUTES } from '@nocobase/agent-protocol';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { createBridgeHarness, type BridgeHarness } from './bridge-harness.js';
+import { readOwnerHandoff } from '../../server/issues/owner-handoff.js';
+import { createAgentWork } from '../../server/agents/work.js';
+import {
+ ownerHandoffNoticeRule,
+ OWNER_HANDOFF_NOTICE,
+} from '../../server/issues/owner-handoff-notices.js';
+import { connectedRepo } from '../git/helpers.js';
+import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
+
+let h: BridgeHarness;
+beforeEach(async () => {
+ h = await createBridgeHarness();
+ for (const id of ['alice', 'bob', 'carol']) {
+ await h.addUser(id);
+ h.roles.set(id, 'admin');
+ }
+ await h.createAgent();
+});
+afterEach(async () => h.close());
+const runs = (id: string) =>
+ h.agents.runs.list({ subjectKind: 'issue', subjectId: id });
+const task = (statusKey = 'in_progress') =>
+ h.projects.issues.create(h.viewer('alice'), {
+ title: 'Handoff',
+ statusKey,
+ start: false,
+ });
+async function transfer(id: string, to = 'bob', by = 'alice') {
+ const issue = await h.projects.issueQueries.detail(h.viewer(by), id);
+ return h.projects.issues.update(h.viewer(by), id, {
+ revision: issue.revision,
+ ownerUserId: to,
+ });
+}
+async function decide(
+ id: string,
+ decision: 'accept' | 'reject',
+ user = 'bob',
+ key?: string,
+) {
+ const handoff = await readOwnerHandoff(h.projects.tx.read(), id);
+ return h.request('POST', `/issueOwnerHandoffs/${id}/${decision}`, {
+ user,
+ body: { notificationKey: key ?? handoff!.notificationKey },
+ });
+}
+
+describe('owner acceptance', () => {
+ it('withdraws all queued work, stops held work, skips comments and release events, and resumes only the manager with comment delta', async () => {
+ const issue = await task();
+ await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
+ user: 'alice',
+ });
+ const original = await h.claimOne();
+ const worker = await h.createAgent({ name: 'Worker' });
+ await h.projects.comments.create(h.viewer('alice'), issue.id, {
+ content: `[@Worker](mention://agent/${worker}) queued step`,
+ });
+ const before = await runs(issue.id);
+ await transfer(issue.id);
+ expect(
+ (await h.agents.runs.get(original.run.id)).cancelRequestedAt,
+ ).not.toBeNull();
+ expect(
+ (await runs(issue.id)).filter((run) => run.status === 'queued'),
+ ).toEqual([]);
+ await h.projects.comments.create(h.viewer('carol'), issue.id, {
+ content: `[@Worker](mention://agent/${worker}) waiting-period-comment`,
+ });
+ const current = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ const work = createAgentWork({
+ agents: h.agents,
+ projects: () => h.projects,
+ });
+ await h.projects.tx.run(async (tx) => {
+ expect(
+ await work.onUnblocked!(tx, { issue: current, releasedBy: current }),
+ ).toMatchObject([{ skipped: 'waitingOwner' }]);
+ expect(
+ await work.onSubtasksFinished!(tx, {
+ parent: current,
+ stage: null,
+ childIssueIds: [],
+ }),
+ ).toMatchObject([{ skipped: 'waitingOwner' }]);
+ });
+ expect(await runs(issue.id)).toHaveLength(before.length);
+ expect(
+ (
+ await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
+ user: 'bob',
+ })
+ ).body.error.reason,
+ ).toBe('ISSUE_OWNER_HANDOFF_PENDING');
+ expect(
+ (await h.request('GET', `/issueProgress/${issue.id}`, { user: 'alice' }))
+ .body.data.waiting,
+ ).toMatchObject({ reason: 'waitingOwner', owner: { id: 'bob' } });
+ expect(
+ current.activities.some(
+ (activity) =>
+ activity.action === 'work_skipped' &&
+ activity.details.reason === 'waitingOwner',
+ ),
+ ).toBe(true);
+ await expect(
+ h.agents.runs.retry(
+ before.find((run) => run.status === 'queued')!.id,
+ 'bob',
+ ),
+ ).rejects.toBeDefined();
+ expect((await decide(issue.id, 'accept')).status).toBe(200);
+ expect((await decide(issue.id, 'accept')).status).toBe(409);
+ const newRuns = (await runs(issue.id)).filter(
+ (run) => run.status === 'queued',
+ );
+ expect(newRuns).toHaveLength(1);
+ expect(newRuns[0]).toMatchObject({
+ agentId: PROJECT_MANAGER_ID,
+ actorUserId: 'bob',
+ });
+ await h.runner(RUNNER_ROUTES.cancelAck, original.run.id, {});
+ const claimed = await h.claimOne();
+ expect(claimed.run.id).toBe(newRuns[0].id);
+ expect(JSON.stringify(claimed)).toContain('waiting-period-comment');
+ });
+
+ it('keeps rejection paused and notifies the original owner and actual transfer user', async () => {
+ const issue = await task();
+ const events: Parameters<
+ ReturnType
+ >[0]['events'][number][] = [];
+ h.projects.events.on('work.announced', (event) => {
+ if (event.kind === OWNER_HANDOFF_NOTICE) events.push(event);
+ });
+ await transfer(issue.id, 'bob', 'carol');
+ expect((await decide(issue.id, 'reject')).status).toBe(200);
+ expect(
+ await readOwnerHandoff(h.projects.tx.read(), issue.id),
+ ).toMatchObject({
+ waiting: true,
+ ownerUserId: 'bob',
+ rejectedAt: expect.any(String),
+ });
+ await h.projects.comments.create(h.viewer('alice'), issue.id, {
+ content: 'Do not resume yet',
+ });
+ expect(await runs(issue.id)).toEqual([]);
+ const notices = await h.projects.tx.run((tx) =>
+ ownerHandoffNoticeRule()({
+ tx,
+ events,
+ subscribers: async () => [],
+ nameOf: async () => null,
+ }),
+ );
+ expect(
+ notices.find((notice) => notice.type === 'owner_handoff')?.userIds,
+ ).toEqual(['bob']);
+ expect(
+ notices.find((notice) => notice.type === 'owner_handoff_rejected')
+ ?.userIds,
+ ).toEqual(['alice', 'carol']);
+ expect(h.port.settled).toContainEqual({
+ decisionKey: `owner-handoff:${(await readOwnerHandoff(h.projects.tx.read(), issue.id))!.notificationKey}`,
+ outcome: 'rejected',
+ });
+ });
+
+ it('checks authentication, issue permission, actual human identity and stale notices including ABA', async () => {
+ const issue = await task();
+ await transfer(issue.id);
+ const first = (await readOwnerHandoff(h.projects.tx.read(), issue.id))!;
+ expect(
+ (
+ await h.request('POST', `/issueOwnerHandoffs/${issue.id}/accept`, {
+ body: { notificationKey: first.notificationKey },
+ })
+ ).status,
+ ).toBe(401);
+ expect((await decide(issue.id, 'accept', 'alice')).status).toBe(403);
+ h.roles.set('bob', 'none');
+ expect((await decide(issue.id, 'accept')).status).toBe(403);
+ h.roles.set('bob', 'admin');
+ const other = await h.projects.issues.create(h.viewer('bob'), {
+ title: 'Actual Bob run',
+ statusKey: 'in_progress',
+ start: false,
+ });
+ await h.request('POST', `/projectManager/issues/${other.id}/start`, {
+ user: 'bob',
+ });
+ const claimed = await h.claimOne();
+ expect(
+ (
+ await h.request('POST', `/issueOwnerHandoffs/${issue.id}/accept`, {
+ runToken: claimed.cli.credential.content.token,
+ body: { notificationKey: first.notificationKey },
+ })
+ ).status,
+ ).toBe(403);
+ await transfer(issue.id, 'carol');
+ await transfer(issue.id, 'bob');
+ expect(
+ (await decide(issue.id, 'accept', 'bob', first.notificationKey)).status,
+ ).toBe(409);
+ expect(
+ (await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
+ ).toBe(true);
+ });
+
+ it('does not replace the first start when accepting or claiming a backlog task', async () => {
+ const issue = await task('backlog');
+ await transfer(issue.id);
+ const pending = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ await expect(
+ h.projects.issues.update(h.viewer('bob'), issue.id, {
+ revision: pending.revision,
+ statusKey: 'in_progress',
+ }),
+ ).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_PENDING' });
+ expect(
+ (await h.projects.issueQueries.detail(h.viewer('bob'), issue.id))
+ .statusKey,
+ ).toBe('backlog');
+ expect((await decide(issue.id, 'accept')).status).toBe(200);
+ await h.projects.comments.create(h.viewer('bob'), issue.id, {
+ content: 'Still requires start',
+ });
+ expect(await runs(issue.id)).toEqual([]);
+ await transfer(issue.id, 'alice', 'alice');
+ expect(
+ (await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
+ ).toBe(false);
+ expect(await runs(issue.id)).toEqual([]);
+ });
+
+ it('allows a human owner to claim their already assigned, rejected handoff', async () => {
+ const issue = await task();
+ await transfer(issue.id);
+ await decide(issue.id, 'reject');
+ await transfer(issue.id, 'bob', 'bob');
+ expect(await runs(issue.id)).toHaveLength(1);
+ expect(
+ (await readOwnerHandoff(h.projects.tx.read(), issue.id))!.waiting,
+ ).toBe(false);
+ await h.projects.comments.create(h.viewer('alice'), issue.id, {
+ content: 'Continue after the claim',
+ });
+ expect((await runs(issue.id))[0]).toMatchObject({
+ agentId: PROJECT_MANAGER_ID,
+ actorUserId: 'bob',
+ });
+ });
+
+ it.each(['pending', 'rejected'] as const)(
+ 'clears an unassigned %s handoff on a human claim and resumes only the owner manager',
+ async (state) => {
+ const issue = await task();
+ await h.projects.issues.update(h.viewer('alice'), issue.id, {
+ revision: issue.revision,
+ ownerUserId: 'bob',
+ executor: null,
+ });
+ if (state === 'rejected') await decide(issue.id, 'reject');
+ const before = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ const response = await h.request(
+ 'PATCH',
+ `/projects/issues/${issue.id}`,
+ {
+ user: 'bob',
+ body: { revision: before.revision, ownerUserId: 'bob' },
+ },
+ );
+ expect(response.status).toBe(200);
+ const returned = response.body.data.issue;
+ const stored = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ expect(returned.revision).toBe(stored.revision);
+ expect(returned.executor).toEqual(stored.executor);
+ const edited = await h.request('PATCH', `/projects/issues/${issue.id}`, {
+ user: 'bob',
+ body: { revision: returned.revision, title: 'Immediate edit' },
+ });
+ expect(edited.status).toBe(200);
+ expect(
+ await readOwnerHandoff(h.projects.tx.read(), issue.id),
+ ).toMatchObject({ waiting: false, rejectedAt: null });
+ const after = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ expect(after.executor).toEqual({ type: 'agent', id: PROJECT_MANAGER_ID });
+ expect(await runs(issue.id)).toMatchObject([
+ { agentId: PROJECT_MANAGER_ID, actorUserId: 'bob', status: 'queued' },
+ ]);
+ expect(await runs(issue.id)).toHaveLength(1);
+ expect((await decide(issue.id, 'accept')).status).toBe(409);
+ await transfer(issue.id, 'bob', 'bob');
+ expect(await runs(issue.id)).toHaveLength(1);
+ },
+ );
+
+ it('does not let another person or an agent claim an unassigned handoff for its owner', async () => {
+ const issue = await task();
+ await h.projects.issues.update(h.viewer('alice'), issue.id, {
+ revision: issue.revision,
+ ownerUserId: 'bob',
+ executor: null,
+ });
+ for (const viewer of [
+ h.viewer('alice'),
+ {
+ ...h.viewer('bob'),
+ actor: { type: 'user', id: 'bob', via: 'agent' } as const,
+ },
+ ]) {
+ const current = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ await h.projects.issues.update(viewer, issue.id, {
+ revision: current.revision,
+ ownerUserId: 'bob',
+ });
+ expect(
+ await readOwnerHandoff(h.projects.tx.read(), issue.id),
+ ).toMatchObject({ waiting: true });
+ expect(await runs(issue.id)).toEqual([]);
+ }
+ });
+
+ it('clears an unassigned backlog handoff without replacing the first start', async () => {
+ const issue = await task('backlog');
+ await h.projects.issues.update(h.viewer('alice'), issue.id, {
+ revision: issue.revision,
+ ownerUserId: 'bob',
+ executor: null,
+ });
+ await transfer(issue.id, 'bob', 'bob');
+ expect(
+ await readOwnerHandoff(h.projects.tx.read(), issue.id),
+ ).toMatchObject({ waiting: false, rejectedAt: null });
+ expect(await runs(issue.id)).toEqual([]);
+ await h.projects.comments.create(h.viewer('bob'), issue.id, {
+ content: 'Still not started',
+ });
+ expect(await runs(issue.id)).toEqual([]);
+ });
+
+ it('keeps the pause when the executor is cleared and restores the manager on acceptance', async () => {
+ const issue = await task();
+ await h.projects.issues.update(h.viewer('alice'), issue.id, {
+ revision: issue.revision,
+ ownerUserId: 'bob',
+ executor: null,
+ });
+ await h.projects.comments.create(h.viewer('alice'), issue.id, {
+ content: 'Comment with no executor',
+ });
+ const before = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ expect(
+ before.activities.some(
+ (activity) =>
+ activity.action === 'work_skipped' &&
+ activity.details.reason === 'waitingOwner',
+ ),
+ ).toBe(true);
+ expect((await decide(issue.id, 'accept')).status).toBe(200);
+ const after = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ expect(after.executor).toEqual({ type: 'agent', id: PROJECT_MANAGER_ID });
+ expect((await runs(issue.id))[0]).toMatchObject({
+ actorUserId: 'bob',
+ agentId: PROJECT_MANAGER_ID,
+ });
+ });
+
+ it('refuses a legacy queued attempt returned after transfer at claim time', async () => {
+ const issue = await task();
+ await h.request('POST', `/projectManager/issues/${issue.id}/start`, {
+ user: 'alice',
+ });
+ const [run] = await runs(issue.id);
+ await transfer(issue.id);
+ await h.database
+ .connection()
+ .repository('agRuns')
+ .updateOne({
+ filter: { id: run.id },
+ values: {
+ status: 'queued',
+ attempt: 2,
+ dispatchedAt: new Date().toISOString(),
+ },
+ });
+ expect(await h.claimOne()).toBeUndefined();
+ expect((await h.agents.runs.get(run.id)).status).toBe('cancelled');
+ });
+
+ it('keeps CI and PR signals in activity while skipping agent work', async () => {
+ const issue = await task();
+ const repo = await connectedRepo(h, 'acme/handoff');
+ h.github.addPull('acme/handoff', {
+ number: 1,
+ head: { ref: `agent/${issue.identifier}`, sha: 'head1' },
+ });
+ await h.git.link(
+ h.viewer('alice'),
+ issue.id,
+ 'https://github.com/acme/handoff/pull/1',
+ { type: 'user', id: 'alice' },
+ );
+ await transfer(issue.id);
+ h.github.setStatus('acme/handoff', 'head1', 'failure');
+ await h.git.pollRepo(repo);
+ h.github.pull('acme/handoff', 1).mergeable_state = 'dirty';
+ await h.git.pollRepo(repo);
+ h.github.setStatus('acme/handoff', 'head1', 'success');
+ await h.git.pollRepo(repo);
+ expect(
+ (await runs(issue.id)).filter((run) => run.status === 'queued'),
+ ).toEqual([]);
+ const detail = await h.projects.issueQueries.detail(
+ h.viewer('bob'),
+ issue.id,
+ );
+ expect(
+ detail.activities.filter(
+ (activity) =>
+ activity.action === 'work_skipped' &&
+ activity.details.reason === 'waitingOwner',
+ ).length,
+ ).toBeGreaterThanOrEqual(3);
+ });
+});
diff --git a/packages/apps/studio/tests/components/follow-ups.test.tsx b/packages/apps/studio/tests/components/follow-ups.test.tsx
index f0b4dacd332..741ffcb312b 100644
--- a/packages/apps/studio/tests/components/follow-ups.test.tsx
+++ b/packages/apps/studio/tests/components/follow-ups.test.tsx
@@ -237,6 +237,34 @@ const card = (rail: HTMLElement, identifier: string) =>
rail.querySelector(`[data-issue="${identifier}"]`)!;
describe('My follow-ups', () => {
+ it('keeps handoff activities readable after sharing the issue activity wording', async () => {
+ const original = mocks.issue.getMockImplementation()!;
+ mocks.issue.mockImplementation(async (id: string) => ({
+ ...(await original(id)),
+ activities: [
+ 'studio_owner_transferred',
+ 'studio_owner_accepted',
+ 'studio_owner_rejected',
+ ].map((action, index) => ({
+ id: `handoff-${index}`,
+ actorType: 'user',
+ actorId: 'alice',
+ actorName: 'Alice',
+ action,
+ details: action === 'work_skipped' ? { reason: 'waitingOwner' } : {},
+ via: null,
+ createdAt: at(index + 4),
+ })),
+ }));
+ await mount();
+ const panel = await screen.findByTestId('follow-ups-activity');
+ await waitFor(() =>
+ expect(panel.textContent).toContain('transferred responsibility'),
+ );
+ expect(panel.textContent).toContain('accepted responsibility');
+ expect(panel.textContent).toContain('rejected the handoff');
+ });
+
it('shows the followed issues, what needs the viewer and what is new, with the first one open', async () => {
const rail = await mount();
const first = card(rail, 'WEB-5');
diff --git a/packages/apps/studio/tests/components/owner-handoff.test.tsx b/packages/apps/studio/tests/components/owner-handoff.test.tsx
new file mode 100644
index 00000000000..466c744b1dd
--- /dev/null
+++ b/packages/apps/studio/tests/components/owner-handoff.test.tsx
@@ -0,0 +1,129 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ createTestI18nRuntime,
+ TestI18nProvider,
+} from '@nocobase/i18n/testing';
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { afterEach, expect, it, vi } from 'vitest';
+import enUS from '../../client/locales/en-US.js';
+import zhCN from '../../client/locales/zh-CN.js';
+import { OwnerHandoffActions } from '../../client/issues/detail/owner-handoff.js';
+
+const mocks = vi.hoisted(() => ({ request: vi.fn(), show: vi.fn() }));
+vi.mock('@nocobase/app-client', async (original) => ({
+ ...(await original()),
+ useApiClient: () => ({ request: mocks.request }),
+ useToaster: () => ({ show: mocks.show }),
+}));
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+async function mount(
+ canDecide = true,
+ expected?: string,
+ language: 'en-US' | 'zh-CN' = 'zh-CN',
+ rejected = false,
+) {
+ mocks.request.mockResolvedValue({
+ data: {
+ canDecide,
+ canClaim: canDecide || rejected,
+ handoff: {
+ waiting: true,
+ notificationKey: 'current',
+ rejectedAt: rejected ? '2026-10-12T00:00:00Z' : null,
+ ownerUserId: 'bob',
+ },
+ },
+ });
+ const runtime = await createTestI18nRuntime({
+ application: {
+ namespace: 'studio',
+ resources: language === 'en-US' ? enUS : zhCN,
+ },
+ locale: language,
+ });
+ const query = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ render(
+
+
+
+
+ ,
+ );
+}
+it.each(['en-US', 'zh-CN'] as const)(
+ 'submits the current decision and renders both actions in %s',
+ async (language) => {
+ await mount(true, undefined, language);
+ const accept = await screen.findByRole('button', {
+ name: language === 'en-US' ? 'Take over' : '接手',
+ });
+ expect(
+ screen.getByRole('button', {
+ name: language === 'en-US' ? 'Reject' : '拒绝',
+ }),
+ ).toBeDefined();
+ fireEvent.click(accept);
+ await waitFor(() =>
+ expect(mocks.request).toHaveBeenCalledWith({
+ method: 'POST',
+ path: 'issueOwnerHandoffs/i1/accept',
+ json: { notificationKey: 'current' },
+ }),
+ );
+ },
+);
+it('hides decisions for an unauthorized viewer and explains a stale inbox item', async () => {
+ await mount(false);
+ await waitFor(() => expect(mocks.request).toHaveBeenCalled());
+ expect(screen.queryByRole('button')).toBeNull();
+ cleanup();
+ await mount(true, 'old');
+ expect(
+ await screen.findByText('此接手请求已失效,请刷新任务。'),
+ ).toBeDefined();
+ expect(screen.queryByRole('button')).toBeNull();
+});
+it('submits rejection without removing the waiting marker locally', async () => {
+ await mount();
+ fireEvent.click(await screen.findByRole('button', { name: '拒绝' }));
+ await waitFor(() =>
+ expect(mocks.request).toHaveBeenCalledWith({
+ method: 'POST',
+ path: 'issueOwnerHandoffs/i1/reject',
+ json: { notificationKey: 'current' },
+ }),
+ );
+});
+
+it('lets the owner explicitly claim a rejected handoff using the issue revision', async () => {
+ await mount(false, undefined, 'zh-CN', true);
+ const claim = await screen.findByRole('button', { name: '认领' });
+ mocks.request.mockImplementation(
+ (request: { method?: string; path: string }) =>
+ Promise.resolve({
+ data:
+ request.path === 'projects/issues/i1'
+ ? { revision: 4 }
+ : { canDecide: false, handoff: null },
+ }),
+ );
+ fireEvent.click(claim);
+ await waitFor(() =>
+ expect(mocks.request).toHaveBeenCalledWith({
+ method: 'PATCH',
+ path: 'projects/issues/i1',
+ json: { revision: 4, ownerUserId: 'bob' },
+ }),
+ );
+});
diff --git a/packages/apps/studio/tests/git/connections.test.ts b/packages/apps/studio/tests/git/connections.test.ts
index 5c44f441ab0..6a5e7c53b40 100644
--- a/packages/apps/studio/tests/git/connections.test.ts
+++ b/packages/apps/studio/tests/git/connections.test.ts
@@ -1223,7 +1223,12 @@ describe('push credentials on demand', () => {
async function claimed(features: readonly string[] = ON_DEMAND) {
const app = await appConnection();
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
- const linked = await projectWithRepo(app);
+ const linked = await projectWithRepo(app, 'in_progress');
+ linked.issue = await h.projects.issues.update(alice(), linked.issue.id, {
+ revision: linked.issue.revision,
+ executor: null,
+ start: false,
+ });
await mentionAgent(h, 'alice', linked.issue.id, agentId);
const payload = await h.claimOne(features);
return { app, payload, ...linked };
@@ -1263,7 +1268,12 @@ describe('push credentials on demand', () => {
it('lists nothing on demand for a token connection', async () => {
const connection = await tokenConnection(h);
- const { issue } = await projectWithRepo(connection);
+ const { issue: created } = await projectWithRepo(connection, 'in_progress');
+ const issue = await h.projects.issues.update(alice(), created.id, {
+ revision: created.revision,
+ executor: null,
+ start: false,
+ });
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
await mentionAgent(h, 'alice', issue.id, agentId);
const payload = await h.claimOne(ON_DEMAND);
diff --git a/packages/apps/studio/tests/git/required-checks.test.ts b/packages/apps/studio/tests/git/required-checks.test.ts
index c664974c234..58ed3490f60 100644
--- a/packages/apps/studio/tests/git/required-checks.test.ts
+++ b/packages/apps/studio/tests/git/required-checks.test.ts
@@ -228,6 +228,12 @@ describe('required and reference checks', () => {
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
const issue = await h.projects.issues.create(alice(), {
title: 'Read checks',
+ statusKey: 'in_progress',
+ start: false,
+ });
+ await h.projects.issues.update(alice(), issue.id, {
+ revision: issue.revision,
+ executor: null,
start: false,
});
await repoWithReference();
diff --git a/packages/apps/studio/tests/logic/owner-handoff-migration.test.ts b/packages/apps/studio/tests/logic/owner-handoff-migration.test.ts
new file mode 100644
index 00000000000..083d100babb
--- /dev/null
+++ b/packages/apps/studio/tests/logic/owner-handoff-migration.test.ts
@@ -0,0 +1,142 @@
+// @vitest-environment node
+import path from 'node:path';
+
+import { describeMigration } from '@nocobase/app-testing/server';
+import { expect } from 'vitest';
+
+import {
+ decideOwnerHandoff,
+ ownerHandoffRejectionRecipients,
+ ownerHandoffWaiting,
+ readOwnerHandoff,
+ recordOwnerHandoff,
+} from '../../server/issues/owner-handoff.js';
+
+describeMigration('202610240040_studio_issue_owner_handoffs', {
+ sources: [
+ {
+ packageName: 'projects-issues-fixture',
+ directory: path.resolve(
+ import.meta.dirname,
+ '../fixtures/projects-issues',
+ ),
+ },
+ {
+ packageName: '@nocobase/studio',
+ directory: path.resolve(
+ import.meta.dirname,
+ '../../database/main/migrations',
+ ),
+ },
+ ],
+ up: async ({ connection, expectCollection }) => {
+ await expectCollection('studioIssueOwnerHandoffs').toHaveField('waiting');
+ await expectCollection('studioIssueOwnerHandoffs').toHaveField(
+ 'notificationKey',
+ );
+ const issue = { id: 'handoff', ownerUserId: 'bob' };
+ expect(await readOwnerHandoff(connection, issue.id)).toBeNull();
+ expect(await ownerHandoffWaiting(connection, issue)).toBe(false);
+ const transfer = {
+ issueId: issue.id,
+ ownerUserId: 'bob',
+ previousOwnerUserId: 'alice',
+ transferredByUserId: 'carol',
+ authenticatedPersonUserId: 'carol',
+ };
+ const first = await recordOwnerHandoff(connection, transfer);
+ expect(await ownerHandoffWaiting(connection, issue)).toBe(true);
+ expect(await readOwnerHandoff(connection, issue.id)).toMatchObject(first);
+
+ // A run acting for Bob cannot make Bob's decision, and Alice cannot decide for Bob.
+ for (const authenticatedPersonUserId of [null, 'alice']) {
+ await expect(
+ decideOwnerHandoff(connection, {
+ issue,
+ authenticatedPersonUserId,
+ notificationKey: first.notificationKey,
+ decision: 'accept',
+ }),
+ ).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_REQUIRED' });
+ }
+ const rejected = await decideOwnerHandoff(connection, {
+ issue,
+ authenticatedPersonUserId: 'bob',
+ notificationKey: first.notificationKey,
+ decision: 'reject',
+ });
+ expect(rejected.waiting).toBe(true);
+ expect(rejected.ownerUserId).toBe('bob');
+ expect(rejected.rejectedAt).not.toBeNull();
+ expect(ownerHandoffRejectionRecipients(rejected)).toEqual([
+ 'alice',
+ 'carol',
+ ]);
+ expect(
+ ownerHandoffRejectionRecipients({
+ ...rejected,
+ transferredByUserId: 'alice',
+ }),
+ ).toEqual(['alice']);
+ await expect(
+ decideOwnerHandoff(connection, {
+ issue,
+ authenticatedPersonUserId: 'bob',
+ notificationKey: first.notificationKey,
+ decision: 'accept',
+ }),
+ ).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
+
+ // The current owner and current notice are checked independently: even an ABA transfer cannot reuse the old notice.
+ await recordOwnerHandoff(connection, { ...transfer, ownerUserId: 'dave' });
+ const latest = await recordOwnerHandoff(connection, transfer);
+ expect(latest.notificationKey).not.toBe(first.notificationKey);
+ await expect(
+ decideOwnerHandoff(connection, {
+ issue,
+ authenticatedPersonUserId: 'bob',
+ notificationKey: first.notificationKey,
+ decision: 'accept',
+ }),
+ ).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
+ await expect(
+ decideOwnerHandoff(connection, {
+ issue: { ...issue, ownerUserId: 'dave' },
+ authenticatedPersonUserId: 'dave',
+ notificationKey: latest.notificationKey,
+ decision: 'accept',
+ }),
+ ).rejects.toMatchObject({ reason: 'ISSUE_OWNER_HANDOFF_STALE' });
+ const accepted = await decideOwnerHandoff(connection, {
+ issue,
+ authenticatedPersonUserId: 'bob',
+ notificationKey: latest.notificationKey,
+ decision: 'accept',
+ });
+ expect(accepted.waiting).toBe(false);
+ expect(await ownerHandoffWaiting(connection, issue)).toBe(false);
+ expect(
+ await ownerHandoffWaiting(connection, { ...issue, ownerUserId: 'dave' }),
+ ).toBe(true);
+
+ const claimed = await recordOwnerHandoff(connection, {
+ ...transfer,
+ authenticatedPersonUserId: 'bob',
+ transferredByUserId: 'bob',
+ });
+ expect(claimed.waiting).toBe(false);
+ // Agent and automatic assignment still need confirmation, even when the mapped user is the new owner.
+ expect(
+ (
+ await recordOwnerHandoff(connection, {
+ ...transfer,
+ transferredByUserId: 'bob',
+ authenticatedPersonUserId: null,
+ })
+ ).waiting,
+ ).toBe(true);
+ },
+ down: async ({ expectCollection }) => {
+ await expectCollection('studioIssueOwnerHandoffs').not.toExist();
+ },
+});
diff --git a/packages/plugins/app-plugin-projects/server/domains/issues/issue.service.ts b/packages/plugins/app-plugin-projects/server/domains/issues/issue.service.ts
index 0eacec7f6bb..bfb6f0484b0 100644
--- a/packages/plugins/app-plugin-projects/server/domains/issues/issue.service.ts
+++ b/packages/plugins/app-plugin-projects/server/domains/issues/issue.service.ts
@@ -207,8 +207,11 @@ export function createIssueService(deps: IssueDeps): IssueService {
after: Issue,
actor: Actor,
start: boolean,
+ ownerClaimed = false,
): Promise {
- await deps.triggers().onIssueChanged(tx, { before, after, actor, start });
+ await deps
+ .triggers()
+ .onIssueChanged(tx, { before, after, actor, start, ownerClaimed });
await deps.relations().changed(tx, { before, after, actor });
tx.emit({ type: 'issue.changed', issueId: after.id });
for (const parentId of new Set([before.parentIssueId, after.parentIssueId]))
@@ -313,6 +316,11 @@ export function createIssueService(deps: IssueDeps): IssueService {
return deps.tx.run(async (tx) => {
const before = await requireVisible(tx.conn, viewer, idOrKey);
requireEditor(viewer);
+ const ownerClaimed =
+ patch.ownerUserId === before.ownerUserId &&
+ viewer.actor.type === 'user' &&
+ viewer.actor.via !== 'agent' &&
+ viewer.actor.id === before.ownerUserId;
// Status and project changes can release waits. Take the graph guard
// before this issue's first write, in the same order as relationship edits and plan execution.
if (
@@ -343,7 +351,7 @@ export function createIssueService(deps: IssueDeps): IssueService {
: { added: [], removed: [] };
if (labels.added.length > 0 || labels.removed.length > 0)
activities.push({ action: 'labels_changed', details: labels });
- if (activities.length === 0) return;
+ if (activities.length === 0 && !ownerClaimed) return;
const now = new Date().toISOString();
await updateIssue(
tx.conn,
@@ -367,8 +375,16 @@ export function createIssueService(deps: IssueDeps): IssueService {
if (!written) return before;
const after = await reload(tx, before.id);
if (placed) await deps.relations().placed(tx, after);
- await announce(tx, before, after, viewer.actor, patch.start !== false);
- return after;
+ await announce(
+ tx,
+ before,
+ after,
+ viewer.actor,
+ patch.start !== false,
+ ownerClaimed,
+ );
+ // Work hooks may update the issue again within this transaction.
+ return reload(tx, before.id);
});
},
diff --git a/packages/plugins/app-plugin-projects/server/domains/issues/ports.ts b/packages/plugins/app-plugin-projects/server/domains/issues/ports.ts
index df84345f2bc..73d2ebc02e2 100644
--- a/packages/plugins/app-plugin-projects/server/domains/issues/ports.ts
+++ b/packages/plugins/app-plugin-projects/server/domains/issues/ports.ts
@@ -55,6 +55,8 @@ export interface StatusService {
}
export interface IssueChange {
+ /** A person explicitly assigned the issue to themselves, including an unchanged owner field. */
+ readonly ownerClaimed?: boolean;
/** Null for a new issue. */
readonly before: Issue | null;
readonly after: Issue;
diff --git a/packages/plugins/app-plugin-projects/server/kernel/work.ts b/packages/plugins/app-plugin-projects/server/kernel/work.ts
index 649658d9138..6835ef7735d 100644
--- a/packages/plugins/app-plugin-projects/server/kernel/work.ts
+++ b/packages/plugins/app-plugin-projects/server/kernel/work.ts
@@ -56,6 +56,7 @@ declare module './events.js' {
* principals' comments started as many runs on the issue as the kind allows.
*/
export type RunAttemptSkip =
+ | 'waitingOwner'
| 'deferred'
| 'limited'
| 'denied'
@@ -96,6 +97,10 @@ export interface WorkWithdrawal {
* that transaction is a rehearsal (`tx.rehearsal`), it only answers what it would start.
*/
export interface IssueWorkHandler {
+ /** Receives comments even when the executor was cleared, for issue-wide pause policies. */
+ readonly observesComments?: boolean;
+ /** Receives trusted owner claims even when the executor is cleared. */
+ readonly observesOwnerClaims?: boolean;
/** A new issue, or an update (including a status moved by a status event). */
onIssueChanged(tx: Tx, change: IssueChange): Promise;
/**
@@ -199,6 +204,7 @@ const blockerRefs = (blockers: readonly Blocker[]) =>
/** The skips recorded on the issue's activity. */
const RECORDED_SKIPS: ReadonlySet = new Set([
+ 'waitingOwner',
'blocked',
'dormant',
'limited',
@@ -264,12 +270,22 @@ export function kindTriggers(
for (const [, work] of handlersOf(kinds, [
before?.executor?.type,
after.executor?.type,
+ ...(change.ownerClaimed
+ ? kinds
+ .list()
+ .filter((kind) => kind.work?.observesOwnerClaims)
+ .map((kind) => kind.key)
+ : []),
]))
await report(tx, after, work.onIssueChanged(tx, change));
},
async onCommentCreated(tx, change) {
const concerned = handlersOf(kinds, [
+ ...kinds
+ .list()
+ .filter((kind) => kind.work?.observesComments)
+ .map((kind) => kind.key),
change.issue.executor?.type,
change.parent?.authorType,
...change.mentions.map((ref) => ref.kind),
diff --git a/packages/plugins/app-plugin-projects/tests/domains/work.test.ts b/packages/plugins/app-plugin-projects/tests/domains/work.test.ts
index d5270eb3325..70df6798b6c 100644
--- a/packages/plugins/app-plugin-projects/tests/domains/work.test.ts
+++ b/packages/plugins/app-plugin-projects/tests/domains/work.test.ts
@@ -21,7 +21,13 @@ interface Seen {
}
/** A kind whose work handler records what it hears and starts work for every call, unless told to refuse. */
-function addBots(options: { refuse?: boolean } = {}): Seen[] {
+function addBots(
+ options: {
+ refuse?: boolean;
+ observesOwnerClaims?: boolean;
+ changed?: IssueWorkHandler['onIssueChanged'];
+ } = {},
+): Seen[] {
const seen: Seen[] = [];
const attempt = (
subjectId: string,
@@ -38,7 +44,9 @@ function addBots(options: { refuse?: boolean } = {}): Seen[] {
},
];
const work: IssueWorkHandler = {
+ observesOwnerClaims: options.observesOwnerClaims,
onIssueChanged(_tx, change) {
+ if (options.changed) return options.changed(_tx, change);
if (options.refuse) return Promise.reject(new Error('No runner.'));
seen.push({
hook: 'changed',
@@ -86,6 +94,72 @@ function addBots(options: { refuse?: boolean } = {}): Seen[] {
return seen;
}
+it('delivers only trusted owner claims to an opt-in work kind without an executor', async () => {
+ const seen = addBots({ observesOwnerClaims: true });
+ const issue = await h.services.issues.create(alice(), {
+ title: 'Claim',
+ executor: null,
+ });
+ seen.length = 0;
+ await h.services.issues.update(alice(), issue.id, {
+ revision: issue.revision,
+ title: 'Ordinary change',
+ });
+ expect(seen).toEqual([]);
+ const current = await h.services.issueQueries.detail(alice(), issue.id);
+ await h.services.issues.update(alice(), issue.id, {
+ revision: current.revision,
+ ownerUserId: 'alice',
+ });
+ expect(seen.filter((event) => event.hook === 'changed')).toHaveLength(1);
+});
+
+it('returns the final issue after ordinary updates and nested work-hook updates', async () => {
+ const calls: string[] = [];
+ addBots({
+ changed: async (tx, change) => {
+ calls.push(change.after.title);
+ if (change.after.title === 'Outer update')
+ await h.services.tx.savepoint(tx, () =>
+ h.services.issues.update(alice(), change.after.id, {
+ revision: change.after.revision,
+ title: 'Hook update',
+ start: false,
+ }),
+ );
+ return [];
+ },
+ });
+ const issue = await h.services.issues.create(alice(), {
+ title: 'Initial',
+ executor: { type: 'bot', id: 'b1' },
+ });
+ calls.length = 0;
+ const ordinary = await h.services.issues.update(alice(), issue.id, {
+ revision: issue.revision,
+ title: 'Ordinary update',
+ });
+ expect(ordinary).toMatchObject({
+ title: 'Ordinary update',
+ revision: issue.revision + 1,
+ });
+ const nested = await h.services.issues.update(alice(), issue.id, {
+ revision: ordinary.revision,
+ title: 'Outer update',
+ });
+ const stored = await h.services.issueQueries.detail(alice(), issue.id);
+ expect(nested).toMatchObject({
+ title: 'Hook update',
+ revision: ordinary.revision + 2,
+ });
+ expect(nested.revision).toBe(stored.revision);
+ expect(nested.title).toBe(stored.title);
+ expect(calls).toEqual(['Ordinary update', 'Outer update', 'Hook update']);
+ expect(
+ stored.activities.filter((entry) => entry.action === 'title_changed'),
+ ).toHaveLength(3);
+});
+
describe('work handlers of registered kinds', () => {
it('hears an issue given to its kind, in the same transaction, and reports the attempt', async () => {
const seen = addBots();