fix(studio): run project manager work as issue owner (#10800)

* fix(studio): run project manager work as issue owner

* fix(studio): require issue owner to start project manager

* fix(studio): cancel agent work on owner transfer

* fix(studio): preserve manager identity across retries

* fix(studio): clarify owner transfer handoff
This commit is contained in:
YANG QIA
2026-10-13 06:32:58 +08:00
committed by GitHub
parent 3af6c302fd
commit 6fc0af29e2
55 changed files with 2056 additions and 146 deletions
+11
View File
@@ -0,0 +1,11 @@
---
'@nocobase/studio': minor
'@nocobase/app-plugin-agents': minor
'@nocobase/app-plugin-projects': minor
---
Require the issue owner to start or continue Studio project manager work, and ignore agent wakeups from comments on unstarted issues. Run the manager and its delegated steps as the current owner while preserving trigger audit information. Withdraw all queued agent work and request cancellation of held runs on owner transfers, without automatically restarting them. Correct legacy queued identities before claim and expose waiting reasons.
Expose the existing transactional `Projects.activity` recorder for application issue events and deliver owner transfers to all work kinds before ordinary triggers, including executor clearing. Restore Studio wording for models, efforts, the start dialog and historical sub-issue activity.
Preserve delegated execution identity throughout repeated retries, including handled inputs, and show the initiator of the current retry instead of copied retry history.
@@ -5,6 +5,7 @@
* is the agents plugin's own (its namespace), filled into the item's labels here.
*/
import { useClientApplication } from '@nocobase/app-client';
import { runTriggerActor } from './run-trigger.js';
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
import type { SubjectRun } from '@nocobase/app-plugin-agents/client/runs';
import {
@@ -621,13 +622,14 @@ export function IssueRunTranscript({
: null;
// The entry of the agent's list the run worked with: a coding tool and model, or a model service and model, and its effort.
const effort = detail?.effort
? ` · ${t(`agentForm.efforts.${detail.effort}`, { defaultValue: detail.effort })}`
? ` · ${appT(`runTranscript.efforts.${detail.effort}`, { defaultValue: detail.effort })}`
: '';
const used = detail?.tool
? `${t(`tools.${detail.tool}`)} · ${detail.model ?? t('agents.defaultModel')}${effort}`
? `${t(`tools.${detail.tool}`)} · ${detail.model ?? appT('runTranscript.defaultModel')}${effort}`
: detail?.modelService
? `${detail.modelService} · ${detail.model ?? ''}${effort}`
: null;
const triggerActor = detail ? runTriggerActor(detail) : undefined;
const summary = detail ? (
<>
<p className='text-xs text-muted-foreground'>
@@ -637,9 +639,15 @@ export function IssueRunTranscript({
})}
{used ? (
<span data-testid='run-model'>
{` · ${t('modelEntries.used', { model: used })}`}
{` · ${appT('runTranscript.usedModel', { model: used })}`}
</span>
) : null}
{` · ${appT('runTranscript.actingAs', { actor: detail.actorName ?? detail.actorUserId })}`}
{triggerActor?.kind === 'system'
? ` · ${appT('runTranscript.systemTrigger')}`
: triggerActor
? ` · ${appT('runTranscript.triggeredBy', { person: triggerActor.name })}`
: ''}
{duration !== null
? ` · ${t('transcript.took', { duration: `${duration}s` })}`
: ''}
@@ -0,0 +1,15 @@
import type { RunDetail } from '@nocobase/app-plugin-agents/shared/runs';
/** Retries retain unhandled history, so select the input for this retry only. */
export function runTriggerActor(
detail: Pick<RunDetail, 'retryOfRunId' | 'inputs'>,
) {
return detail.retryOfRunId
? (detail.inputs.find(
(input) =>
input.type === 'retry' &&
(input.payload as { retryOfRunId?: string } | null)?.retryOfRunId ===
detail.retryOfRunId,
)?.actor ?? detail.inputs[0]?.actor)
: detail.inputs[0]?.actor;
}
@@ -8,7 +8,7 @@ export interface ProjectDetailLabels {
readonly noLead: string;
/** Beside the name of a project only its members see. */
readonly private: string;
readonly workflow: string;
readonly workflow?: string;
};
readonly deletion: {
readonly more: string;
@@ -206,27 +206,41 @@ export function ActivityRow({
<span className='text-xs'>{via}</span>
) : null}
<span>
{prAction
? studioT(prAction)
: cannotImplement
? studioT('studioAgents.activity.executorCannotImplement', {
name:
text(activity.details.name) ??
{activity.action === 'studio_execution_wait'
? studioT(
`issueProgress.waiting.${activity.details.reason === 'noOwner' ? 'noOwner' : 'notAllowed'}`,
{
agent:
text(activity.details.agent) ??
text(activity.details.agentId) ??
t('common.unknown'),
})
: activity.action === EXECUTOR_WAITS_ACTIVITY
? studioT('studioAgents.activity.executorWaits', {
'',
owner:
text(activity.details.owner) ??
text(activity.details.ownerUserId) ??
'',
},
)
: prAction
? studioT(prAction)
: cannotImplement
? studioT('studioAgents.activity.executorCannotImplement', {
name:
(text(activity.details.agentId)
? agentName(text(activity.details.agentId) ?? '')
: null) ??
text(activity.details.name) ??
text(activity.details.agentId) ??
t('common.unknown'),
})
: t(actionLabel(activity.action, activity.details), {
name: text(activity.details.name) ?? t('common.unknown'),
})}
: activity.action === EXECUTOR_WAITS_ACTIVITY
? studioT('studioAgents.activity.executorWaits', {
name:
(text(activity.details.agentId)
? agentName(text(activity.details.agentId) ?? '')
: null) ??
text(activity.details.name) ??
t('common.unknown'),
})
: t(actionLabel(activity.action, activity.details), {
name: text(activity.details.name) ?? t('common.unknown'),
})}
</span>
{prAction ? (
<PullRequestActivity activity={activity} t={studioT} />
@@ -288,7 +302,7 @@ export function ActivityRow({
) : null}
{activity.action === 'status_changed' &&
activity.details.event === 'subtasks.done' ? (
<span>{t('activity.afterSubtasks')}</span>
<span>{studioT('runTranscript.afterSubtasks')}</span>
) : null}
{moved && event && eventTitle(event) ? (
<span>{t('activity.afterEvent', { event: eventTitle(event) })}</span>
@@ -1,6 +1,6 @@
/**
* An agent's comment the comment run limit held back (`GET /api/issueCommentRuns/:issueId`): a person who may edit the
* issue continues it once, which wakes the agents it names and restarts the count. The server checks and consumes its
* An agent's comment the comment run limit held back (`GET /api/issueCommentRuns/:issueId`): the owner with issue edit
* permission continues it once, which wakes the agents it names and restarts the count. The server checks and consumes its
* token.
*/
import { ApiClientError, useApiClient } from '@nocobase/app-client';
@@ -36,7 +36,7 @@ export function IssueCommentRunLimit({
const notify = useNotify();
const queryClient = useQueryClient();
const continuingRef = useRef(false);
const allowed = canEditIssues(viewer);
const allowed = canEditIssues(viewer) && viewer?.userId === issue.ownerUserId;
const key = ['studio', 'commentRunLimit', issue.id, issue.revision];
const path = `issueCommentRuns/${encodeURIComponent(issue.id)}`;
const pending = useQuery({
@@ -43,7 +43,7 @@ export function IssueProgressLine({
const viewer = useViewer();
const notify = useNotify();
const queryClient = useQueryClient();
const canEdit = canEditIssues(viewer);
const canEdit = canEditIssues(viewer) && viewer?.userId === issue.ownerUserId;
const progress = useQuery({
queryKey: [...issueProgressKey(issue.id), refresh],
queryFn: async () =>
@@ -154,9 +154,9 @@ export function useIssuePageWording(): IssuePageWording {
updated: t('properties.updated'),
},
start: {
title: t('start.title'),
later: t('start.later'),
start: t('start.start'),
title: appT('issueStart.title'),
later: appT('issueStart.later'),
start: appT('issueStart.start'),
},
},
richText: {
@@ -18,6 +18,11 @@ export function progressSentence(
now: number = Date.now(),
): string {
const agent = progress.agent?.name ?? t('issueProgress.anAgent');
if (progress.waiting)
return t(`issueProgress.waiting.${progress.waiting.reason}`, {
agent,
owner: progress.waiting.owner?.name ?? '',
});
switch (progress.state) {
case 'working': {
const parts = [
@@ -72,6 +72,24 @@ const enUS = {
keepDraft: 'Keep panel draft',
appendDraft: 'Append header question',
},
runTranscript: {
usedModel: 'Model: {{model}}',
defaultModel: 'Default model',
actingAs: 'Running as {{actor}}',
triggeredBy: 'Triggered by {{person}}',
systemTrigger: 'Triggered by the system',
afterSubtasks: 'After all sub-issues finished',
efforts: {
off: 'Off',
minimal: 'Minimal',
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'Extra high',
max: 'Maximum',
ultra: 'Ultra',
},
},
runTranscriptFilters: {
title: 'Event types',
agent: 'Agent',
@@ -1367,7 +1385,19 @@ const enUS = {
issuePage: {
arrange: 'Arrange',
},
issueStart: { title: 'Start now?', later: "Don't start now", start: 'Start' },
issueProgress: {
waiting: {
noOwner: 'Waiting for a valid issue owner',
notConfigured:
'{{agent}} waits: no preferred or fallback model is configured',
noRunner:
'{{agent}} waits: no online runner supports its preferred or fallback model',
ownerHasNoRunner:
'{{agent}} waits: owner {{owner}} has no online runner for its preferred or fallback model',
notAllowed:
'{{agent}} waits: owner {{owner}} cannot run this agent with the available runners',
},
title: 'Now',
anAgent: 'An agent',
working: '{{agent}} is working on {{step}}',
@@ -63,6 +63,24 @@ const zhCN: AppResource = {
keepDraft: '保留面板草稿',
appendDraft: '追加顶部问题',
},
runTranscript: {
usedModel: '使用模型:{{model}}',
defaultModel: '默认模型',
actingAs: '运行身份:{{actor}}',
triggeredBy: '触发人:{{person}}',
systemTrigger: '由系统触发',
afterSubtasks: '所有子任务完成后',
efforts: {
off: '关闭',
minimal: '最低',
low: '低',
medium: '中',
high: '高',
xhigh: '特高',
max: '最高',
ultra: '极高',
},
},
runTranscriptFilters: {
title: '事件类型',
agent: 'Agent',
@@ -1254,7 +1272,17 @@ const zhCN: AppResource = {
issuePage: {
arrange: '安排',
},
issueStart: { title: '现在开始?', later: '暂不开始', start: '开始' },
issueProgress: {
waiting: {
noOwner: '正在等待有效的任务负责人',
notConfigured: '{{agent}} 正在等待:尚未配置首选或备选模型',
noRunner: '{{agent}} 正在等待:没有能运行首选或备选模型的在线执行机',
ownerHasNoRunner:
'{{agent}} 正在等待:负责人 {{owner}} 没有能运行首选或备选模型的在线执行机',
notAllowed:
'{{agent}} 正在等待:负责人 {{owner}} 无权运行此 Agent,或可用执行机不满足运行要求',
},
title: '现在',
anAgent: '某个 Agent',
working: '{{agent}} 正在{{step}}',
@@ -55,7 +55,6 @@ export function useProjectPageWording(): ProjectPageWording {
lead: t('projects.columns.lead'),
noLead: t('projects.noLead'),
private: t('projects.visibility.members'),
workflow: t('projects.workflow'),
},
deletion: {
more: t('projectMore.label'),
@@ -22,7 +22,7 @@ nb-studio project-manager get
Hand an issue to the project manager
Wakes the project manager on an open issue it executes, as the caller, to go on with it: for an issue it never worked on. Needs `pm.issues` edit on the issue.
Wakes the project manager on an open issue it executes, as the current issue owner. Only that owner may invoke it, and needs `pm.issues` edit on the issue. Other callers receive `ISSUE_OWNER_START_REQUIRED`.
```bash
nb-studio project-manager start <issue>
+108
View File
@@ -402,6 +402,11 @@ test.describe('issue page', () => {
page,
api,
}, testInfo) => {
await api.patch(`projects/issues/${issue.id}`, {
statusKey: 'in_progress',
executor: null,
start: false,
});
const runner = new FakeRunner(api, `live-issue-${unique()}`);
await runner.agent();
const runsPath = `agents/runs?subjectKind=issue&subjectId=${issue.id}`;
@@ -492,6 +497,109 @@ test.describe('issue page', () => {
).toBeHidden();
});
test('only the owner starts; another user comments as a trigger and the transcript names both', async ({
page,
api,
browser,
context,
}) => {
test.setTimeout(120_000);
const admin = await apiAs(browser, 'admin');
const suffix = unique();
const triggerUser = {
email: `identity-trigger-${suffix}@example.com`,
password: 'identity-trigger-e2e-password',
};
const triggerName = `Identity trigger ${suffix}`;
const runner = new FakeRunner(admin.api, `owner-identity-${unique()}`);
const settings = await admin.api.get<{
firstChoiceAgentId: string | null;
fallbackAgentId: string | null;
}>('projectManager');
try {
const choice = await runner.agent();
await admin.api.patch('projectManager', {
firstChoiceAgentId: choice,
fallbackAgentId: null,
});
await admin.api.post('users', {
...triggerUser,
name: triggerName,
roleScopes: { studio: ['admin'] },
});
await signIn(context, triggerUser);
await open(page, `/issues/${issue.identifier}`);
await expect(
page
.getByRole('region', { name: '现在' })
.getByRole('button', { name: '开始', exact: true }),
).toHaveCount(0);
await expect(
api.post(`projectManager/issues/${issue.id}/start`),
).rejects.toThrow('ISSUE_OWNER_START_REQUIRED');
await signIn(context, 'alex');
await open(page, `/issues/${issue.identifier}`);
await page
.getByRole('region', { name: '现在' })
.getByRole('button', { name: '开始', exact: true })
.click();
await expect
.poll(
async () =>
(await api.get<Issue>(`projects/issues/${issue.id}`)).statusKey,
)
.toBe('in_progress');
const first = await runner.start(issue.identifier);
await runner.complete(first.runId, 'Ready for comments.');
await signIn(context, triggerUser);
const trigger = await api.get<{ userId: string }>('projects/me');
await api.post(`projects/issues/${issue.id}/comments`, {
content: `Please check the implementation, ${suffix}.`,
});
const started = await runner.start(issue.identifier);
const detail = await admin.api.get<{
actorUserId: string;
requestedByUserId: string;
}>(`agents/runs/${started.runId}`);
expect(detail.actorUserId).toBe(me.userId);
expect(detail.requestedByUserId).toBe(trigger.userId);
await open(page, `/issues/${issue.identifier}/runs/${started.runId}`);
await expect(page.getByTestId('run-model')).toContainText('使用模型:');
await expect(page.getByText(/运行身份:Alex Turner/u)).toBeVisible();
await expect(
page.getByText(new RegExp(`触发人:${triggerName}`, 'u')),
).toBeVisible();
await page.screenshot({
path: test.info().outputPath('manager-identity-zh.png'),
});
await api.patch('users/me/preferences', {
locale: 'en-US',
'theme.mode': 'dark',
});
await page.evaluate(() => {
localStorage.setItem('nocobase.locale', 'en-US');
localStorage.setItem('nocobase:main:theme:color-scheme', 'dark');
});
await page.setViewportSize({ width: 375, height: 812 });
await page.reload();
await expect(page.getByTestId('run-model')).toContainText('Model:');
await expect(page.getByText(/Running as Alex Turner/u)).toBeVisible();
await expect(
page.getByText(new RegExp(`Triggered by ${triggerName}`, 'u')),
).toBeVisible();
expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1);
await page.screenshot({
path: test.info().outputPath('manager-identity-en-mobile.png'),
});
} finally {
await admin.api.patch('projectManager', {
firstChoiceAgentId: settings.firstChoiceAgentId,
fallbackAgentId: settings.fallbackAgentId,
});
await admin.close();
}
});
test('the project manager arranges an issue among the agents', async ({
page,
}) => {
+9
View File
@@ -63,6 +63,9 @@ test.describe('issues', () => {
const working = await api.post<Issue>('projects/issues', {
title: `Agent 队列:处理中 ${unique()}`,
ownerUserId: me.userId,
statusKey: 'in_progress',
executor: null,
start: false,
});
await runner.wake(working);
await runner.claim(working.identifier);
@@ -70,12 +73,18 @@ test.describe('issues', () => {
const queued = await api.post<Issue>('projects/issues', {
title: `Agent 队列:排队中 ${unique()}`,
ownerUserId: me.userId,
statusKey: 'in_progress',
executor: null,
start: false,
});
await runner.wake(queued);
// An issue no agent touches.
const blocker = await api.post<Issue>('projects/issues', {
title: `Agent 队列:无关任务 ${unique()}`,
ownerUserId: me.userId,
statusKey: 'in_progress',
executor: null,
start: false,
});
await open(page, '/issues?view=agent&mine=1');
@@ -18,6 +18,52 @@ interface ScrollViewport {
test.use({ user: 'admin' });
test.setTimeout(120_000);
test('shows the current retry initiator after an unclaimed retry was cancelled', async ({
page,
api,
browser,
}) => {
const me = await api.get<{ userId: string }>('projects/me');
const runner = new FakeRunner(api, `retry-audit-${unique()}`);
await runner.agent();
const issue = await api.post<{ id: string; identifier: string }>(
'projects/issues',
{
title: `Retry audit ${unique()}`,
ownerUserId: me.userId,
statusKey: 'in_progress',
executor: null,
start: false,
},
);
await runner.wake(issue);
const [original] = await api.get<{ id: string }[]>(
`agents/runs?subjectKind=issue&subjectId=${issue.id}`,
);
await api.post(`agents/runs/${original!.id}/cancel`, {});
const other = await apiAs(browser, 'alex');
try {
const first = await other.api.post<{ id: string }>(
`agents/runs/${original!.id}/retry`,
{},
);
await other.api.post(`agents/runs/${first.id}/cancel`, {});
const second = await api.post<{ id: string }>(
`agents/runs/${first.id}/retry`,
{},
);
await open(page, `/issues/${issue.identifier}/runs/${second.id}`);
await expect(
page.getByText(/(?:触发人:|Triggered by )Super Admin/u),
).toBeVisible();
await expect(
page.getByText(/(?:触发人:|Triggered by )Alex Turner/u),
).toHaveCount(0);
} finally {
await other.close();
}
});
test('filters a real 200-event run, preserves preferences and follows live failures', async ({
page,
api,
@@ -15,6 +15,9 @@ test('parallel fake runners only claim the runs of their own agents', async ({
api.post<Issue>('projects/issues', {
title: `Concurrent mention ${index} ${unique()}`,
ownerUserId: me.userId,
statusKey: 'in_progress',
executor: null,
start: false,
}),
),
);
+4 -1
View File
@@ -163,10 +163,13 @@ export class FakeRunner {
ownerUserId: string,
author = this.api,
) {
// In backlog nothing else starts: only the mention wakes this runner's agent.
// An active issue with no executor lets the mention wake only this runner's agent.
const issue = await author.post<Issue>('projects/issues', {
title,
ownerUserId,
statusKey: 'in_progress',
executor: null,
start: false,
});
await this.wake(issue, author);
const { run } = await this.claimRun(issue.identifier);
@@ -1,6 +1,6 @@
/**
* The loop guard of agents' comments (`work.ts`): once they started as many runs on an issue as the project manager's
* settings allow (`commentRunLimit`, 20 by default), the owner is told, and a person who may edit the issue continues
* settings allow (`commentRunLimit`, 20 by default), the owner is told, and that owner, with issue edit permission, continues
* the comment held back once, which restarts the count.
*/
import type { Agents } from '@nocobase/app-plugin-agents/server/tokens';
@@ -90,7 +90,7 @@ export async function pendingCommentRun(
readonly windowHours: number;
} | null> {
const issue = await projects.issueQueries.detail(viewer, issueId);
if (!mayContinue(viewer)) return null;
if (!mayContinue(viewer) || viewer.userId !== issue.ownerUserId) return null;
const guard = await commentGuards(projects.tx.read()).findOne({
filter: { id: commentGuardId(issue.id) },
});
@@ -103,7 +103,7 @@ export async function pendingCommentRun(
};
}
/** Consumes the token, restarts the count and wakes the held comment's agents for the person continuing it. */
/** The owner consumes the token, restarts the count and wakes the held comment's agents. */
export async function continueCommentRun(
projects: Projects,
agents: Agents,
@@ -119,6 +119,12 @@ export async function continueCommentRun(
'COMMENT_CONTINUE_FORBIDDEN',
'Only a person who may edit the issue may continue this comment.',
);
if (viewer.userId !== issue.ownerUserId)
throw studioError(
'PERMISSION_DENIED',
'ISSUE_OWNER_START_REQUIRED',
'The current issue owner must continue this work.',
);
const id = commentGuardId(issue.id);
const stale = () =>
studioError(
@@ -0,0 +1,118 @@
/** Only the project manager and work it handed out use the issue's current owner. */
import type {
Agents,
SubjectBinding,
} from '@nocobase/app-plugin-agents/server/tokens';
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';
export function issueExecutionIdentity(
agents: Pick<Agents, 'runs' | 'agents'>,
projects: () => Pick<Projects, 'issueContext' | 'commentQueries'>,
): NonNullable<SubjectBinding['actsAs']> {
return async (conn, work) => {
const seen = new Set<string>();
const comments = await projects().commentQueries.list(conn, work.subjectId);
const byId = new Map(comments.map((comment) => [comment.id, comment]));
async function inChain(
agentId: string,
inputs: typeof work.inputs,
cause?: string,
): Promise<boolean> {
if (agentId === PROJECT_MANAGER_ID) return true;
if (cause && !seen.has(cause)) {
seen.add(cause);
const previous = await agents.runs.identityInputs(conn, cause);
if (
previous?.run.subject.id === work.subjectId &&
previous.run.subject.kind === 'issue' &&
(await inChain(
previous.run.agentId,
previous.inputs,
previous.run.retryOfRunId ?? undefined,
))
)
return true;
}
for (const input of inputs) {
const payload =
input.payload && typeof input.payload === 'object'
? (input.payload as Record<string, unknown>)
: {};
if (payload.ownerIdentity === true) return true;
if (
typeof payload.retryOfRunId === 'string' &&
(await inChain(agentId, [], payload.retryOfRunId))
)
return true;
if (
typeof payload.causedByRunId === 'string' &&
(await inChain(agentId, [], payload.causedByRunId))
)
return true;
// Old queued steps predate the explicit marker. Follow their recorded comment source,
// or the hand-over thread when old comments did not record a run.
let comment =
typeof payload.commentId === 'string'
? byId.get(payload.commentId)
: undefined;
if (
comment?.source?.runId &&
(await inChain(agentId, [], comment.source.runId))
)
return true;
if (input.actor.kind !== 'agent') continue;
if (input.actor.id === PROJECT_MANAGER_ID) return true;
const visited = new Set<string>();
while (comment && !visited.has(comment.id)) {
visited.add(comment.id);
if (
comment.authorType === AGENT_KIND &&
comment.authorId === PROJECT_MANAGER_ID
)
return true;
if (
comment.source?.runId &&
(await inChain(agentId, [], comment.source.runId))
)
return true;
comment = comment.parentId ? byId.get(comment.parentId) : undefined;
}
}
return false;
}
if (!(await inChain(work.agentId, work.inputs, work.causedByRunId)))
return undefined;
const issue = await projects().issueContext.contextFor(
conn,
work.subjectId,
{ kind: AGENT_KIND },
);
// Missing subjects are rejected by claim admission with its precise reason.
if (!issue) return undefined;
const ownerId = issue?.owner.id;
if (!ownerId) return { actorUserId: null, reason: 'noOwner' };
if (!(await validIssueOwner(conn, ownerId)))
return { actorUserId: null, reason: 'noOwner' };
const agent = await agents.agents.findWorkable(conn, work.agentId);
if (!agent || !agents.agents.mayInvoke(agent, ownerId))
return { actorUserId: null, reason: 'notAllowed' };
return { actorUserId: ownerId };
};
}
export async function validIssueOwner(
conn: DatabaseConnection,
id: string,
): Promise<boolean> {
const user = await conn
.repository<{
id: string;
disabledAt: string | null;
deletedAt: string | null;
}>('user')
.findOne({ filter: { id } });
return Boolean(user && !user.disabledAt && !user.deletedAt);
}
@@ -49,6 +49,7 @@ import {
PROJECT_TITLE,
} from './catalog/triggers.js';
import { AGENT_KIND } from './tx.js';
import { issueExecutionIdentity } from './execution-identity.js';
const SUBJECT_NOUN = 'issue';
@@ -623,13 +624,14 @@ export function issueSample(agents: Pick<Agents, 'briefs'>): SubjectSample {
}
export function issueBinding(
agents: Pick<Agents, 'runs' | 'briefs'>,
agents: Pick<Agents, 'runs' | 'briefs' | 'agents'>,
projects: () => Pick<Projects, 'issueContext' | 'commentQueries'>,
sink: SubjectBinding['sink'],
queuedExpiryMs = 0,
): SubjectBinding {
return {
kind: ISSUE_SUBJECT,
actsAs: issueExecutionIdentity(agents, projects),
...(queuedExpiryMs > 0 ? { queuedExpiryMs } : {}),
title: ISSUE_TITLE,
groupTitle: PROJECT_TITLE,
@@ -20,6 +20,11 @@ import { pullRequestsOfIssue } from '../../git/store.js';
import { ISSUE_SUBJECT } from '../catalog/triggers.js';
import { AGENT_KIND } from '../tx.js';
import { nextLineOf } from './texts.js';
import {
issueExecutionIdentity,
validIssueOwner,
} from '../execution-identity.js';
import { userName } from '../work.js';
/** How much of a step or a question the line carries. */
const LINE_LENGTH = 160;
@@ -51,12 +56,41 @@ const HELD = new Set(['dispatched', 'running']);
export async function issueProgress(
deps: {
readonly agents: Pick<Agents, 'runs' | 'agents' | 'runners'>;
readonly projects: Pick<Projects, 'commentQueries'>;
readonly agents: Pick<
Agents,
'runs' | 'agents' | 'runners' | 'availability'
>;
readonly projects: Pick<Projects, 'commentQueries' | 'issueContext'>;
},
conn: DatabaseConnection,
issue: Issue,
): Promise<IssueProgress> {
async function waitingFor(
agentId: string,
userId: string,
): Promise<IssueProgress['waiting']> {
const owner = userId
? { id: userId, name: await userName(conn, userId) }
: null;
if (!userId || !(await validIssueOwner(conn, userId)))
return { reason: 'noOwner', owner };
const agent = await deps.agents.agents.findWorkable(conn, agentId);
if (!agent || !agent.modelEntries.length)
return { reason: 'notConfigured', owner };
if (!deps.agents.agents.mayInvoke(agent, userId))
return { reason: 'notAllowed', owner };
const availability = (
await deps.agents.availability(conn, [agent], userId)
).get(agentId);
if (!availability || availability.online) return null;
const reason =
availability.runners.team + availability.runners.personal === 0
? 'noRunner'
: availability.personalOnly && availability.runners.mine === 0
? 'ownerHasNoRunner'
: 'notAllowed';
return { reason, owner };
}
const subject = { kind: ISSUE_SUBJECT, id: issue.id };
const comments = await deps.projects.commentQueries.list(conn, issue.id);
const managerComments = comments.filter(
@@ -110,6 +144,15 @@ export async function issueProgress(
run.runnerId && HELD.has(run.status)
? ((await deps.agents.runners.find(conn, run.runnerId))?.name ?? null)
: null;
const identity =
run.status === 'queued'
? await issueExecutionIdentity(deps.agents, () => deps.projects)(conn, {
subjectId: issue.id,
agentId: run.agentId,
causedByRunId: run.retryOfRunId ?? undefined,
inputs: detail?.inputs ?? [],
})
: undefined;
return {
...base,
state: HELD.has(run.status) ? 'working' : 'queued',
@@ -122,11 +165,25 @@ export async function issueProgress(
: null,
since: run.startedAt ?? run.dispatchedAt ?? run.createdAt,
runner,
waiting:
run.status === 'queued' && identity
? await waitingFor(
run.agentId,
run.dispatchedAt || run.attempt > 1 || run.executions?.length
? run.actorUserId
: (identity.actorUserId ?? issue.ownerUserId),
)
: null,
};
}
if (category === 'done' || category === 'closed')
return { ...base, state: 'finished' };
if (issue.statusKey === 'backlog') return { ...base, state: 'notStarted' };
if (managed && open) {
const waiting = await waitingFor(PROJECT_MANAGER_ID, issue.ownerUserId);
if (waiting?.reason === 'noOwner' || waiting?.reason === 'notAllowed')
return { ...base, state: 'queued', waiting };
}
const pending = (await pullRequestsOfIssue(conn, issue.id))
.map(({ pr }) => pr)
.find((pr) => pr.state === 'open' && pr.ciState === 'pending');
@@ -4,7 +4,7 @@
* - `GET /projectManager`: its settings and the agents it may run with, for anyone signed in; `PATCH /projectManager`
* changes them, for a manager of agents (`agents.agents` manage).
* - `POST /projectManager/issues/{issueId}/start`: hands an open issue the project manager executes to it to go on,
* as the caller, who may edit the issue: "Hand over to the project manager" on an issue it never worked on.
* as the owner, who must also be the caller and may edit the issue: "Hand over to the project manager" on an issue it never worked on.
* - `GET /issueProgress/{issueId}`: what the issue waits for now, for whoever sees it.
* - `GET /issueCommentRuns/{issueId}`, `POST …/continue`: an agent's comment the comment run limit held back, and
* continuing it (`../comment-run-limit.ts`).
@@ -214,7 +214,7 @@ export const projectManagerRoutes: AppApiRouteContribution<Application> =
}),
security: [{ cookieAuth: [] }, { apiKeyAuth: [] }],
description:
'Wakes the project manager on an open issue it executes, as the caller, to go on with it: for an issue it never worked on. Needs `pm.issues` edit on the issue.',
'Wakes the project manager on an open issue it executes, as the current issue owner. Only that owner may invoke it, and needs `pm.issues` edit on the issue. Other callers receive `ISSUE_OWNER_START_REQUIRED`.',
responses: {
200: dataResponse(z.object({ runId: z.string() })),
400: apiErrorResponse(
@@ -237,7 +237,7 @@ export const projectManagerRoutes: AppApiRouteContribution<Application> =
'ACTION_FORBIDDEN',
'Only a person who may edit the issue may hand it over.',
);
const runId = await projects.tx.unit(async (tx) => {
const attempt = await projects.tx.unit(async (tx) => {
const issue = await projects.issueQueries.detail(
viewer,
context.req.valid('param').issueId,
@@ -256,17 +256,17 @@ export const projectManagerRoutes: AppApiRouteContribution<Application> =
agents,
projects: () => projects,
}).startNow(tx, issue, { type: 'user', id: viewer.userId });
if (!attempt.started || !attempt.runId)
throw studioError(
'FAILED_PRECONDITION',
'PROJECT_MANAGER_NOT_STARTED',
'The project manager cannot take this issue now.',
{ metadata: { reason: attempt.skipped ?? 'unknown' } },
);
tx.emit({ type: 'issue.changed', issueId: issue.id });
return attempt.runId;
return attempt;
});
return context.json({ data: { runId } });
if (!attempt.started || !attempt.runId)
throw studioError(
'FAILED_PRECONDITION',
'PROJECT_MANAGER_NOT_STARTED',
'The project manager cannot take this issue now.',
{ metadata: { reason: attempt.skipped ?? 'unknown' } },
);
return context.json({ data: { runId: attempt.runId } });
},
);
progress.get(
@@ -341,7 +341,7 @@ export const projectManagerRoutes: AppApiRouteContribution<Application> =
...cliRoute(false),
security: [{ cookieAuth: [] }, { apiKeyAuth: [] }],
description:
'Needs pm.issues/edit on the issue. Wakes the agents the comment names as the caller and restarts the count; the token is consumed once, and failed preconditions leave it pending.',
'Needs pm.issues/edit and the current issue owner. Wakes the agents the comment names and restarts the count; project manager work uses the owner identity. The token is consumed once, and failed preconditions leave it pending.',
responses: {
200: dataResponse(z.object({ runIds: z.array(z.string()) })),
...apiErrorResponses,
@@ -39,6 +39,19 @@ export const ProjectManagerViewSchema: z.ZodType<ProjectManagerView> = z
export const IssueProgressSchema: z.ZodType<IssueProgress> = z
.object({
waiting: z
.object({
reason: z.enum([
'noOwner',
'notConfigured',
'noRunner',
'ownerHasNoRunner',
'notAllowed',
]),
owner: z.object({ id: z.string(), name: z.string() }).nullable(),
})
.nullable()
.optional(),
state: z.enum([
'working',
'queued',
@@ -174,6 +174,11 @@ const IssueRunRowSchema: z.ZodType<IssueRunRow> = z
export interface IssueRunDetail {
readonly id: string;
readonly actorUserId: string;
readonly actorName: string;
readonly requestedByUserId: string;
readonly requestedByName: string;
readonly ownerUserId: string | null;
readonly agentId: string;
readonly agent: string;
readonly issue: {
@@ -214,6 +219,23 @@ export interface IssueRunDetail {
const IssueRunDetailSchema: z.ZodType<IssueRunDetail> = z
.object({
id: z.string(),
actorUserId: z.string().meta({
description: 'Execution identity, fixed when the run is first claimed.',
}),
actorName: z
.string()
.meta({ description: 'The name of the execution identity.' }),
requestedByUserId: z.string().meta({
description:
'The user associated with the chain origin; input actors identify the actual triggers, including system events.',
}),
requestedByName: z.string().meta({
description: 'The name of the user associated with the chain origin.',
}),
ownerUserId: z
.string()
.nullable()
.meta({ description: 'The issue owner snapshot for this run.' }),
agentId: z.string(),
agent: z.string(),
issue: z.object({
@@ -612,6 +634,11 @@ export const issueRunsRoutes: AppApiRouteContribution<Application> =
];
const data: IssueRunDetail = {
id: run.id,
actorUserId: run.actorUserId,
actorName: detail.actorName ?? run.actorUserId,
requestedByUserId: run.requestedByUserId,
requestedByName: detail.requestedByName ?? run.requestedByUserId,
ownerUserId: run.ownerUserId,
agentId: run.agentId,
agent,
issue: {
+173 -31
View File
@@ -21,7 +21,7 @@
* - A person moving the issue into In progress starts the executing agent's work (`started` from backlog or a finished
* status, else `statusChange`), unless they asked not to start now; moving it elsewhere tells a run that has not
* ended. Finishing or closing the issue withdraws queued work instead.
* - A new owner: queued work the previous owner woke is withdrawn, and starts again as the new owner's.
* - A new owner: all queued agent work is withdrawn and held work is asked to stop. Resumption belongs to the handover flow.
* - Moved to another project (`project-move.ts`): work starts in the new project's working directories
* (`projectChanged`). A run claimed before the move works in the previous project's, so it is told to end and asked
* to stop (left to end on its own when its agent moved the issue), and the new run starts once it has ended
@@ -41,6 +41,7 @@
*/
import { PROJECT_MANAGER_ID } from '../../shared/project-manager.js';
import { studioError } from '../http/errors.js';
import { randomUUID } from 'node:crypto';
import type { ActorRef, RunInputType } from '@nocobase/agent-protocol';
@@ -85,6 +86,7 @@ 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 { issueExecutionIdentity } from './execution-identity.js';
interface Wake {
readonly agentId: string;
@@ -92,6 +94,7 @@ interface Wake {
readonly actorUserId: string;
readonly triggerType: string;
readonly freshSession?: boolean;
readonly causedByRunId?: string;
readonly input: {
readonly type: RunInputType;
readonly actor: ActorRef;
@@ -165,7 +168,10 @@ export interface AgentWork extends IssueWorkHandler {
export function createAgentWork(deps: {
readonly agents: Pick<Agents, 'runs' | 'agents'>;
readonly projects: () => Pick<Projects, 'subtasks' | 'issueContext'>;
readonly projects: () => Pick<
Projects,
'subtasks' | 'issueContext' | 'commentQueries' | 'activity'
>;
}): AgentWork {
const { runs } = deps.agents;
const subjectOf = (issue: Issue) => ({ kind: ISSUE_SUBJECT, id: issue.id });
@@ -182,7 +188,44 @@ export function createAgentWork(deps: {
request.agentId,
);
if (!agent) return { ...attempt, started: false, skipped: 'unavailable' };
if (!deps.agents.agents.mayInvoke(agent, request.actorUserId))
const identity = await issueExecutionIdentity(deps.agents, deps.projects)(
tx.conn,
{
subjectId: request.issue.id,
agentId: request.agentId,
causedByRunId: request.causedByRunId,
inputs: [request.input],
},
);
if (identity && !identity.actorUserId) {
if (!tx.rehearsal) {
await deps.projects().activity.record(tx.conn, {
issueId: request.issue.id,
actor: { type: request.input.actor.kind, id: request.input.actor.id },
action: 'studio_execution_wait',
details: {
reason: identity.reason ?? 'noOwner',
trigger: request.triggerType,
agentId: request.agentId,
agent: agent.name,
ownerUserId: request.issue.ownerUserId,
owner: await userName(tx.conn, request.issue.ownerUserId),
},
});
tx.emit({ type: 'issue.changed', issueId: request.issue.id });
}
return {
...attempt,
started: false,
skipped: identity.reason === 'noOwner' ? 'unavailable' : 'denied',
};
}
if (
!deps.agents.agents.mayInvoke(
agent,
identity?.actorUserId ?? request.actorUserId,
)
)
return { ...attempt, started: false, skipped: 'denied' };
// Held by unfinished issues: the release wakes the agent once nothing holds it.
const blockers = await deps
@@ -197,6 +240,8 @@ export function createAgentWork(deps: {
agentId: agent.id,
subject: subjectOf(request.issue),
actorUserId: request.actorUserId,
requestedByUserId: request.actorUserId,
causedByRunId: request.causedByRunId,
ownerUserId: request.issue.ownerUserId,
priority: runPriorityOf(request.issue.priority),
freshSession: request.freshSession,
@@ -204,6 +249,10 @@ export function createAgentWork(deps: {
...request.input,
payload: {
trigger: request.triggerType,
...(identity ? { ownerIdentity: true } : {}),
...(request.causedByRunId
? { causedByRunId: request.causedByRunId }
: {}),
...(request.input.payload &&
typeof request.input.payload === 'object'
? request.input.payload
@@ -229,10 +278,15 @@ export function createAgentWork(deps: {
tx: ProjectsTx,
actor: Actor,
issue: Issue,
): Promise<{ readonly userId: string; readonly ref: ActorRef }> {
): Promise<{
readonly userId: string;
readonly ref: ActorRef;
readonly causedByRunId?: string;
}> {
if (actor.type === 'user' && actor.id)
return {
userId: actor.id,
causedByRunId: actor.trace?.runId,
ref: {
kind: 'user',
id: actor.id,
@@ -241,6 +295,7 @@ export function createAgentWork(deps: {
};
return {
userId: issue.ownerUserId,
causedByRunId: actor.trace?.runId,
ref: {
kind: actor.type === AGENT_KIND ? 'agent' : 'system',
id: actor.id ?? 'system',
@@ -283,6 +338,16 @@ export function createAgentWork(deps: {
...(reason ? { skipped: reason } : {}),
});
function requireOwnerStart(issue: Issue, actor: Actor): void {
if (actor.type !== 'user' || actor.id !== issue.ownerUserId)
throw studioError(
'PERMISSION_DENIED',
'ISSUE_OWNER_START_REQUIRED',
'The current issue owner must start the project manager.',
{ metadata: { reason: 'ownerStartRequired' } },
);
}
/** Wakes each agent `held` names with the comment, for `userId`. */
async function wakeComment(
tx: ProjectsTx,
@@ -291,6 +356,7 @@ export function createAgentWork(deps: {
userId: string,
extra: Readonly<Record<string, unknown>>,
): Promise<RunAttempt[]> {
if (statusCategory(issue.statusKey) === 'unstarted') return [];
const attempts: RunAttempt[] = [];
for (const { agentId, triggerType } of held.targets)
attempts.push(
@@ -301,6 +367,10 @@ export function createAgentWork(deps: {
agentId,
issue,
actorUserId: userId,
causedByRunId:
typeof extra.causedByRunId === 'string'
? extra.causedByRunId
: undefined,
triggerType,
input: {
type: 'comment',
@@ -397,7 +467,11 @@ export function createAgentWork(deps: {
tx: ProjectsTx,
issue: Issue,
agentId: string,
who: { readonly userId: string; readonly ref: ActorRef },
who: {
readonly userId: string;
readonly ref: ActorRef;
readonly causedByRunId?: string;
},
triggerType: 'assigned' | 'started' = 'assigned',
): Promise<RunAttempt> {
// In backlog, or finished: the work starts when the issue leaves backlog.
@@ -406,6 +480,7 @@ export function createAgentWork(deps: {
agentId,
issue,
actorUserId: who.userId,
causedByRunId: who.causedByRunId,
triggerType,
input: {
type: 'signal',
@@ -494,6 +569,18 @@ export function createAgentWork(deps: {
return {
async onRunFinished(tx, run) {
if (run.subject.kind !== ISSUE_SUBJECT) 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 (
previous?.inputs.some(
(input) =>
input.payload &&
typeof input.payload === 'object' &&
(input.payload as Record<string, unknown>).trigger ===
'ownerChanged',
)
)
return;
// Admission cancellations never restart old work; acknowledged project moves still do.
if (
run.status === 'cancelled' &&
@@ -522,12 +609,45 @@ export function createAgentWork(deps: {
.at(-1);
if (!move) return;
const agent = await deps.agents.agents.findWorkable(conn, run.agentId);
if (!agent || !deps.agents.agents.mayInvoke(agent, move.byUserId)) return;
if (!agent) return;
const identity = await issueExecutionIdentity(deps.agents, deps.projects)(
conn,
{
subjectId: issue.id,
agentId: run.agentId,
causedByRunId: run.id,
inputs: [],
},
);
if (identity && !identity.actorUserId) {
await deps.projects().activity.record(conn, {
issueId: issue.id,
actor: { type: move.by.kind, id: move.by.id },
action: 'studio_execution_wait',
details: {
reason: identity.reason ?? 'noOwner',
trigger: PROJECT_CHANGED,
agentId: agent.id,
agent: agent.name,
ownerUserId: issue.owner.id,
owner: issue.owner.name,
},
});
return;
}
if (
!deps.agents.agents.mayInvoke(
agent,
identity?.actorUserId ?? move.byUserId,
)
)
return;
await runs.enqueue(
{
agentId: agent.id,
subject: run.subject,
actorUserId: move.byUserId,
causedByRunId: run.id,
ownerUserId: issue.owner.id,
priority: runPriorityOf(issue.priority as Priority),
input: {
@@ -546,6 +666,7 @@ export function createAgentWork(deps: {
},
async startNow(tx, issue, actor) {
requireOwnerStart(issue, actor);
const agentId = agentOf(issue);
if (!agentId) throw new Error('No agent executes the issue.');
return assigned(
@@ -559,8 +680,17 @@ 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 [];
const from = agentOf(before);
const to = agentOf(after);
if (
to === PROJECT_MANAGER_ID &&
change.start &&
!dormant(after) &&
(!before || dormant(before) || to !== from)
)
requireOwnerStart(after, change.actor);
const who = await actorOf(tx, change.actor, after);
if (from && from !== to)
await stop(
@@ -618,6 +748,7 @@ export function createAgentWork(deps: {
agentId: to,
issue: after,
actorUserId: who.userId,
causedByRunId: who.causedByRunId,
triggerType: started && fromDormant ? 'started' : 'statusChange',
input: {
type: 'statusChange',
@@ -631,6 +762,7 @@ export function createAgentWork(deps: {
async onCommentCreated(tx, change) {
const { comment, issue, parent } = change;
if (statusCategory(issue.statusKey) === 'unstarted') return [];
const authorId = change.actor.id;
if (!authorId) return [];
const byAgent = change.actor.type === AGENT_KIND;
@@ -678,7 +810,10 @@ export function createAgentWork(deps: {
triggerType,
})),
};
if (!byAgent) return wakeComment(tx, issue, held, authorId, {});
if (!byAgent)
return wakeComment(tx, issue, held, authorId, {
causedByRunId: change.actor.trace?.runId,
});
if (await commentLimited(tx, issue, held))
return held.targets.map((target) =>
skipped(target.agentId, issue, target.triggerType, 'limited'),
@@ -686,6 +821,7 @@ export function createAgentWork(deps: {
// Runs act for a person: the one the agent's run acts for, else the owner.
return wakeComment(tx, issue, held, change.userId ?? issue.ownerUserId, {
byAgent: true,
causedByRunId: change.actor.trace?.runId,
});
},
@@ -694,32 +830,38 @@ export function createAgentWork(deps: {
return wakeComment(tx, issue, held, userId, { continuedFrom });
},
async onOwnerChanged(tx, { issue, from, to }) {
const withdrawn = await runs.withdrawQueued(
{ subject: subjectOf(issue), actorUserId: from, byUserId: null },
async onOwnerChanged(tx, { issue, from, to, actor }) {
if (tx.rehearsal) return [];
const subject = subjectOf(issue);
const who = await actorOf(
tx,
actor ?? { type: 'system', id: null },
issue,
);
// Preserve a transfer marker and every original input before cancellation sinks can run.
for (const run of await runs.openOn(tx.conn, subject))
await runs.addInput(
run.id,
{
type: 'signal',
actor: who.ref,
text: `${issue.identifier} has a new owner. Stop this work; the new owner decides when to resume.`,
payload: { trigger: 'ownerChanged', from, to },
},
agentsTx(tx),
);
await runs.withdrawQueued(
{
subject,
byUserId: actor?.type === 'user' ? actor.id : null,
detail: `${issue.identifier} is waiting for its new owner to take over.`,
},
agentsTx(tx),
);
const attempts: RunAttempt[] = [];
for (const agentId of new Set(withdrawn.map((run) => run.agentId)))
attempts.push(
await wake(tx, {
agentId,
issue,
actorUserId: to,
triggerType: 'ownerChanged',
input: {
type: 'signal',
actor: {
kind: 'user',
id: to,
name: await userName(tx.conn, to),
},
text: `${issue.identifier} has a new owner; the work continues for them.`,
payload: { from, to },
},
}),
);
return attempts;
for (const run of await runs.openOn(tx.conn, subject))
if (run.status !== 'queued')
await runs.cancel(run.id, who.userId, agentsTx(tx));
return [];
},
async onBlocked(tx, { issue }) {
+26 -2
View File
@@ -37,6 +37,8 @@ import {
} from '../../shared/git.js';
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 type { StudioInboxPort } from '../inbox/port.js';
import {
IN_REVIEW,
@@ -59,7 +61,7 @@ import {
export interface GitFlowDeps {
readonly projects: () => Pick<
Projects,
'tx' | 'statusEvents' | 'issueContext'
'tx' | 'statusEvents' | 'issueContext' | 'activity'
>;
readonly agents: () => Pick<Agents, 'agents' | 'runs'> | undefined;
readonly inbox: () => StudioInboxPort | undefined;
@@ -271,7 +273,29 @@ export function createGitFlow(deps: GitFlowDeps): GitFlow {
tx.conn,
issue.executor.id,
);
if (!agent || !agents.agents.mayInvoke(agent, issue.owner.id)) continue;
if (!agent) continue;
const allowed = agents.agents.mayInvoke(agent, issue.owner.id);
if (agent.id === PROJECT_MANAGER_ID) {
const validOwner = await validIssueOwner(tx.conn, issue.owner.id);
if (!validOwner || !allowed) {
await deps.projects().activity.record(tx.conn, {
issueId: issue.id,
actor: { type: 'system', id: null },
action: 'studio_execution_wait',
details: {
reason: validOwner ? 'notAllowed' : 'noOwner',
trigger: signal,
agentId: agent.id,
agent: agent.name,
ownerUserId: issue.owner.id,
owner: issue.owner.name,
},
});
tx.emit({ type: 'issue.changed', issueId: issue.id });
continue;
}
}
if (!allowed) continue;
await agents.runs.enqueue(
{
agentId: agent.id,
@@ -80,6 +80,15 @@ export type IssueProgressState =
| 'idle';
export interface IssueProgress {
readonly waiting?: {
readonly reason:
| 'noOwner'
| 'notConfigured'
| 'noRunner'
| 'ownerHasNoRunner'
| 'notAllowed';
readonly owner: { readonly id: string; readonly name: string } | null;
} | null;
readonly state: IssueProgressState;
readonly agent: { readonly id: string; readonly name: string } | null;
readonly step: string | null;
@@ -97,7 +97,7 @@ describe('issues start work for the project manager', () => {
expect.objectContaining({
type: 'signal',
actor: { kind: 'user', id: 'alice', name: 'alice' },
payload: { trigger: 'assigned' },
payload: { trigger: 'assigned', ownerIdentity: true },
}),
]);
});
@@ -176,10 +176,11 @@ describe('issues start work for the project manager', () => {
});
});
it("withdraws the previous owner's queued work and queues it again for the new owner", async () => {
it('withdraws queued work on transfer without changing identity or restarting', async () => {
await h.createAgent();
const issue = await openIssue(alice());
const assigned = await assign(issue);
const [before] = await runsOf(issue.id);
const { attempts } = await collectRunAttempts(() =>
h.projects.issues.update(alice(), issue.id, {
revision: assigned.revision,
@@ -189,11 +190,9 @@ describe('issues start work for the project manager', () => {
const runs = await runsOf(issue.id);
expect(runs.map((run) => [run.status, run.actorUserId]).sort()).toEqual([
['cancelled', 'alice'],
['queued', 'bob'],
]);
expect(attempts).toEqual([
expect.objectContaining({ triggerType: 'ownerChanged', started: true }),
]);
expect(runs[0].id).toBe(before.id);
expect(attempts).toEqual([]);
});
});
@@ -226,7 +225,7 @@ describe('comments reach agents', () => {
});
});
it('queues another person’s comment separately behind the active work', async () => {
it('appends another person’s comment to the manager working as the owner', async () => {
await h.createAgent();
const issue = await openIssue(alice());
await assign(issue);
@@ -235,23 +234,25 @@ describe('comments reach agents', () => {
content: 'Use the v2 API.',
});
const work = await runsOf(issue.id);
expect(work).toHaveLength(2);
const next = work.find((run) => run.id !== payload.run.id)!;
expect(next).toMatchObject({ status: 'queued', actorUserId: 'bob' });
expect(work).toHaveLength(1);
const next = work[0];
expect(next).toMatchObject({
id: payload.run.id,
status: 'dispatched',
actorUserId: 'alice',
});
expect(
(await h.agents.runs.detail(payload.run.id)).inputs.some(
(input) => input.text === 'Use the v2 API.',
),
).toBe(false);
).toBe(true);
expect((await h.agents.runs.detail(next.id)).inputs.at(-1)).toMatchObject({
type: 'comment',
text: 'Use the v2 API.',
actor: { id: 'bob' },
});
const workload = await h.agents.runs.workload({ subjectKind: 'issue' });
expect(workload.runs.find((run) => run.id === next.id)?.wait?.reason).toBe(
'sameWorkActive',
);
expect(workload.runs.find((run) => run.id === next.id)?.wait).toBeNull();
});
it('wakes a mentioned agent, and refuses mentioning one that takes no work', async () => {
@@ -309,7 +310,7 @@ describe("an agent's comment", () => {
return payload.run.id as string;
}
it('wakes the agent it mentions, as the person its run acts for', async () => {
it('wakes a step the project manager mentions as the issue owner', async () => {
const coordinator = PM;
const worker = await h.createAgent({ name: 'Worker' });
const issue = await openIssue(alice(), { title: 'Split' });
@@ -320,7 +321,7 @@ describe("an agent's comment", () => {
);
expect(triggered).toEqual([{ kind: 'agent', id: worker }]);
const [run] = await runsOf(issue.id);
expect(run).toMatchObject({ agentId: worker, actorUserId: 'bob' });
expect(run).toMatchObject({ agentId: worker, actorUserId: 'alice' });
expect((await h.agents.runs.detail(run!.id)).inputs.at(-1)).toMatchObject({
type: 'comment',
actor: { kind: 'agent', id: coordinator, name: 'Project manager' },
@@ -511,6 +512,20 @@ describe("an agent's comment", () => {
content: `${mention(worker, 'Worker')} again`,
});
const pending = (await pendingCommentRun(h.projects, alice(), issue.id))!;
h.roles.set('bob', 'admin');
expect(
await pendingCommentRun(h.projects, h.viewer('bob'), issue.id),
).toBeNull();
const refused = await h.request(
'POST',
`/issueCommentRuns/${issue.id}/continue`,
{
user: 'bob',
body: { token: pending.token },
},
);
expect(refused.status).toBe(403);
expect(refused.body.error.reason).toBe('ISSUE_OWNER_START_REQUIRED');
const attempts = await Promise.all(
[0, 1].map(() =>
h.request('POST', `/issueCommentRuns/${issue.id}/continue`, {
@@ -1514,6 +1529,28 @@ describe('an issue moved to another project', () => {
);
}
it('does not restart a project move after its running agent is stopped by an owner transfer', async () => {
const { core, issue } = await movable();
const payload = await h.claimOne();
const runId = payload.run.id;
await start(runId);
await move(issue.id, core.id);
const current = await h.projects.issueQueries.detail(alice(), issue.id);
await h.projects.issues.update(alice(), issue.id, {
revision: current.revision,
ownerUserId: 'bob',
});
expect((await h.runner(RUNNER_ROUTES.cancelAck, runId, {})).status).toBe(
200,
);
const runs = await runsOf(issue.id);
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({
status: 'cancelled',
actorUserId: 'alice',
});
});
/**
* The repositories the run's context lists as its project's working directories; the project manager checks none
* of them out.
@@ -123,7 +123,7 @@ describe('claiming queued issue work', () => {
});
});
it('admits a mentioned agent on a backlog issue, and does not wake the project manager there', async () => {
it('does not wake any mentioned agent on a backlog issue', async () => {
const harness = await setUp();
const coder = await harness.createAgent({ name: 'Coder' });
const issue = await harness.projects.issues.create(
@@ -136,15 +136,9 @@ describe('claiming queued issue work', () => {
const claimed = [await harness.claimOne(), await harness.claimOne()].filter(
Boolean,
);
expect(claimed).toHaveLength(1);
expect(claimed).toHaveLength(0);
const runs = await runsOf(harness, issue.id);
expect(runs.find((run) => run.agentId === coder)).toMatchObject({
id: claimed[0].run.id,
status: 'dispatched',
});
expect(
runs.find((run) => run.agentId === PROJECT_MANAGER_ID),
).toBeUndefined();
expect(runs).toEqual([]);
});
it('cancels a run whose issue is gone', async () => {
@@ -147,9 +147,11 @@ describe('an issue run', () => {
});
const next = await h.claimOne();
expect(next.prompt.session).toBe('resume');
expect(next.prompt.turn).toContain(`New comments on ${issue.identifier}`);
expect(next.prompt.turn).toContain(
`New input for issue ${issue.identifier}`,
);
expect(next.prompt.turn).toContain('Background from Bob.');
// Alice's comment is this turn's input, so it is not repeated as news.
// Both comments merge into the owner's next run; neither is repeated as news.
expect(next.prompt.turn.match(/Please continue\./gu)).toHaveLength(1);
});
@@ -289,7 +289,7 @@ describe('delegate and report back', () => {
const delegated = await delegate();
for (let round = 0; round <= WAKE_LIMIT; round += 1) {
await moveIssue('carol', delegated.issueId, 'done');
await moveIssue('carol', delegated.issueId, 'in_progress');
await moveIssue('alice', delegated.issueId, 'in_progress');
}
expect(await cards(delegated.conversationId)).toHaveLength(WAKE_LIMIT + 1);
expect(await wakes(delegated.conversationId)).toHaveLength(WAKE_LIMIT);
@@ -0,0 +1,631 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
HEADERS,
PROTOCOL_VERSION,
RUNNER_ROUTES,
} from '@nocobase/agent-protocol';
import { createBridgeHarness, type BridgeHarness } from './bridge-harness.js';
import { PROJECT_MANAGER_ID as PM } from '../../shared/project-manager.js';
import { runTriggerActor } from '../../client/agents/run-trigger.js';
let h: BridgeHarness;
beforeEach(async () => {
h = await createBridgeHarness();
await h.addUser('alice');
await h.addUser('bob');
h.roles.set('bob', 'admin');
await h.createAgent();
});
afterEach(async () => {
vi.restoreAllMocks();
await h.close();
});
const runs = (id: string) =>
h.agents.runs.list({ subjectKind: 'issue', subjectId: id });
const issue = () =>
h.projects.issues.create(h.viewer('alice'), {
title: 'Owner identity',
statusKey: 'in_progress',
start: false,
});
const mention = (id: string) => `[@Agent](mention://agent/${id}) do the step`;
describe('manager and delegated identities', () => {
it('keeps three handled retries and their subsequent delegation in the manager chain at enqueue and claim', async () => {
const task = await issue();
const worker = await h.createAgent({ name: 'Worker' });
const next = await h.createAgent({ name: 'Next' });
await h.projects.comments.create(
{ ...h.viewer('bob'), actor: { type: 'agent', id: PM } },
task.id,
{ content: mention(worker) },
);
let [run] = await runs(task.id);
await h.agents.runs.cancel(run.id, 'bob');
for (let n = 0; n < 3; n++) {
await h.database
.connection()
.repository('agRunInputs')
.updateMany({
filter: { runId: run.id },
values: { handledAt: new Date().toISOString() },
});
run = await h.agents.runs.retry(run.id, 'bob');
expect(run.actorUserId).toBe('alice');
// Legacy incorrect actors must also be corrected by claim admission.
await h.database
.connection()
.repository('agRuns')
.updateMany({
filter: { id: run.id },
values: { actorUserId: 'bob' },
});
expect((await h.claimOne()).run.id).toBe(run.id);
expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice');
if (n < 2) {
await h.agents.runs.cancel(run.id, 'bob');
await h.runner(RUNNER_ROUTES.cancelAck, run.id, {});
}
}
await h.projects.comments.create(
{
...h.viewer('bob'),
actor: { type: 'agent', id: worker, trace: { runId: run.id } },
},
task.id,
{ content: mention(next) },
);
const step = (await runs(task.id)).find((item) => item.agentId === next)!;
expect(step.actorUserId).toBe('alice');
expect((await h.claimOne()).run.id).toBe(step.id);
expect((await h.agents.runs.get(step.id)).actorUserId).toBe('alice');
});
it('selects this retry actor while retaining the earlier unhandled retry input', async () => {
const task = await issue();
await h.projects.comments.create(h.viewer('alice'), task.id, {
content: 'Please work',
});
const [original] = await runs(task.id);
await h.agents.runs.cancel(original.id, 'alice');
const first = await h.agents.runs.retry(original.id, 'bob');
await h.agents.runs.cancel(first.id, 'bob');
const second = await h.agents.runs.retry(first.id, 'alice');
const detail = await h.agents.runs.detail(second.id);
expect(
detail.inputs
.filter((input) => input.type === 'retry')
.map((input) => input.actor.id),
).toEqual(['bob', 'alice']);
expect(runTriggerActor(detail)?.id).toBe('alice');
});
it('preserves the retrying person for a direct worker through repeated handled retries', async () => {
const task = await issue();
const worker = await h.createAgent({ name: 'Direct worker' });
await h.projects.comments.create(h.viewer('bob'), task.id, {
content: mention(worker),
});
let run = (await runs(task.id)).find((item) => item.agentId === worker)!;
for (let n = 0; n < 3; n++) {
await h.agents.runs.cancel(run.id, 'bob');
await h.database
.connection()
.repository('agRunInputs')
.updateMany({
filter: { runId: run.id },
values: { handledAt: new Date().toISOString() },
});
run = await h.agents.runs.retry(run.id, 'bob');
expect(run.actorUserId).toBe('bob');
}
});
it('stops cyclic retry provenance and ignores a manager run on another subject', async () => {
const task = await issue();
const worker = await h.createAgent({ name: 'Direct worker' });
await h.projects.comments.create(h.viewer('bob'), task.id, {
content: mention(worker),
});
const original = (await runs(task.id)).find(
(item) => item.agentId === worker,
)!;
const lookup = vi
.spyOn(h.agents.runs, 'identityInputs')
.mockImplementation(async (_conn, id) => ({
run: {
...original,
id,
retryOfRunId: id === 'first' ? 'second' : 'first',
},
inputs: [],
}));
const resolve = h.agents.subjects.get('issue')!.actsAs!;
const work = {
subjectId: task.id,
agentId: worker,
inputs: [
{
actor: { kind: 'user' as const, id: 'bob', name: 'Bob' },
payload: { retryOfRunId: 'first' },
},
],
};
expect(await resolve(h.database.connection(), work)).toBeUndefined();
expect(lookup).toHaveBeenCalledTimes(2);
lookup.mockResolvedValue({
run: {
...original,
agentId: PM,
subject: { ...original.subject, id: 'another-issue' },
},
inputs: [],
});
expect(await resolve(h.database.connection(), work)).toBeUndefined();
lookup.mockResolvedValue({
run: {
...original,
agentId: PM,
subject: { ...original.subject, kind: 'conversation' },
},
inputs: [],
});
expect(await resolve(h.database.connection(), work)).toBeUndefined();
});
it('requires the owner to start, then uses the owner for others commenting and retrying', async () => {
const task = await issue();
expect(
(
await h.request('POST', `/projectManager/issues/${task.id}/start`, {
user: 'bob',
})
).status,
).toBe(403);
expect(await runs(task.id)).toHaveLength(0);
expect(
(
await h.request('POST', `/projectManager/issues/${task.id}/start`, {
user: 'alice',
})
).status,
).toBe(200);
await h.projects.comments.create(h.viewer('bob'), task.id, {
content: 'Please check this',
});
const [run] = await runs(task.id);
expect(run).toMatchObject({
agentId: PM,
actorUserId: 'alice',
requestedByUserId: 'alice',
ownerUserId: 'alice',
});
expect(
(await h.agents.runs.detail(run.id)).inputs.map(
(input) => input.actor.id,
),
).toEqual(['alice', 'bob']);
await h.agents.runs.cancel(run.id, 'bob');
const retried = await h.agents.runs.retry(run.id, 'bob');
expect(retried).toMatchObject({
actorUserId: 'alice',
requestedByUserId: 'bob',
});
const claimed = await h.claimOne();
h.roles.set('alice', 'none');
expect(
(await h.request('GET', `/projects/issues/${task.id}`, { user: 'bob' }))
.status,
).toBe(200);
expect(
(
await h.request('GET', `/projects/issues/${task.id}`, {
runToken: claimed.cli.credential.content.token,
})
).status,
).not.toBe(200);
});
it('never queues backlog comments, including owner and worker mentions', async () => {
const task = await h.projects.issues.create(h.viewer('alice'), {
title: 'Not started',
statusKey: 'backlog',
start: false,
});
const worker = await h.createAgent({ name: 'Worker' });
for (const user of ['alice', 'bob'])
await h.projects.comments.create(h.viewer(user), task.id, {
content: mention(worker),
});
expect(await runs(task.id)).toEqual([]);
});
it('rejects a non-owner status start and rolls back the issue change', async () => {
const task = await h.projects.issues.create(h.viewer('alice'), {
title: 'Owner starts',
statusKey: 'backlog',
start: false,
});
await expect(
h.projects.issues.update(h.viewer('bob'), task.id, {
revision: task.revision,
statusKey: 'in_progress',
start: true,
}),
).rejects.toMatchObject({ reason: 'ISSUE_OWNER_START_REQUIRED' });
expect(
(await h.projects.issueQueries.detail(h.viewer('alice'), task.id))
.statusKey,
).toBe('backlog');
expect(await runs(task.id)).toEqual([]);
});
it('covers the delegated chain but preserves direct human worker mentions', async () => {
const task = await issue();
const worker = await h.createAgent({ name: 'Worker' });
const next = await h.createAgent({ name: 'Next' });
await h.projects.comments.create(h.viewer('bob'), task.id, {
content: mention(worker),
});
expect(
(await runs(task.id)).find((run) => run.agentId === worker)?.actorUserId,
).toBe('bob');
const delegated = await h.projects.comments.create(
{ ...h.viewer('bob'), actor: { type: 'agent', id: PM } },
task.id,
{ content: mention(next) },
);
const chain = (await runs(task.id)).find((run) => run.agentId === next)!;
expect(chain.actorUserId).toBe('alice');
await h.projects.comments.create(
{
...h.viewer('alice'),
actor: { type: 'agent', id: next, trace: { runId: chain.id } },
},
task.id,
{ parentId: delegated.comment.id, content: mention(worker) },
);
expect(
(await runs(task.id)).find(
(run) => run.agentId === worker && run.actorUserId === 'alice',
),
).toBeDefined();
await h.addUser('carol');
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
task.id,
);
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'carol',
});
const switched = await runs(task.id);
expect(
switched.find(
(run) => run.agentId === worker && run.actorUserId === 'bob',
),
).toBeDefined();
expect(switched.every((run) => run.status === 'cancelled')).toBe(true);
expect(switched.find((run) => run.id === chain.id)?.actorUserId).toBe(
'alice',
);
});
it('corrects legacy delegated data at claim and requests cancellation on transfer', async () => {
const task = await issue();
const worker = await h.createAgent({ name: 'Worker' });
await h.projects.comments.create(
{ ...h.viewer('bob'), actor: { type: 'agent', id: PM } },
task.id,
{ content: mention(worker) },
);
const [run] = await runs(task.id);
const original = (await h.agents.runs.detail(run.id)).inputs;
// Simulate a pre-upgrade hand-over, without the new identity marker.
await h.database
.connection()
.repository('agRuns')
.updateMany({ filter: { id: run.id }, values: { actorUserId: 'bob' } });
await h.database
.connection()
.repository('agRunInputs')
.updateMany({
filter: { runId: run.id },
values: {
payload: {
commentId: (original[0].payload as { commentId: string }).commentId,
},
},
});
const payload = await h.claimOne();
expect(payload.run.id).toBe(run.id);
expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice');
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
task.id,
);
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
});
expect((await h.agents.runs.get(run.id)).actorUserId).toBe('alice');
expect((await h.agents.runs.get(run.id)).cancelRequestedAt).not.toBeNull();
expect(
(await h.agents.runs.detail(run.id)).inputs.at(-1)?.payload,
).toMatchObject({ trigger: 'ownerChanged' });
expect(await runs(task.id)).toHaveLength(1);
expect((await h.runner(RUNNER_ROUTES.cancelAck, run.id, {})).status).toBe(
200,
);
});
it('withdraws every queued agent on transfer, retains inputs and stops held work even when the executor is cleared', async () => {
const task = await issue();
await h.request('POST', `/projectManager/issues/${task.id}/start`, {
user: 'alice',
});
const payload = await h.claimOne();
const worker = await h.createAgent({ name: 'Direct worker' });
await h.projects.comments.create(h.viewer('bob'), task.id, {
content: mention(worker),
});
const before = await runs(task.id);
const queued = before.filter((run) => run.status === 'queued');
expect(queued.length).toBeGreaterThan(0);
const inputs = await Promise.all(
queued.map((run) => h.agents.runs.detail(run.id)),
);
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
task.id,
);
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
executor: null,
});
const after = await runs(task.id);
expect(after).toHaveLength(before.length);
for (const previous of inputs) {
const now = await h.agents.runs.detail(previous.id);
expect(now.status).toBe('cancelled');
expect(now.actorUserId).toBe(previous.actorUserId);
expect(now.inputs.slice(0, previous.inputs.length)).toEqual(
previous.inputs,
);
}
expect(await h.agents.runs.get(payload.run.id)).toMatchObject({
actorUserId: 'alice',
status: 'dispatched',
cancelRequestedAt: expect.any(String),
});
expect(
(await h.agents.runs.detail(payload.run.id)).inputs.at(-1)?.actor.id,
).toBe('bob');
const acknowledged = await h.runner(
RUNNER_ROUTES.cancelAck,
payload.run.id,
{},
);
expect(acknowledged.status).toBe(200);
expect((await h.agents.runs.get(payload.run.id)).status).toBe('cancelled');
await expect(
h.agents.runs.authenticateToken(payload.cli.credential.content.token),
).rejects.toMatchObject({ code: 'RUN_TOKEN_INVALID' });
expect(await runs(task.id)).toHaveLength(before.length);
});
it('follows recorded run provenance for user-approved steps and legacy queued inputs', async () => {
const task = await issue();
const worker = await h.createAgent({ name: 'Worker' });
const next = await h.createAgent({ name: 'Next' });
await h.projects.comments.create(
{ ...h.viewer('bob'), actor: { type: 'agent', id: PM } },
task.id,
{ content: mention(worker) },
);
const cause = (await runs(task.id)).find((run) => run.agentId === worker)!;
const result = await h.projects.comments.create(
{
...h.viewer('bob'),
actor: {
type: 'user',
id: 'bob',
via: 'agent',
trace: { runId: cause.id, agentId: worker },
},
},
task.id,
{ content: mention(next) },
);
const step = (await runs(task.id)).find((run) => run.agentId === next)!;
expect(step.actorUserId).toBe('alice');
await h.database
.connection()
.repository('agRunInputs')
.updateMany({
filter: { runId: step.id },
values: { payload: { commentId: result.comment.id } },
});
await h.addUser('carol');
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
task.id,
);
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'carol',
});
expect(await h.agents.runs.get(step.id)).toMatchObject({
actorUserId: 'alice',
status: 'cancelled',
});
});
it('retains the system source and reports no runner and inactive owner waiting', async () => {
const task = await issue();
await h.agents.runs.enqueue({
agentId: PM,
subject: { kind: 'issue', id: task.id },
actorUserId: 'alice',
input: {
type: 'signal',
actor: { kind: 'system', id: 'system', name: 'Studio' },
text: 'PR checks passed',
payload: { trigger: 'prChecksPassed' },
},
});
const [run] = await runs(task.id);
expect(run.actorUserId).toBe('alice');
expect((await h.agents.runs.detail(run.id)).inputs[0].actor.kind).toBe(
'system',
);
const queued = await h.request('GET', `/issueProgress/${task.id}`, {
user: 'bob',
});
expect(queued.body.data.waiting.reason).toBe('noRunner');
const manager = await h.agents.agents.findWorkable(
h.database.connection(),
PM,
);
const registration = await h.agents.runners.createRegistrationToken('bob', {
trust: 'ownerOnly',
});
const registered = await h.request('POST', '/agents/runners/register', {
body: {
registrationToken: registration.token,
name: 'Bob team runner',
hostname: 'test',
os: 'linux',
arch: 'x64',
version: '0.0.1',
protocolVersion: PROTOCOL_VERSION,
slots: 1,
features: ['input', 'checkout', 'directories', 'skills', 'secrets'],
tools: [
{
kind: 'claude',
authenticated: true,
models: [{ id: manager!.modelEntries[0].model }],
},
],
},
});
expect(registered.status).toBe(200);
expect(
(await h.request('GET', `/issueProgress/${task.id}`, { user: 'bob' }))
.body.data.waiting.reason,
).toBe('ownerHasNoRunner');
const settings = await h.manager.settings();
await h.manager.update('bob', {
firstChoiceAgentId: null,
fallbackAgentId: null,
});
expect(
(await h.request('GET', `/issueProgress/${task.id}`, { user: 'bob' }))
.body.data.waiting.reason,
).toBe('notConfigured');
await h.manager.update('bob', {
firstChoiceAgentId: settings.firstChoiceAgentId,
fallbackAgentId: settings.fallbackAgentId,
});
await h.database
.connection()
.repository('user')
.updateMany({
filter: { id: 'alice' },
values: { disabledAt: new Date().toISOString() },
});
const invalid = await h.request('GET', `/issueProgress/${task.id}`, {
user: 'bob',
});
expect(invalid.body.data.waiting.reason).toBe('noOwner');
await h.projects.comments.create(h.viewer('bob'), task.id, {
content: 'Try again',
});
const activities = (
await h.projects.issueQueries.activities(h.viewer('bob'), task.id, {})
).data;
expect(activities).toContainEqual(
expect.objectContaining({
action: 'studio_execution_wait',
details: expect.objectContaining({ reason: 'noOwner' }),
}),
);
const claim = await h.request('POST', '/agents/runners/claim', {
headers: { [HEADERS.runnerKey]: registered.body.data.runnerKey },
body: { free: 1 },
});
expect(claim.status).toBe(200);
expect(claim.body.data.runs).toEqual([]);
expect((await h.agents.runs.get(run.id)).failureDetail).toBe('noOwner');
});
it('retries a delegated step as the new owner even when every original input was handled', async () => {
const task = await issue();
const worker = await h.createAgent({ name: 'Worker' });
await h.projects.comments.create(
{ ...h.viewer('bob'), actor: { type: 'agent', id: PM } },
task.id,
{ content: mention(worker) },
);
const [previous] = await runs(task.id);
await h.agents.runs.cancel(previous.id, 'bob');
await h.database
.connection()
.repository('agRunInputs')
.updateMany({
filter: { runId: previous.id },
values: { handledAt: new Date().toISOString() },
});
const current = await h.projects.issueQueries.detail(
h.viewer('bob'),
task.id,
);
await h.projects.issues.update(h.viewer('bob'), task.id, {
revision: current.revision,
ownerUserId: 'bob',
});
const retry = await h.agents.runs.retry(previous.id, 'bob');
expect(retry.actorUserId).toBe('bob');
expect(
(await h.agents.runs.detail(retry.id)).inputs.map((input) => input.type),
).toEqual(['retry']);
const claimed = await h.claimOne();
expect(claimed.run.id).toBe(retry.id);
expect((await h.agents.runs.get(retry.id)).actorUserId).toBe('bob');
});
it('checks owner invocation permission and records denial without fallback', async () => {
const task = await issue();
const invoke = vi
.spyOn(h.agents.agents, 'mayInvoke')
.mockImplementation((_agent, userId) => userId !== 'alice');
const started = await h.request(
'POST',
`/projectManager/issues/${task.id}/start`,
{ user: 'alice' },
);
expect(started.status).toBe(400);
expect(await runs(task.id)).toEqual([]);
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({ id: PM }),
'alice',
);
const now = await h.request('GET', `/issueProgress/${task.id}`, {
user: 'bob',
});
expect(now.body.data.waiting.reason).toBe('notAllowed');
expect(
(await h.projects.issueQueries.activities(h.viewer('bob'), task.id, {}))
.data,
).toContainEqual(
expect.objectContaining({
action: 'studio_execution_wait',
actorId: 'alice',
details: expect.objectContaining({ reason: 'notAllowed' }),
}),
);
});
});
@@ -207,6 +207,8 @@ describe('Studio’s command manifest', () => {
const issue = await projects.issues.create(viewer, {
title: 'Fix login',
executor: null,
statusKey: 'in_progress',
start: false,
});
await projects.comments.post({ type: 'user', id: me.user.id }, issue.id, {
content: `[@Coder](mention://agent/${agentId}) fix it`,
@@ -135,4 +135,11 @@ describe('continuing an agent comment', () => {
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(mocks.request).not.toHaveBeenCalled();
});
it('hides continuation from an editor who is not the owner', async () => {
mocks.viewer = { userId: 'bob' };
await mount(<IssueCommentRunLimit issue={issue} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(mocks.request).not.toHaveBeenCalled();
});
});
@@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
request: vi.fn(),
show: vi.fn(),
editable: true,
userId: 'alice',
}));
vi.mock('@nocobase/app-client', async (original) => ({
...(await original<typeof import('@nocobase/app-client')>()),
@@ -34,7 +35,7 @@ vi.mock('@nocobase/app-client', async (original) => ({
useToaster: () => ({ show: mocks.show }),
}));
vi.mock('@nocobase/app-plugin-projects/client/kit', () => ({
useViewer: () => ({ userId: 'alice' }),
useViewer: () => ({ userId: mocks.userId }),
}));
vi.mock('@nocobase/app-plugin-projects/client/issues', () => ({
canEditIssues: () => mocks.editable,
@@ -54,10 +55,11 @@ const progress = (overrides: Partial<IssueProgress> = {}): IssueProgress => ({
...overrides,
});
const issue = (statusKey: string) =>
({ id: 'i1', statusKey, revision: 1 }) as IssueDetail;
({ id: 'i1', ownerUserId: 'alice', statusKey, revision: 1 }) as IssueDetail;
beforeEach(() => {
mocks.editable = true;
mocks.userId = 'alice';
mocks.request.mockReset();
mocks.show.mockReset();
});
@@ -227,3 +229,14 @@ describe('what an issue waits for', () => {
expect(screen.queryByRole('button')).toBeNull();
});
});
it('hides start and hand-over from an editor who is not the owner', async () => {
mocks.userId = 'bob';
await mount('backlog', progress());
expect(screen.queryByRole('button', { name: 'Start' })).toBeNull();
cleanup();
await mount('in_progress', progress({ handOver: true }));
expect(
screen.queryByRole('button', { name: 'Hand over to the project manager' }),
).toBeNull();
});
@@ -1,4 +1,5 @@
import agents from '@nocobase/app-plugin-agents/client';
import type { RunDetail } from '@nocobase/app-plugin-agents/shared/runs';
import {
createTestI18nRuntime,
TestI18nProvider,
@@ -26,6 +27,8 @@ const state = vi.hoisted(() => ({
basePath: '/main',
events: [] as RunTranscriptEvent[],
retriedRunId: null as string | null,
retryOfRunId: null as string | null,
inputs: [] as RunDetail['inputs'],
}));
vi.mock('@nocobase/app-client', async (original) => ({
...(await original<typeof import('@nocobase/app-client')>()),
@@ -57,6 +60,9 @@ vi.mock('@nocobase/app-plugin-agents/client/runs', async (original) => ({
data: {
id: 'run-1',
retriedRunId: state.retriedRunId,
retryOfRunId: state.retryOfRunId,
actorUserId: 'alice',
actorName: 'Alice',
agentId: 'coder',
status: 'failed',
createdAt: '2026-10-01T00:00:00.000Z',
@@ -65,7 +71,7 @@ vi.mock('@nocobase/app-plugin-agents/client/runs', async (original) => ({
cancelRequestedAt: null,
failureReason: 'toolProcess',
failureDetail: 'The command failed',
inputs: [],
inputs: state.inputs,
maxAttempts: 1,
summary: 'Run summary',
},
@@ -135,10 +141,40 @@ beforeEach(() => {
state.basePath = '/main';
state.events = mixedRunEvents();
state.retriedRunId = null;
state.retryOfRunId = null;
state.inputs = [];
});
afterEach(cleanup);
describe('Studio issue run transcript', () => {
it.each(['en-US', 'zh-CN'])(
'shows the current retry trigger rather than a copied retry (%s)',
async (locale) => {
state.retryOfRunId = 'bob-retry';
state.inputs = ['Bob', 'Alice'].map((name, index) => ({
id: `input-${index}`,
runId: 'run-1',
type: 'retry',
text: 'Retry',
actor: { kind: 'user', id: name.toLowerCase(), name },
payload: { retryOfRunId: index === 0 ? 'original' : 'bob-retry' },
createdAt: '2026-10-01T00:00:00.000Z',
handledAt: null,
deliveredAt: null,
}));
await setup(locale);
expect(
screen.getByText(
locale === 'zh-CN' ? /触发人:Alice/u : /Triggered by Alice/u,
),
).toBeInTheDocument();
expect(
screen.queryByText(
locale === 'zh-CN' ? /触发人:Bob/u : /Triggered by Bob/u,
),
).not.toBeInTheDocument();
},
);
it.each(['en-US', 'zh-CN'])(
'links the replacement instead of offering another retry (%s)',
async (locale) => {
@@ -89,6 +89,8 @@ async function pushedIssue(title: string, sha: string): Promise<Issue> {
title,
projectId,
executor: null,
statusKey: 'in_progress',
start: false,
});
// Alice hands it the step.
await h.projects.comments.post({ type: 'user', id: 'alice' }, issue.id, {
@@ -744,7 +744,15 @@ describe('nb-studio pr open', () => {
'studio.git/open-pr',
],
});
const { issue, resource } = await projectWithRepo(connection);
const { issue: created, resource } = await projectWithRepo(
connection,
'in_progress',
);
const issue = await h.projects.issues.update(alice(), created.id, {
revision: created.revision,
executor: null,
start: false,
});
await mentionAgent(h, 'alice', issue.id, agentId);
beforeClaim?.();
const payload = await h.claimOne();
@@ -952,6 +960,8 @@ describe('nb-studio pr open', () => {
});
const loose = await h.projects.issues.create(alice(), {
title: 'Loose',
executor: null,
statusKey: 'in_progress',
start: false,
});
await mentionAgent(h, 'alice', loose.id, agentId);
@@ -1000,6 +1010,8 @@ describe('nb-studio pr open', () => {
const agentId = await h.createAgent({ actions: ['pm.issues/view'] });
const second = await h.projects.issues.create(alice(), {
title: 'Second',
executor: null,
statusKey: 'in_progress',
projectId: project.id,
start: false,
});
@@ -1012,6 +1024,8 @@ describe('nb-studio pr open', () => {
h.preferences.set(`alice:${ATTRIBUTION_PREFERENCE}`, 'withAgent');
const third = await h.projects.issues.create(alice(), {
title: 'Third',
executor: null,
statusKey: 'in_progress',
projectId: project.id,
start: false,
});
@@ -704,6 +704,8 @@ describe('pull requests', () => {
});
const issue = await h.projects.issues.create(alice(), {
title: 'Fix login',
executor: null,
statusKey: 'in_progress',
start: false,
});
await mentionAgent(h, 'alice', issue.id, agentId);
@@ -857,6 +859,8 @@ describe('pull requests', () => {
});
const issue = await h.projects.issues.create(alice(), {
title: 'Fix login',
executor: null,
statusKey: 'in_progress',
start: false,
});
await mentionAgent(h, 'alice', issue.id, agentId);
@@ -27,6 +27,8 @@ async function runOn(actions: readonly string[]) {
const agentId = await h.createAgent({ actions: [...actions] });
const issue = await h.projects.issues.create(h.viewer('alice'), {
title: 'Fix login',
executor: null,
statusKey: 'in_progress',
start: false,
});
await h.projects.comments.create(h.viewer('alice'), issue.id, {
@@ -0,0 +1,87 @@
// @vitest-environment node
import fs from 'node:fs';
import { describe, expect, it } from 'vitest';
import en from '../../client/locales/en-US.js';
import zh from '../../client/locales/zh-CN.js';
import { progressSentence } from '../../client/issues/detail/progress-text.js';
import type { IssueProgress } from '../../shared/project-manager.js';
describe('run wording after agent configuration removal', () => {
it('keeps model, effort and historical activity wording in both Studio locales', () => {
for (const locale of [en, zh]) {
expect(locale.runTranscript.usedModel).toContain('{{model}}');
expect(locale.runTranscript.defaultModel).toBeTruthy();
expect(locale.runTranscript.afterSubtasks).toBeTruthy();
for (const effort of [
'off',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
'ultra',
] as const)
expect(locale.runTranscript.efforts[effort]).toBeTruthy();
}
expect(zh.runTranscript.usedModel).toBe('使用模型:{{model}}');
const sources = [
'agents/issue-runs.tsx',
'issues/detail/activity-row.tsx',
'issues/detail/labels.ts',
'projects/detail/labels.ts',
];
const deleted = [
'modelEntries.used',
'agents.defaultModel',
'agentForm.efforts.',
'activity.afterSubtasks',
'projects.workflow',
'start.title',
'start.later',
'start.start',
];
for (const file of sources) {
const source = fs.readFileSync(
new URL(`../../client/${file}`, import.meta.url),
'utf8',
);
for (const key of deleted) expect(source).not.toContain(key);
}
});
it('renders each waiting reason in both languages', () => {
for (const locale of [en, zh]) {
for (const reason of [
'noOwner',
'notConfigured',
'noRunner',
'ownerHasNoRunner',
'notAllowed',
] as const) {
const progress = {
state: 'queued',
agent: { id: 'manager', name: 'Manager' },
waiting: { reason, owner: { id: 'a', name: 'Alice' } },
} as IssueProgress;
const t = (key: string, options?: Record<string, unknown>) => {
const text = key
.split('.')
.reduce<unknown>(
(value, part) => (value as Record<string, unknown>)[part],
locale,
);
expect(typeof text).toBe('string');
return String(text).replace(
/\{\{(\w+)\}\}/gu,
(_match, name: string) => String(options?.[name] ?? ''),
);
};
const sentence = progressSentence(t, progress);
expect(sentence).not.toContain('issueProgress.');
if (reason === 'ownerHasNoRunner' || reason === 'notAllowed')
expect(sentence).toContain('Alice');
}
}
});
});
@@ -24,6 +24,8 @@ async function runOn(actions: readonly string[]) {
const issue = await h.projects.issues.create(h.viewer('alice'), {
title: 'Fix login',
executor: null,
statusKey: 'in_progress',
start: false,
});
await h.projects.comments.post({ type: 'user', id: 'alice' }, issue.id, {
content: `[@Coder](mention://agent/${agentId}) look at it`,
@@ -88,6 +88,12 @@ async function seed() {
title: `Work ${issues.length + 1}`,
projectId,
executor: null,
start: false,
});
await h.projects.issues.update(alice, issue.id, {
revision: issue.revision,
statusKey: 'in_progress',
start: false,
});
await h.projects.comments.post({ type: 'user', id: 'alice' }, issue.id, {
content: `[@Coder](mention://agent/${coder}) do it`,
@@ -71,6 +71,8 @@ async function seed() {
title: `Work ${index + 1}`,
projectId,
executor: null,
statusKey: 'in_progress',
start: false,
});
// Alice hands the first two their step, Bob the last: its run acts for him.
await h.projects.comments.post(
@@ -108,6 +108,7 @@ import {
} from './run.store.js';
import { dropInputs } from './input-gate.js';
import { finishRun, type TransitionDeps } from './transitions.js';
import { refreshQueuedIdentity } from './identity.js';
import { consumeReset, saveBrief } from './workspace.store.js';
/** Queued runs looked at per claim, oldest first by priority. */
@@ -775,6 +776,18 @@ export function createClaimService(deps: ClaimDeps): ClaimService {
candidate: RunRecord,
reported: ToolRoom,
): Promise<Attempt> {
// Repair pre-upgrade queued work before even checking personal runner ownership.
if (deps.subjects.get(candidate.subjectKind)?.actsAs) {
const current = await tx.run(async (unit) => {
await lockAgentForClaim(unit.conn, agent.id, clock.now().toISOString());
const run = await findRunRecord(unit.conn, candidate.id);
return run?.status === 'queued'
? refreshQueuedIdentity(unit, deps, run)
: null;
});
if (!current) return { kind: 'skipped' };
candidate = current;
}
// Avoid assembling or asking authorization when a cheap claim rule already rules this work out.
const conn = tx.read();
if (
@@ -801,6 +814,12 @@ export function createClaimService(deps: ClaimDeps): ClaimService {
const now = clock.now();
const nowText = now.toISOString();
await lockAgentForClaim(conn, agent.id, nowText);
const current = await findRunRecord(conn, candidate.id);
if (!current || current.status !== 'queued') return null;
const identity = await refreshQueuedIdentity(unit, deps, current);
// Providers were prepared for the candidate's identity outside this transaction.
if (!identity || identity.actorUserId !== candidate.actorUserId)
return null;
if (
(await countActive(conn, 'agentId', agent.id)) >=
agent.maxConcurrentRuns
@@ -827,6 +846,7 @@ export function createClaimService(deps: ClaimDeps): ClaimService {
f.and([
f.string('id').eq(candidate.id),
f.string('status').eq('queued'),
f.string('actorUserId').eq(candidate.actorUserId),
]),
values: {
status: 'dispatched',
@@ -1010,7 +1030,12 @@ export function createClaimService(deps: ClaimDeps): ClaimService {
if (!agents.has(candidate.agentId))
agents.set(candidate.agentId, await findAgent(conn, candidate.agentId));
const agent = agents.get(candidate.agentId);
if (!agent || !fits(runner, agent, candidate)) continue;
if (
!agent ||
(!deps.subjects.get(candidate.subjectKind)?.actsAs &&
!fits(runner, agent, candidate))
)
continue;
// None of its tools has room on the runner's side: the runs behind it may still fit.
if (!pickOpenEntry(runner, agent, (tool) => reported.has(tool))) continue;
try {
@@ -0,0 +1,64 @@
/** Subject-selected identities, refreshed before a queued run can borrow a runner or credentials. */
import type { Tx } from '../../kernel/tx.js';
import type { TransitionDeps } from './transitions.js';
import { finishRun } from './transitions.js';
import {
inputsRepo,
runsRepo,
toInput,
toExecutions,
type RunRecord,
} from './run.store.js';
export async function refreshQueuedIdentity(
unit: Tx,
deps: TransitionDeps,
run: RunRecord,
): Promise<RunRecord | null> {
const resolve = deps.subjects.get(run.subjectKind)?.actsAs;
// A run returned to the queue after a lease loss has already fixed its identity.
if (
!resolve ||
run.status !== 'queued' ||
run.dispatchedAt ||
Number(run.attempt) > 1 ||
toExecutions(run).length > 0
)
return run;
const inputs = await inputsRepo(unit.conn).findMany({
filter: { runId: run.id },
});
const identity = await resolve(unit.conn, {
subjectId: run.subjectId,
agentId: run.agentId,
causedByRunId: run.retryOfRunId ?? undefined,
inputs: inputs.map(toInput),
});
if (!identity) return run;
if (!identity.actorUserId) {
await finishRun(unit, deps, run, {
status: 'cancelled',
reason: null,
detail: identity.reason ?? 'noOwner',
});
return null;
}
if (
run.actorUserId === identity.actorUserId &&
run.ownerUserId === identity.actorUserId
)
return run;
const values = {
actorUserId: identity.actorUserId,
ownerUserId: identity.actorUserId,
requestedByUserId: run.requestedByUserId ?? run.actorUserId,
updatedAt: deps.clock.now().toISOString(),
};
const updated = await runsRepo(unit.conn).updateMany({
filter: { id: run.id, status: 'queued', actorUserId: run.actorUserId },
values,
});
if (updated.updatedCount !== 1) return null;
unit.emit({ type: 'run.changed', runId: run.id, status: 'queued' });
return { ...run, ...values };
}
@@ -231,6 +231,21 @@ export interface SubjectReports {
export interface SubjectBinding {
readonly kind: string;
/** Overrides execution identity for selected work. Undefined preserves the caller's identity; a null actorUserId refuses it.
* Read in the enqueue/claim transaction. Claimed runs keep their identity until they finish.
*/
readonly actsAs?: (
conn: DatabaseConnection,
work: {
readonly subjectId: string;
readonly agentId: string;
readonly causedByRunId?: string;
readonly inputs: readonly Pick<RunInput, 'actor' | 'payload'>[];
},
) => Promise<
| { readonly actorUserId: string | null; readonly reason?: string }
| undefined
>;
/**
* Reads the current responsible inside the request's transaction, before confirming or rejecting. Null means
* nobody answers for it (including a deleted subject). Applications with mutable responsibility must bind this;
@@ -198,6 +198,14 @@ export interface RunService {
* events).
*/
enqueue(request: EnqueueRequest, outer?: Tx): Promise<EnqueueResult>;
/** Reads causal work inside the caller's transaction, without opening a second database connection. */
identityInputs(
conn: DatabaseConnection,
runId: string,
): Promise<{
readonly run: Run;
readonly inputs: readonly RunInput[];
} | null>;
/** Work waiting for the subject's responsible to confirm it: confirming, rejecting, withdrawing, handing it on. */
readonly requests: RunRequestService;
/** Adds input to a run that has not ended. */
@@ -666,6 +674,24 @@ export function createRunService(deps: RunServiceDeps): RunService {
return source;
}
async function selectedIdentity(unit: Tx, request: EnqueueRequest) {
const identity = await deps.subjects
.get(request.subject.kind)
?.actsAs?.(unit.conn, {
subjectId: request.subject.id,
agentId: request.agentId,
causedByRunId: request.causedByRunId,
inputs: [request.input],
});
if (identity && !identity.actorUserId)
throw new ProtocolError(
'FORBIDDEN',
'The subject has no valid execution identity.',
{ identityReason: identity.reason ?? 'noOwner' },
);
return identity;
}
/** Refuses work run as `userId` when no runner they may use can run the agent now: it would only wait. */
async function requireRunnerFor(
unit: Tx,
@@ -699,29 +725,40 @@ export function createRunService(deps: RunServiceDeps): RunService {
mayInvoke: (agent, userId) => deps.agents.mayInvoke(agent, userId),
requireInvocable,
requireRunnerFor,
direct: (unit, run) =>
queue(unit, run.agent, {
direct: async (unit, run) => {
const identity = await selectedIdentity(unit, {
agentId: run.agent.id,
subject: run.subject,
input: run.input,
});
const actorUserId = identity?.actorUserId ?? run.actorUserId;
const agent = await requireInvocable(unit, run.agent.id, actorUserId);
return queue(unit, agent, {
freshSession: run.freshSession,
subject: run.subject,
threadScope: run.threadScope,
actorUserId: run.actorUserId,
actorUserId,
requestedByUserId: run.requestedByUserId,
confirmedByUserId: run.confirmedByUserId,
ownerUserId: run.ownerUserId,
ownerUserId: identity ? actorUserId : run.ownerUserId,
priority: run.priority,
// As it was asked: a moment already past when it is confirmed means now.
fireAt: fireAtOf(run.fireAt ?? undefined, clock.now()),
requires: run.requires,
...(run.maxAttempts === null ? {} : { maxAttempts: run.maxAttempts }),
input: run.input,
}),
});
},
});
return {
enqueue: (request, outer) =>
tx.run(async (unit): Promise<EnqueueResult> => {
const source = await sourceOf(unit, request);
const responsible = request.responsibleUserId ?? null;
const identity = await selectedIdentity(unit, request);
const responsible = identity
? null
: (request.responsibleUserId ?? null);
const threadScope = request.threadScope ?? DEFAULT_THREAD;
const foreign = responsible !== null && source !== responsible;
if (foreign && (request.execution ?? 'auto') === 'auto') {
@@ -764,9 +801,9 @@ export function createRunService(deps: RunServiceDeps): RunService {
}
// As the responsible when they caused it; as the source when they run it as themselves (`mine`); as whoever
// woke the agent when nobody answers for the subject.
const actorUserId = foreign
? source
: (responsible ?? request.actorUserId ?? source);
const actorUserId =
identity?.actorUserId ??
(foreign ? source : (responsible ?? request.actorUserId ?? source));
const agent = await requireInvocable(
unit,
request.agentId,
@@ -786,7 +823,7 @@ export function createRunService(deps: RunServiceDeps): RunService {
actorUserId,
requestedByUserId: source,
confirmedByUserId: null,
ownerUserId: request.ownerUserId ?? null,
ownerUserId: identity ? actorUserId : (request.ownerUserId ?? null),
priority: request.priority ?? 0,
fireAt: fireAtOf(request.fireAt, clock.now()),
requires: request.requires ?? [],
@@ -798,6 +835,13 @@ export function createRunService(deps: RunServiceDeps): RunService {
requests,
identityInputs: async (conn, runId) => {
const run = await findRunRecord(conn, runId);
if (!run) return null;
const inputs = await inputsRepo(conn).findMany({ filter: { runId } });
return { run: toRun(run), inputs: inputs.map(toInput) };
},
addInput: (runId, input, outer) =>
tx.run(async (unit) => {
const run = await require(unit.conn, runId);
@@ -917,12 +961,26 @@ export function createRunService(deps: RunServiceDeps): RunService {
runId: retried.id,
},
);
const agent = await requireInvocable(unit, run.agentId, byUserId);
const previousInputs = await inputsRepo(unit.conn).findMany({
filter: { runId: run.id },
});
const identity = await deps.subjects
.get(run.subjectKind)
?.actsAs?.(unit.conn, {
subjectId: run.subjectId,
agentId: run.agentId,
inputs: previousInputs.map(toInput),
causedByRunId: run.retryOfRunId ?? undefined,
});
if (identity && !identity.actorUserId)
throw forbidden(identity.reason ?? 'noOwner');
const actorUserId = identity?.actorUserId ?? byUserId;
const agent = await requireInvocable(unit, run.agentId, actorUserId);
const open = await runsOfKey(
unit.conn,
run,
['queued', ...ACTIVE],
byUserId,
actorUserId,
);
if (open.length > 0)
throw precondition(
@@ -935,10 +993,10 @@ export function createRunService(deps: RunServiceDeps): RunService {
const newId = await createRun(unit, agent, {
subject: { kind: run.subjectKind, id: run.subjectId },
threadScope: run.threadScope,
actorUserId: byUserId,
actorUserId,
requestedByUserId: byUserId,
confirmedByUserId: null,
ownerUserId: run.ownerUserId,
ownerUserId: identity ? actorUserId : run.ownerUserId,
priority: Number(run.priority),
requires: toRun(run).requires,
retryOfRunId: run.id,
@@ -1028,6 +1086,18 @@ export function createRunService(deps: RunServiceDeps): RunService {
);
return {
...(await readRuns([run]))[0],
actorName:
(await deps.people.names(conn, [run.actorUserId])).get(
run.actorUserId,
) ?? run.actorUserId,
requestedByName:
(
await deps.people.names(conn, [
run.requestedByUserId ?? run.actorUserId,
])
).get(run.requestedByUserId ?? run.actorUserId) ??
run.requestedByUserId ??
run.actorUserId,
retriedRunId: retried?.id ?? null,
inputs: inputs.map(toInputView),
repos: repos.map(toRepo),
@@ -926,6 +926,14 @@ const runObject = z.object({
export const RunSchema: z.ZodType<Run> = runObject.meta({ ref: 'AgentsRun' });
export const RunDetailSchema: z.ZodType<RunDetail> = runObject.extend({
actorName: z
.string()
.optional()
.meta({ description: 'The name of the execution identity.' }),
requestedByName: z.string().optional().meta({
description:
'The name of the user associated with the chain origin; input actors identify individual triggers.',
}),
retriedRunId: z.string().nullable().optional(),
inputs: z.array(
z.object({
@@ -184,6 +184,8 @@ export interface RunChild {
}
export interface RunDetail extends Run {
readonly actorName?: string;
readonly requestedByName?: string;
/** The first run that retried this one; absent on older servers. */
readonly retriedRunId?: string | null;
readonly inputs: readonly RunInputRecord[];
@@ -0,0 +1,182 @@
import { afterEach, describe, expect, it } from 'vitest';
import { claim, createHarness, type Harness } from './harness.js';
let h: Harness;
afterEach(async () => h?.close());
describe('subject-selected execution identity', () => {
async function setup() {
h = await createHarness();
const binding = h.services.subjects.get('sample')!;
let owner: string | null = 'owner';
h.services.subjects.register({
...binding,
kind: 'owned',
actsAs: async () => ({ actorUserId: owner }),
});
const agentId = await h.createAgent();
const enqueue = (text = 'Work') =>
h.services.runs.enqueue({
agentId,
subject: { kind: 'owned', id: '1' },
actorUserId: 'bob',
input: {
type: 'comment',
actor: { kind: 'user', id: 'bob', name: 'Bob' },
text,
},
});
return {
agentId,
enqueue,
setOwner: (next: string | null) => {
owner = next;
},
};
}
it('uses the owner runner and credentials while preserving the trigger and inputs', async () => {
const { enqueue, setOwner } = await setup();
const result = await enqueue();
await enqueue('More');
expect(await h.services.runs.get(result.runId!)).toMatchObject({
actorUserId: 'owner',
requestedByUserId: 'bob',
ownerUserId: 'owner',
});
const bob = await h.registerRunner({
trust: 'ownerOnly',
ownerUserId: 'bob',
});
const owner = await h.registerRunner({
trust: 'ownerOnly',
ownerUserId: 'owner',
});
expect(await claim(h, bob)).toEqual([]);
setOwner('bob');
expect(await claim(h, owner)).toEqual([]);
const [payload] = await claim(h, bob);
expect(payload.inputs.map((input: { text: string }) => input.text)).toEqual(
['Work', 'More'],
);
expect(await h.services.runs.get(result.runId!)).toMatchObject({
actorUserId: 'bob',
requestedByUserId: 'bob',
});
const credential = JSON.parse(
payload.cli.credential.content
? JSON.stringify(payload.cli.credential.content)
: '{}',
);
expect(
(await h.services.runs.authenticateToken(credential.token)).run
.actorUserId,
).toBe('bob');
});
it('repairs legacy queued identity before runner ownership checks and leaves claimed work fixed', async () => {
const { enqueue, setOwner } = await setup();
const result = await enqueue();
await h.database
.connection()
.repository('agRuns')
.updateMany({
filter: { id: result.runId },
values: {
actorUserId: 'bob',
ownerUserId: 'bob',
requestedByUserId: null,
},
});
const bob = await h.registerRunner({
trust: 'ownerOnly',
ownerUserId: 'bob',
});
const owner = await h.registerRunner({
trust: 'ownerOnly',
ownerUserId: 'owner',
});
expect((await claim(h, owner))[0].run.id).toBe(result.runId);
expect((await h.services.runs.get(result.runId!)).requestedByUserId).toBe(
'bob',
);
expect(await claim(h, bob)).toEqual([]);
setOwner('bob');
expect(await h.services.runs.get(result.runId!)).toMatchObject({
actorUserId: 'owner',
status: 'dispatched',
});
expect(
(await h.services.runs.detail(result.runId!)).inputs.at(-1)?.text,
).toBe('Work');
expect(await enqueue('Next')).toMatchObject({ outcome: 'created' });
const failed = await h.request(
'POST',
`/agents/runners/runs/${result.runId}/fail`,
{
runnerKey: owner.key,
body: { reason: 'workspaceBusy' },
},
);
expect(failed.status).toBe(200);
h.clock.advance(30_000);
expect((await claim(h, owner))[0].run.id).toBe(result.runId);
expect((await h.services.runs.get(result.runId!)).actorUserId).toBe(
'owner',
);
});
it('abandons a stale prepared candidate when identity changes at the claim boundary', async () => {
const { enqueue, setOwner } = await setup();
const result = await enqueue();
let reached!: () => void;
let resume!: () => void;
const prepared = new Promise<void>((resolve) => {
reached = resolve;
});
const released = new Promise<void>((resolve) => {
resume = resolve;
});
h.services.briefs.sections.register({
key: 'pause-before-claim',
prepare: async () => {
reached();
await released;
},
section: async () => null,
});
const owner = await h.registerRunner({
trust: 'ownerOnly',
ownerUserId: 'owner',
});
const claiming = claim(h, owner);
await prepared;
try {
setOwner('bob');
} finally {
resume();
}
expect(await claiming).toEqual([]);
expect(await h.services.runs.get(result.runId!)).toMatchObject({
actorUserId: 'bob',
status: 'queued',
});
const bob = await h.registerRunner({
trust: 'ownerOnly',
ownerUserId: 'bob',
});
expect((await claim(h, bob))[0].run.id).toBe(result.runId);
});
it('refuses an absent owner and cancels queued work rather than falling back', async () => {
const { enqueue, setOwner } = await setup();
const result = await enqueue();
setOwner(null);
await expect(enqueue()).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect(await claim(h, await h.registerRunner())).toEqual([]);
expect(await h.services.runs.get(result.runId!)).toMatchObject({
status: 'cancelled',
failureDetail: 'noOwner',
});
});
});
@@ -10,7 +10,10 @@ import type { DatabaseManager } from '@nocobase/db';
import type { ProjectsAccess } from './tokens.js';
import { createActivityRecorder } from './kernel/activity.js';
import {
createActivityRecorder,
type ActivityRecorder,
} from './kernel/activity.js';
import { createKindRegistry, type KindRegistry } from './kernel/kinds.js';
import { createDomainEventBus, type DomainEventBus } from './kernel/events.js';
import { createIdSource } from './kernel/ids.js';
@@ -132,6 +135,8 @@ export interface ProjectsDeps {
}
export interface Projects {
/** Application-owned issue events, recorded in the transaction that caused them. */
readonly activity: ActivityRecorder;
readonly events: DomainEventBus;
readonly tx: TxRunner;
readonly members: MemberService;
@@ -340,6 +345,7 @@ export function createProjects(deps: ProjectsDeps): Projects {
kinds,
});
return {
activity,
events,
tx,
settings,
@@ -107,7 +107,7 @@ export interface IssueWorkHandler {
tx: Tx,
change: CommentChange,
): Promise<readonly RunAttempt[]>;
/** The issue's owner changed while the kind executes it: queued work woken by the previous owner is withdrawn. */
/** The issue's owner changed. Every work kind hears this before ordinary triggers, including unassigned issues. */
onOwnerChanged?(
tx: Tx,
input: {
@@ -245,24 +245,27 @@ export function kindTriggers(
return {
async onIssueChanged(tx, change) {
const { before, after } = change;
// Transfers reach every work kind before ordinary triggers, even when the executor is cleared.
if (before && before.ownerUserId !== after.ownerUserId)
for (const [, work] of handlersOf(
kinds,
kinds.list().map((kind) => kind.key),
))
await report(
tx,
after,
work.onOwnerChanged?.(tx, {
issue: after,
from: before.ownerUserId,
to: after.ownerUserId,
actor: change.actor,
}),
);
for (const [, work] of handlersOf(kinds, [
before?.executor?.type,
after.executor?.type,
]))
await report(tx, after, work.onIssueChanged(tx, change));
if (before && before.ownerUserId !== after.ownerUserId) {
const work = handlerOf(kinds, after.executor?.type);
await report(
tx,
after,
work?.onOwnerChanged?.(tx, {
issue: after,
from: before.ownerUserId,
to: after.ownerUserId,
actor: change.actor,
}),
);
}
},
async onCommentCreated(tx, change) {
@@ -133,13 +133,30 @@ describe('work handlers of registered kinds', () => {
revision: issue.revision,
ownerUserId: 'bob',
});
expect(seen.at(-1)).toEqual({
expect(seen.slice(-2).map((event) => event.hook)).toEqual([
'owner',
'changed',
]);
expect(seen.at(-2)).toEqual({
hook: 'owner',
issueId: issue.id,
detail: ['alice', 'bob'],
});
});
it('notifies all work kinds on transfer even when there is no executor', async () => {
const seen = addBots();
const issue = await h.services.issues.create(alice(), { title: 'A' });
await h.services.issues.update(alice(), issue.id, {
revision: issue.revision,
ownerUserId: 'bob',
executor: null,
});
expect(seen).toEqual([
{ hook: 'owner', issueId: issue.id, detail: ['alice', 'bob'] },
]);
});
it('starts work for a mentioned principal and returns it as triggered', async () => {
const seen = addBots();
const issue = await h.services.issues.create(alice(), { title: 'A' });