fix: improve team runtime recovery and launch settings
This commit is contained in:
parent
e48ecf664a
commit
82f73e58c2
24 changed files with 1559 additions and 51 deletions
|
|
@ -16,6 +16,7 @@ function createControllerContext(options = {}) {
|
|||
teamName,
|
||||
claudeDir: paths.claudeDir,
|
||||
paths,
|
||||
allowUserMessageSender: options.allowUserMessageSender !== false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,15 +158,22 @@ function normalizeMessageSendFlags(context, flags) {
|
|||
next.member = resolvedTo;
|
||||
}
|
||||
|
||||
const fromRequiredForAgentTool = context.allowUserMessageSender === false;
|
||||
if (typeof next.from === 'string' && next.from.trim()) {
|
||||
const rawFrom = next.from.trim();
|
||||
if (rawFrom.toLowerCase() !== 'user') {
|
||||
next.from = runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFrom, 'from', {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
} else if (fromRequiredForAgentTool) {
|
||||
throw new Error(
|
||||
'message_send from user is reserved for the human user. Set from to your configured teammate name.'
|
||||
);
|
||||
} else {
|
||||
next.from = 'user';
|
||||
}
|
||||
} else if (fromRequiredForAgentTool) {
|
||||
throw new Error('message_send requires from to be your configured teammate name.');
|
||||
}
|
||||
|
||||
return next;
|
||||
|
|
|
|||
|
|
@ -225,6 +225,85 @@ function resolveExplicitTeamMemberName(paths, candidate, options = {}) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function getLeadProviderKeys(paths, explicitMembers) {
|
||||
const leadName = inferLeadName(paths);
|
||||
const leadKey = normalizeMemberKey(leadName);
|
||||
const leadMember =
|
||||
(leadKey ? explicitMembers.membersByKey.get(leadKey) : null) ||
|
||||
Array.from(explicitMembers.membersByKey.values()).find((member) => isCanonicalLeadMember(member));
|
||||
if (!leadMember) return { leadName: '', keys: new Set() };
|
||||
|
||||
const keys = new Set();
|
||||
for (const field of ['providerId', 'provider', 'providerBackendId']) {
|
||||
const key = normalizeMemberKey(leadMember[field]);
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
return { leadName: leadMember.name, keys };
|
||||
}
|
||||
|
||||
function formatAllowedTaskCommentAuthors(paths, explicitMembers, options = {}) {
|
||||
const allowed = new Set();
|
||||
if (options.allowReservedAuthors !== false) {
|
||||
allowed.add('user');
|
||||
allowed.add('system');
|
||||
}
|
||||
for (const member of explicitMembers.membersByKey.values()) {
|
||||
if (member && typeof member.name === 'string' && member.name.trim()) {
|
||||
allowed.add(member.name.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const leadName = inferLeadName(paths);
|
||||
const leadKey = normalizeMemberKey(leadName);
|
||||
if (leadKey && explicitMembers.membersByKey.has(leadKey)) {
|
||||
allowed.add('lead');
|
||||
allowed.add('team-lead');
|
||||
}
|
||||
|
||||
return Array.from(allowed).sort((a, b) => a.localeCompare(b)).join(', ');
|
||||
}
|
||||
|
||||
function resolveTaskCommentAuthorName(paths, candidate, label = 'task comment author', options = {}) {
|
||||
const normalized = typeof candidate === 'string' && candidate.trim() ? candidate.trim() : '';
|
||||
if (!normalized) {
|
||||
return inferLeadName(paths);
|
||||
}
|
||||
|
||||
const key = normalizeMemberKey(normalized);
|
||||
if (key === 'user' || key === 'system') {
|
||||
if (options.allowReservedAuthors === false) {
|
||||
throw new Error(
|
||||
`${label} "${key}" is reserved for app-owned writes. Set from to your configured teammate name.`
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
const explicit = collectExplicitTeamMembers(paths);
|
||||
const directMember = explicit.membersByKey.get(key);
|
||||
if (directMember && !explicit.removedNames.has(key)) {
|
||||
return directMember.name;
|
||||
}
|
||||
|
||||
const leadAlias = resolveExplicitTeamMemberName(paths, normalized, { allowLeadAliases: true });
|
||||
if (leadAlias) {
|
||||
return leadAlias;
|
||||
}
|
||||
|
||||
const { leadName, keys } = getLeadProviderKeys(paths, explicit);
|
||||
if (leadName && keys.has(key)) {
|
||||
return leadName;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown ${label}: ${normalized}. Use one of: ${formatAllowedTaskCommentAuthors(
|
||||
paths,
|
||||
explicit,
|
||||
options
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
function assertExplicitTeamMemberName(paths, candidate, label = 'member', options = {}) {
|
||||
const resolved = resolveExplicitTeamMemberName(paths, candidate, options);
|
||||
if (!resolved) {
|
||||
|
|
@ -626,6 +705,7 @@ module.exports = {
|
|||
readMembersMeta,
|
||||
readTeamConfig,
|
||||
resolveExplicitTeamMemberName,
|
||||
resolveTaskCommentAuthorName,
|
||||
resolveTeamMembers,
|
||||
getCurrentRuntimeMemberIdentity,
|
||||
resolveCanonicalLeadSessionId,
|
||||
|
|
|
|||
|
|
@ -229,14 +229,16 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
|||
}
|
||||
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
if (isSameTaskMember(owner, comment.author, leadName)) {
|
||||
const rawAuthor = normalizeActorName(comment.author);
|
||||
const sender = rawAuthor.toLowerCase() === 'system' ? leadName : rawAuthor || leadName;
|
||||
if (isSameTaskMember(owner, sender, leadName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: normalizeActorName(comment.author) || leadName,
|
||||
from: sender,
|
||||
text: buildCommentNotificationMessage(context, task, comment),
|
||||
taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined,
|
||||
summary: `Comment on #${task.displayId || task.id}`,
|
||||
|
|
@ -410,11 +412,16 @@ function notifyUnblockedOwners(context, completedTask) {
|
|||
// Stable comment ID prevents duplicates when completeTask is called
|
||||
// multiple times for the same task (e.g. agent retry). addTaskComment
|
||||
// in taskStore.js deduplicates by id (line 485).
|
||||
addTaskComment(context, blockedTask.id, {
|
||||
id: `dep-resolved-${completedTask.id}-${blockedTask.id}`,
|
||||
text: lines.join('\n'),
|
||||
from: 'system',
|
||||
});
|
||||
addTaskCommentWithOptions(
|
||||
context,
|
||||
blockedTask.id,
|
||||
{
|
||||
id: `dep-resolved-${completedTask.id}-${blockedTask.id}`,
|
||||
text: lines.join('\n'),
|
||||
from: 'system',
|
||||
},
|
||||
{ trustedInternalWrite: true }
|
||||
);
|
||||
} catch {
|
||||
// Best-effort per blocked task: skip on failure
|
||||
}
|
||||
|
|
@ -497,23 +504,37 @@ function updateTaskFields(context, taskId, fields) {
|
|||
);
|
||||
}
|
||||
|
||||
function addTaskComment(context, taskId, flags) {
|
||||
function addTaskCommentWithOptions(context, taskId, flags, options = {}) {
|
||||
const commentFlags = flags || {};
|
||||
const fromRequiredForAgentTool =
|
||||
context.allowUserMessageSender === false && options.trustedInternalWrite !== true;
|
||||
if (
|
||||
fromRequiredForAgentTool &&
|
||||
!(typeof commentFlags.from === 'string' && commentFlags.from.trim())
|
||||
) {
|
||||
throw new Error('task_add_comment requires from to be your configured teammate name.');
|
||||
}
|
||||
const author = runtimeHelpers.resolveTaskCommentAuthorName(
|
||||
context.paths,
|
||||
commentFlags.from,
|
||||
'task comment author',
|
||||
{ allowReservedAuthors: !fromRequiredForAgentTool }
|
||||
);
|
||||
const result = withTeamBoardLock(context.paths, () =>
|
||||
taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
||||
author: typeof flags.from === 'string' && flags.from.trim() ?
|
||||
flags.from.trim() : runtimeHelpers.inferLeadName(context.paths),
|
||||
...(flags.id ? { id: flags.id } : {}),
|
||||
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
||||
...(flags.type ? { type: flags.type } : {}),
|
||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||
taskStore.addTaskComment(context.paths, taskId, commentFlags.text, {
|
||||
author,
|
||||
...(commentFlags.id ? { id: commentFlags.id } : {}),
|
||||
...(commentFlags.createdAt ? { createdAt: commentFlags.createdAt } : {}),
|
||||
...(commentFlags.type ? { type: commentFlags.type } : {}),
|
||||
...(Array.isArray(commentFlags.taskRefs) ? { taskRefs: commentFlags.taskRefs } : {}),
|
||||
...(Array.isArray(commentFlags.attachments) ? { attachments: commentFlags.attachments } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
||||
inserted: result.inserted,
|
||||
notifyOwner: flags.notifyOwner,
|
||||
notifyOwner: commentFlags.notifyOwner,
|
||||
});
|
||||
} catch (notifyError) {
|
||||
warnNonCritical(`[tasks] owner notification failed for task ${taskId}`, notifyError);
|
||||
|
|
@ -529,6 +550,10 @@ function addTaskComment(context, taskId, flags) {
|
|||
};
|
||||
}
|
||||
|
||||
function addTaskComment(context, taskId, flags) {
|
||||
return addTaskCommentWithOptions(context, taskId, flags);
|
||||
}
|
||||
|
||||
function attachTaskFile(context, taskId, flags) {
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
|
|
|
|||
|
|
@ -1080,6 +1080,52 @@ describe('agent-teams-controller API', () => {
|
|||
).toThrow('message_send cannot target cross_team_send. Use cross_team_send with toTeam.');
|
||||
});
|
||||
|
||||
it('prevents agent-facing message_send from impersonating the human user', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const appController = createController({ teamName: 'my-team', claudeDir });
|
||||
const agentController = createController({
|
||||
teamName: 'my-team',
|
||||
claudeDir,
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
|
||||
const appMessage = appController.messages.sendMessage({
|
||||
to: 'team-lead',
|
||||
from: 'user',
|
||||
text: 'Real user question.',
|
||||
summary: 'User question',
|
||||
});
|
||||
expect(appMessage.deliveredToInbox).toBe(true);
|
||||
|
||||
expect(() =>
|
||||
agentController.messages.sendMessage({
|
||||
to: 'team-lead',
|
||||
from: 'user',
|
||||
text: 'Forged user message.',
|
||||
summary: 'Forged',
|
||||
})
|
||||
).toThrow('message_send from user is reserved for the human user');
|
||||
|
||||
expect(() =>
|
||||
agentController.messages.sendMessage({
|
||||
to: 'team-lead',
|
||||
text: 'Missing sender should not default to user.',
|
||||
})
|
||||
).toThrow('message_send requires from to be your configured teammate name');
|
||||
|
||||
const agentMessage = agentController.messages.sendMessage({
|
||||
to: 'team-lead',
|
||||
from: 'bob',
|
||||
text: 'Legitimate teammate message.',
|
||||
summary: 'Teammate update',
|
||||
});
|
||||
expect(agentMessage.deliveredToInbox).toBe(true);
|
||||
|
||||
const leadInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
|
||||
const leadRows = JSON.parse(fs.readFileSync(leadInboxPath, 'utf8'));
|
||||
expect(leadRows.map((row) => row.from)).toEqual(['user', 'bob']);
|
||||
});
|
||||
|
||||
it('wakes task owner on regular comment from another member', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -1099,6 +1145,181 @@ describe('agent-teams-controller API', () => {
|
|||
expect(rows[0].leadSessionId).toBe('lead-session-1');
|
||||
});
|
||||
|
||||
it('normalizes task comment authors at the write boundary', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', 'my-team', 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'team-lead', role: 'team-lead', providerId: 'codex', provider: 'codex' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Review result', notifyOwner: false });
|
||||
|
||||
const fromProvider = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'codex',
|
||||
text: 'Lead runtime finished review.',
|
||||
});
|
||||
const fromAlias = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'lead',
|
||||
text: 'Lead alias finished review.',
|
||||
});
|
||||
const fromUser = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'User',
|
||||
text: 'User follow-up.',
|
||||
});
|
||||
const fromSystem = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'System',
|
||||
text: 'System note.',
|
||||
});
|
||||
|
||||
expect(fromProvider.comment.author).toBe('team-lead');
|
||||
expect(fromAlias.comment.author).toBe('team-lead');
|
||||
expect(fromUser.comment.author).toBe('user');
|
||||
expect(fromSystem.comment.author).toBe('system');
|
||||
});
|
||||
|
||||
it('does not map a real teammate named like the lead provider id to the lead', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', 'my-team', 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'team-lead', role: 'team-lead', providerId: 'codex', provider: 'codex' },
|
||||
{ name: 'codex', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Member named codex', notifyOwner: false });
|
||||
|
||||
const commented = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'codex',
|
||||
text: 'Real teammate comment.',
|
||||
});
|
||||
|
||||
expect(commented.comment.author).toBe('codex');
|
||||
});
|
||||
|
||||
it('rejects task comments from unknown authors', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Reject unknown author', notifyOwner: false });
|
||||
|
||||
expect(() =>
|
||||
controller.tasks.addTaskComment(task.id, {
|
||||
from: 'ghost',
|
||||
text: 'This should not be persisted.',
|
||||
})
|
||||
).toThrow('Unknown task comment author: ghost');
|
||||
|
||||
expect(controller.tasks.getTask(task.id).comments || []).toEqual([]);
|
||||
});
|
||||
|
||||
it('prevents agent-facing task_add_comment from impersonating app-owned authors', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const appController = createController({ teamName: 'my-team', claudeDir });
|
||||
const agentController = createController({
|
||||
teamName: 'my-team',
|
||||
claudeDir,
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
const task = appController.tasks.createTask({ subject: 'Reserved comment authors', notifyOwner: false });
|
||||
|
||||
const appComment = appController.tasks.addTaskComment(task.id, {
|
||||
from: 'user',
|
||||
text: 'Real user comment.',
|
||||
});
|
||||
expect(appComment.comment.author).toBe('user');
|
||||
|
||||
expect(() =>
|
||||
agentController.tasks.addTaskComment(task.id, {
|
||||
from: 'user',
|
||||
text: 'Forged user comment.',
|
||||
})
|
||||
).toThrow('task comment author "user" is reserved for app-owned writes');
|
||||
|
||||
expect(() =>
|
||||
agentController.tasks.addTaskComment(task.id, {
|
||||
text: 'Missing sender should not default to lead.',
|
||||
})
|
||||
).toThrow('task_add_comment requires from to be your configured teammate name');
|
||||
|
||||
let unknownAuthorError;
|
||||
try {
|
||||
agentController.tasks.addTaskComment(task.id, {
|
||||
from: 'ghost',
|
||||
text: 'Unknown teammate should get a useful recovery error.',
|
||||
});
|
||||
} catch (error) {
|
||||
unknownAuthorError = error;
|
||||
}
|
||||
expect(unknownAuthorError.message).toContain('Unknown task comment author: ghost');
|
||||
expect(unknownAuthorError.message).not.toContain('user');
|
||||
expect(unknownAuthorError.message).not.toContain('system');
|
||||
|
||||
const agentComment = agentController.tasks.addTaskComment(task.id, {
|
||||
from: 'bob',
|
||||
text: 'Legitimate teammate comment.',
|
||||
});
|
||||
expect(agentComment.comment.author).toBe('bob');
|
||||
|
||||
const comments = agentController.tasks.getTask(task.id).comments || [];
|
||||
expect(comments.map((comment) => comment.author)).toEqual(['user', 'bob']);
|
||||
});
|
||||
|
||||
it('keeps internal dependency comments when agent-facing task_complete unblocks a task', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const appController = createController({ teamName: 'my-team', claudeDir });
|
||||
const agentController = createController({
|
||||
teamName: 'my-team',
|
||||
claudeDir,
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
const dependency = appController.tasks.createTask({
|
||||
subject: 'Prepare calculator API',
|
||||
owner: 'bob',
|
||||
notifyOwner: false,
|
||||
});
|
||||
const blocked = appController.tasks.createTask({
|
||||
subject: 'Build calculator UI',
|
||||
owner: 'bob',
|
||||
'blocked-by': dependency.displayId,
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
expect(() => agentController.tasks.completeTask(dependency.id, 'bob')).not.toThrow();
|
||||
|
||||
const comments = appController.tasks.getTask(blocked.id).comments || [];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].author).toBe('system');
|
||||
expect(comments[0].id).toBe(`dep-resolved-${dependency.id}-${blocked.id}`);
|
||||
expect(comments[0].text).toContain('Dependency resolved');
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].from).toBe('alice');
|
||||
expect(rows[0].text).toContain('Dependency resolved');
|
||||
});
|
||||
|
||||
it('includes the assigned task ref in owner assignment notifications', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
1
mcp-server/src/agent-teams-controller.d.ts
vendored
1
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -2,6 +2,7 @@ declare module 'agent-teams-controller' {
|
|||
export interface ControllerContextOptions {
|
||||
teamName: string;
|
||||
claudeDir?: string;
|
||||
allowUserMessageSender?: boolean;
|
||||
}
|
||||
|
||||
export interface ControllerTaskApi {
|
||||
|
|
|
|||
|
|
@ -23,5 +23,6 @@ export function getController(teamName: string, claudeDir?: string) {
|
|||
return createController({
|
||||
teamName,
|
||||
...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}),
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
server.addTool({
|
||||
name: 'message_send',
|
||||
description:
|
||||
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. When to is "user", from is required and must be your configured teammate name. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
|
||||
'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. from is required and must be your configured teammate name; user is reserved for app-owned writes. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
to: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
from: z.string().min(1),
|
||||
summary: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
relayOfMessageId: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -436,12 +436,13 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
name: 'task_add_comment',
|
||||
description: 'Add task comment',
|
||||
description:
|
||||
'Add task comment. from is required and must be your configured teammate name; user/system are reserved for app-owned writes.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
from: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
|
|
|
|||
83
src/main/services/runtime/cliSettingsArgs.ts
Normal file
83
src/main/services/runtime/cliSettingsArgs.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonSettingsObject(raw: string): JsonObject | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isJsonObject(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject {
|
||||
const merged: JsonObject = { ...target };
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const current = merged[key];
|
||||
if (isJsonObject(current) && isJsonObject(value)) {
|
||||
merged[key] = deepMergeJsonObjects(current, value);
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native multimodel launches may receive app settings and provider settings as
|
||||
* separate --settings JSON values. Some runtimes read only the first one, so
|
||||
* collapse parseable JSON settings into one object before spawn.
|
||||
*/
|
||||
export function mergeJsonSettingsArgs(args: string[]): string[] {
|
||||
let mergedSettings: JsonObject | null = null;
|
||||
let firstSettingsIndex: number | null = null;
|
||||
const output: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--settings') {
|
||||
const value = args[i + 1];
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseJsonSettingsObject(value);
|
||||
if (parsed) {
|
||||
if (firstSettingsIndex === null) {
|
||||
firstSettingsIndex = output.length;
|
||||
}
|
||||
mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output.push(arg);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const settingsPrefix = '--settings=';
|
||||
if (arg.startsWith(settingsPrefix)) {
|
||||
const parsed = parseJsonSettingsObject(arg.slice(settingsPrefix.length));
|
||||
if (parsed) {
|
||||
if (firstSettingsIndex === null) {
|
||||
firstSettingsIndex = output.length;
|
||||
}
|
||||
mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
output.push(arg);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (firstSettingsIndex !== null && mergedSettings) {
|
||||
output.splice(firstSettingsIndex, 0, '--settings', JSON.stringify(mergedSettings));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
|
||||
import { mergeJsonSettingsArgs } from '../runtime/cliSettingsArgs';
|
||||
import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv';
|
||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
|
||||
|
|
@ -149,8 +150,6 @@ export class ScheduledTaskExecutor {
|
|||
|
||||
const args = this.buildArgs(request);
|
||||
|
||||
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
||||
|
||||
const providerId =
|
||||
request.config.providerId === 'codex' || request.config.providerId === 'gemini'
|
||||
? request.config.providerId
|
||||
|
|
@ -171,8 +170,11 @@ export class ScheduledTaskExecutor {
|
|||
}
|
||||
|
||||
args.push(...providerArgs);
|
||||
const launchArgs = mergeJsonSettingsArgs(args);
|
||||
|
||||
const child = spawnCli(binaryPath, args, {
|
||||
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${launchArgs.join(' ')}`);
|
||||
|
||||
const child = spawnCli(binaryPath, launchArgs, {
|
||||
cwd: request.config.cwd,
|
||||
// shellEnv spread after buildEnrichedEnv ensures freshly-resolved values
|
||||
// take precedence over the cached snapshot inside buildEnrichedEnv.
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ import * as path from 'path';
|
|||
import pidusage from 'pidusage';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import { mergeJsonSettingsArgs } from '../runtime/cliSettingsArgs';
|
||||
import {
|
||||
type GeminiRuntimeAuthState,
|
||||
resolveGeminiRuntimeAuth,
|
||||
|
|
@ -121,7 +122,6 @@ import {
|
|||
} from '../runtime/providerModelProbe';
|
||||
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||
|
||||
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
|
||||
import {
|
||||
createOpenCodePromptDeliveryLedgerStore,
|
||||
hashOpenCodePromptDeliveryPayload,
|
||||
|
|
@ -133,16 +133,17 @@ import {
|
|||
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||
import {
|
||||
isOpenCodePromptDeliveryObserveLaterResponseState,
|
||||
isOpenCodePromptDeliveryRetryAttemptDue,
|
||||
isOpenCodePromptDeliveryRetryableResponseState,
|
||||
isOpenCodeVisibleReplySemanticallySufficient,
|
||||
isOpenCodePromptDeliveryRetryAttemptDue,
|
||||
isOpenCodeVisibleReplyReadCommitAllowed,
|
||||
isOpenCodeVisibleReplySemanticallySufficient,
|
||||
OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS,
|
||||
OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS,
|
||||
OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY,
|
||||
OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY,
|
||||
type OpenCodeVisibleReplyProof,
|
||||
} from './opencode/delivery/OpenCodePromptDeliveryWatchdog';
|
||||
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
|
||||
import {
|
||||
type RuntimeDeliveryDestinationPort,
|
||||
RuntimeDeliveryDestinationRegistry,
|
||||
|
|
@ -206,8 +207,8 @@ import {
|
|||
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import {
|
||||
commandArgEquals,
|
||||
|
|
@ -703,7 +704,7 @@ function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number {
|
|||
}
|
||||
|
||||
function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): string[] {
|
||||
return [...providerArgs, ...args];
|
||||
return mergeJsonSettingsArgs([...providerArgs, ...args]);
|
||||
}
|
||||
|
||||
interface ProviderModelListCommandResponse {
|
||||
|
|
@ -11827,7 +11828,7 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const resolvedProviderId = resolveTeamProviderId(request.providerId);
|
||||
const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity);
|
||||
const spawnArgs = [
|
||||
const spawnArgs = mergeJsonSettingsArgs([
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
|
|
@ -11855,7 +11856,7 @@ export class TeamProvisioningService {
|
|||
...(request.worktree ? ['--worktree', request.worktree] : []),
|
||||
...parseCliArgs(request.extraCliArgs),
|
||||
...providerArgs,
|
||||
];
|
||||
]);
|
||||
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
||||
geminiRuntimeAuth,
|
||||
promptSize,
|
||||
|
|
@ -12946,12 +12947,13 @@ export class TeamProvisioningService {
|
|||
}
|
||||
launchArgs.push(...parseCliArgs(request.extraCliArgs));
|
||||
launchArgs.push(...providerArgs);
|
||||
const finalLaunchArgs = mergeJsonSettingsArgs(launchArgs);
|
||||
const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, {
|
||||
geminiRuntimeAuth,
|
||||
promptSize,
|
||||
expectedMembersCount: effectiveMemberSpecs.length,
|
||||
});
|
||||
logRuntimeLaunchSnapshot(request.teamName, claudePath, launchArgs, request, shellEnv, {
|
||||
logRuntimeLaunchSnapshot(request.teamName, claudePath, finalLaunchArgs, request, shellEnv, {
|
||||
geminiRuntimeAuth,
|
||||
promptSize,
|
||||
expectedMembersCount: effectiveMemberSpecs.length,
|
||||
|
|
@ -12996,7 +12998,7 @@ export class TeamProvisioningService {
|
|||
if (request.skipPermissions === false) {
|
||||
await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd);
|
||||
}
|
||||
child = spawnCli(claudePath, launchArgs, {
|
||||
child = spawnCli(claudePath, finalLaunchArgs, {
|
||||
cwd: request.cwd,
|
||||
env: { ...shellEnv },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
|
|
@ -13027,7 +13029,7 @@ export class TeamProvisioningService {
|
|||
run.child = child;
|
||||
run.spawnContext = {
|
||||
claudePath,
|
||||
args: launchArgs,
|
||||
args: finalLaunchArgs,
|
||||
cwd: request.cwd,
|
||||
env: { ...shellEnv },
|
||||
prompt,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,506 @@
|
|||
import { canonicalizeAgentTeamsToolName } from '../agentTeamsToolNames';
|
||||
import { ClaudeBinaryResolver } from '../ClaudeBinaryResolver';
|
||||
import { ClaudeMultimodelBridgeService } from '../../runtime/ClaudeMultimodelBridgeService';
|
||||
|
||||
import type {
|
||||
OpenCodeRuntimeTranscriptLogMessage,
|
||||
OpenCodeRuntimeTranscriptLogToolCall,
|
||||
} from '../../runtime/ClaudeMultimodelBridgeService';
|
||||
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
|
||||
import type { TeamTaskStallExactRow } from './TeamTaskStallTypes';
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
import type { TeamProviderId, TeamTask } from '@shared/types';
|
||||
|
||||
const OPENCODE_STALL_TRANSCRIPT_LIMIT = 500;
|
||||
|
||||
const TASK_STALL_MARKER_TOOL_NAMES = new Set<string>([
|
||||
'task_start',
|
||||
'task_add_comment',
|
||||
'task_set_status',
|
||||
'task_complete',
|
||||
'review_start',
|
||||
'review_request',
|
||||
'review_approve',
|
||||
'review_request_changes',
|
||||
]);
|
||||
|
||||
const TASK_REFERENCE_KEYS = new Set<string>([
|
||||
'taskid',
|
||||
'task_id',
|
||||
'targetid',
|
||||
'targettaskid',
|
||||
'target_task_id',
|
||||
'canonicalid',
|
||||
'canonical_id',
|
||||
'displayid',
|
||||
'display_id',
|
||||
]);
|
||||
|
||||
const TEAM_REFERENCE_KEYS = new Set<string>(['team', 'teamid', 'team_id', 'teamname', 'team_name']);
|
||||
|
||||
interface BinaryResolverLike {
|
||||
resolve(): Promise<string | null>;
|
||||
}
|
||||
|
||||
interface RuntimeBridgeLike {
|
||||
getOpenCodeTranscript(
|
||||
binaryPath: string,
|
||||
params: {
|
||||
teamId: string;
|
||||
memberName: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<Awaited<ReturnType<ClaudeMultimodelBridgeService['getOpenCodeTranscript']>>>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTaskStallEvidence {
|
||||
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
|
||||
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
|
||||
}
|
||||
|
||||
function emptyEvidence(): OpenCodeTaskStallEvidence {
|
||||
return {
|
||||
recordsByTaskId: new Map(),
|
||||
exactRowsByFilePath: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMemberNameKey(name: string | undefined): string | null {
|
||||
const normalized = name?.trim().toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeTaskRef(value: unknown): string | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim().replace(/^#/, '').toLowerCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function buildTaskRefSet(task: TeamTask): Set<string> {
|
||||
return new Set(
|
||||
[task.id, task.displayId]
|
||||
.map(normalizeTaskRef)
|
||||
.filter((value): value is string => value !== null)
|
||||
);
|
||||
}
|
||||
|
||||
function collectNormalizedRefs(value: unknown, depth = 0): Set<string> {
|
||||
const refs = new Set<string>();
|
||||
if (depth > 4 || value === null || value === undefined) {
|
||||
return refs;
|
||||
}
|
||||
|
||||
const normalized = normalizeTaskRef(value);
|
||||
if (normalized) {
|
||||
refs.add(normalized);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
for (const ref of collectNormalizedRefs(item, depth + 1)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
for (const nestedValue of Object.values(value as Record<string, unknown>)) {
|
||||
for (const ref of collectNormalizedRefs(nestedValue, depth + 1)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectExplicitRefsForKeys(value: unknown, keys: Set<string>, depth = 0): Set<string> {
|
||||
const refs = new Set<string>();
|
||||
if (depth > 4 || value === null || value === undefined) {
|
||||
return refs;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
for (const ref of collectExplicitRefsForKeys(item, keys, depth + 1)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return refs;
|
||||
}
|
||||
|
||||
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (keys.has(key.toLowerCase())) {
|
||||
for (const ref of collectNormalizedRefs(nestedValue)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const ref of collectExplicitRefsForKeys(nestedValue, keys, depth + 1)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function refsIntersect(left: Set<string>, right: Set<string>): boolean {
|
||||
for (const value of left) {
|
||||
if (right.has(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function valueReferencesTask(value: unknown, taskRefs: Set<string>, depth = 0): boolean {
|
||||
if (depth > 4 || value === null || value === undefined || taskRefs.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = normalizeTaskRef(value);
|
||||
if (normalized && taskRefs.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => valueReferencesTask(item, taskRefs, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.entries(value as Record<string, unknown>).some(([key, nestedValue]) => {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (TASK_REFERENCE_KEYS.has(normalizedKey)) {
|
||||
return valueReferencesTask(nestedValue, taskRefs, depth + 1);
|
||||
}
|
||||
return depth < 2 && valueReferencesTask(nestedValue, taskRefs, depth + 1);
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function markerInputReferencesTaskInTeam(
|
||||
input: unknown,
|
||||
teamName: string,
|
||||
taskRefs: Set<string>
|
||||
): boolean {
|
||||
const normalizedTeamName = normalizeTaskRef(teamName);
|
||||
const explicitTeamRefs = collectExplicitRefsForKeys(input, TEAM_REFERENCE_KEYS);
|
||||
if (
|
||||
normalizedTeamName &&
|
||||
explicitTeamRefs.size > 0 &&
|
||||
!explicitTeamRefs.has(normalizedTeamName)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const explicitTaskRefs = collectExplicitRefsForKeys(input, TASK_REFERENCE_KEYS);
|
||||
if (explicitTaskRefs.size > 0) {
|
||||
return refsIntersect(explicitTaskRefs, taskRefs);
|
||||
}
|
||||
|
||||
return valueReferencesTask(input, taskRefs);
|
||||
}
|
||||
|
||||
function buildSyntheticFilePath(teamName: string, owner: string): string {
|
||||
return `opencode-runtime:${teamName}:${normalizeMemberNameKey(owner) ?? owner}`;
|
||||
}
|
||||
|
||||
function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null {
|
||||
const timestamp = new Date(message.timestamp);
|
||||
if (Number.isNaN(timestamp.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
uuid: message.uuid,
|
||||
parentUuid: message.parentUuid,
|
||||
type: message.type,
|
||||
timestamp,
|
||||
role: message.role,
|
||||
content: typeof message.content === 'string' ? message.content : [],
|
||||
model: message.model,
|
||||
agentName: message.agentName,
|
||||
isSidechain: true,
|
||||
isMeta: message.isMeta,
|
||||
sessionId: message.sessionId,
|
||||
toolCalls: message.toolCalls.map((toolCall) => ({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
input: toolCall.input,
|
||||
isTask: toolCall.isTask,
|
||||
...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}),
|
||||
...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}),
|
||||
})),
|
||||
toolResults: message.toolResults.map((toolResult) => ({
|
||||
toolUseId: toolResult.toolUseId,
|
||||
content: toolResult.content,
|
||||
isError: toolResult.isError,
|
||||
})),
|
||||
...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}),
|
||||
...(message.sourceToolAssistantUUID
|
||||
? { sourceToolAssistantUUID: message.sourceToolAssistantUUID }
|
||||
: {}),
|
||||
...(message.subtype ? { subtype: message.subtype } : {}),
|
||||
...(message.level ? { level: message.level } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toExactRow(
|
||||
message: OpenCodeRuntimeTranscriptLogMessage,
|
||||
filePath: string,
|
||||
sourceOrder: number
|
||||
): TeamTaskStallExactRow | null {
|
||||
const parsedMessage = toParsedMessage(message);
|
||||
if (!parsedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
sourceOrder,
|
||||
messageUuid: parsedMessage.uuid,
|
||||
timestamp: parsedMessage.timestamp.toISOString(),
|
||||
parsedMessage,
|
||||
...(message.sourceToolUseID ? { sourceToolUseId: message.sourceToolUseID } : {}),
|
||||
...(message.sourceToolAssistantUUID
|
||||
? { sourceToolAssistantUuid: message.sourceToolAssistantUUID }
|
||||
: {}),
|
||||
...(message.subtype === 'turn_duration' || message.subtype === 'init'
|
||||
? { systemSubtype: message.subtype }
|
||||
: {}),
|
||||
toolUseIds: parsedMessage.toolCalls.map((toolCall) => toolCall.id),
|
||||
toolResultIds: parsedMessage.toolResults.map((toolResult) => toolResult.toolUseId),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskRef(task: TeamTask, teamName: string): BoardTaskActivityRecord['task'] {
|
||||
return {
|
||||
locator: {
|
||||
ref: task.id,
|
||||
refKind: 'canonical',
|
||||
canonicalId: task.id,
|
||||
},
|
||||
resolution: 'resolved',
|
||||
taskRef: {
|
||||
taskId: task.id,
|
||||
displayId: task.displayId ?? task.id.slice(0, 8),
|
||||
teamName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildActionCategory(
|
||||
toolName: string
|
||||
): NonNullable<BoardTaskActivityRecord['action']>['category'] {
|
||||
switch (toolName) {
|
||||
case 'task_add_comment':
|
||||
return 'comment';
|
||||
case 'review_start':
|
||||
case 'review_request':
|
||||
case 'review_approve':
|
||||
case 'review_request_changes':
|
||||
return 'review';
|
||||
case 'task_set_owner':
|
||||
return 'assignment';
|
||||
default:
|
||||
return 'status';
|
||||
}
|
||||
}
|
||||
|
||||
function extractCommentId(input: Record<string, unknown>): string | undefined {
|
||||
const commentId = input.commentId ?? input.comment_id;
|
||||
return typeof commentId === 'string' && commentId.trim().length > 0
|
||||
? commentId.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function buildRecord(args: {
|
||||
teamName: string;
|
||||
task: TeamTask;
|
||||
owner: string;
|
||||
sessionId: string;
|
||||
message: OpenCodeRuntimeTranscriptLogMessage;
|
||||
toolCall: OpenCodeRuntimeTranscriptLogToolCall;
|
||||
sourceOrder: number;
|
||||
filePath: string;
|
||||
canonicalToolName: string;
|
||||
}): BoardTaskActivityRecord {
|
||||
const taskRef = buildTaskRef(args.task, args.teamName);
|
||||
const commentId = extractCommentId(args.toolCall.input);
|
||||
return {
|
||||
id: `opencode-stall:${args.teamName}:${args.task.id}:${args.message.uuid}:${args.toolCall.id}`,
|
||||
timestamp: new Date(args.message.timestamp).toISOString(),
|
||||
task: taskRef,
|
||||
linkKind: 'board_action',
|
||||
targetRole: 'subject',
|
||||
actor: {
|
||||
memberName: args.owner,
|
||||
role: 'member',
|
||||
sessionId: args.sessionId,
|
||||
isSidechain: true,
|
||||
},
|
||||
actorContext: {
|
||||
relation: 'same_task',
|
||||
activeTask: taskRef,
|
||||
activePhase: args.task.reviewState === 'review' ? 'review' : 'work',
|
||||
},
|
||||
action: {
|
||||
canonicalToolName: args.canonicalToolName,
|
||||
toolUseId: args.toolCall.id,
|
||||
category: buildActionCategory(args.canonicalToolName),
|
||||
...(commentId ? { details: { commentId } } : {}),
|
||||
},
|
||||
source: {
|
||||
messageUuid: args.message.uuid,
|
||||
filePath: args.filePath,
|
||||
toolUseId: args.toolCall.id,
|
||||
sourceOrder: args.sourceOrder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectTaskRecords(args: {
|
||||
teamName: string;
|
||||
task: TeamTask;
|
||||
owner: string;
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
messages: OpenCodeRuntimeTranscriptLogMessage[];
|
||||
}): BoardTaskActivityRecord[] {
|
||||
const taskRefs = buildTaskRefSet(args.task);
|
||||
if (taskRefs.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const records: BoardTaskActivityRecord[] = [];
|
||||
for (let index = 0; index < args.messages.length; index += 1) {
|
||||
const message = args.messages[index];
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const toolCall of message.toolCalls) {
|
||||
const canonicalToolName = canonicalizeAgentTeamsToolName(toolCall.name ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!TASK_STALL_MARKER_TOOL_NAMES.has(canonicalToolName)) {
|
||||
continue;
|
||||
}
|
||||
if (!markerInputReferencesTaskInTeam(toolCall.input, args.teamName, taskRefs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
records.push(
|
||||
buildRecord({
|
||||
teamName: args.teamName,
|
||||
task: args.task,
|
||||
owner: args.owner,
|
||||
sessionId: args.sessionId,
|
||||
message,
|
||||
toolCall,
|
||||
sourceOrder: index + 1,
|
||||
filePath: args.filePath,
|
||||
canonicalToolName,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function groupOpenCodeTasksByOwner(
|
||||
tasks: TeamTask[],
|
||||
providerByMemberName: Map<string, TeamProviderId>
|
||||
): Map<string, TeamTask[]> {
|
||||
const grouped = new Map<string, TeamTask[]>();
|
||||
for (const task of tasks) {
|
||||
const owner = task.owner?.trim();
|
||||
if (!owner) {
|
||||
continue;
|
||||
}
|
||||
const provider = providerByMemberName.get(normalizeMemberNameKey(owner) ?? '');
|
||||
if (provider !== 'opencode') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = grouped.get(owner) ?? [];
|
||||
existing.push(task);
|
||||
grouped.set(owner, existing);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export class OpenCodeTaskStallEvidenceSource {
|
||||
constructor(
|
||||
private readonly runtimeBridge: RuntimeBridgeLike = new ClaudeMultimodelBridgeService(),
|
||||
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
|
||||
) {}
|
||||
|
||||
async readEvidence(args: {
|
||||
teamName: string;
|
||||
tasks: TeamTask[];
|
||||
providerByMemberName: Map<string, TeamProviderId>;
|
||||
}): Promise<OpenCodeTaskStallEvidence> {
|
||||
const tasksByOwner = groupOpenCodeTasksByOwner(args.tasks, args.providerByMemberName);
|
||||
if (tasksByOwner.size === 0) {
|
||||
return emptyEvidence();
|
||||
}
|
||||
|
||||
const binaryPath = await this.binaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
return emptyEvidence();
|
||||
}
|
||||
|
||||
const evidence = emptyEvidence();
|
||||
for (const [owner, tasks] of tasksByOwner.entries()) {
|
||||
const transcript = await this.runtimeBridge
|
||||
.getOpenCodeTranscript(binaryPath, {
|
||||
teamId: args.teamName,
|
||||
memberName: owner,
|
||||
limit: OPENCODE_STALL_TRANSCRIPT_LIMIT,
|
||||
})
|
||||
.catch(() => null);
|
||||
const messages = transcript?.logProjection?.messages ?? [];
|
||||
if (messages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = buildSyntheticFilePath(args.teamName, owner);
|
||||
const exactRows = messages
|
||||
.map((message, index) => toExactRow(message, filePath, index + 1))
|
||||
.filter((row): row is TeamTaskStallExactRow => row !== null);
|
||||
if (exactRows.length > 0) {
|
||||
evidence.exactRowsByFilePath.set(filePath, exactRows);
|
||||
}
|
||||
|
||||
const sessionId = transcript?.sessionId ?? messages[0]?.sessionId ?? filePath;
|
||||
for (const task of tasks) {
|
||||
const records = collectTaskRecords({
|
||||
teamName: args.teamName,
|
||||
task,
|
||||
owner,
|
||||
sessionId,
|
||||
filePath,
|
||||
messages,
|
||||
});
|
||||
if (records.length > 0) {
|
||||
evidence.recordsByTaskId.set(task.id, records);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
}
|
||||
|
|
@ -44,12 +44,12 @@ function isOpenCodeDeliveryAccepted(delivery: OpenCodeTaskStallDelivery): boolea
|
|||
if (delivery.queuedBehindMessageId) {
|
||||
return false;
|
||||
}
|
||||
if (delivery.responsePending === true) {
|
||||
return false;
|
||||
}
|
||||
if (delivery.accepted === true) {
|
||||
return true;
|
||||
}
|
||||
if (delivery.responsePending === true) {
|
||||
return false;
|
||||
}
|
||||
if (delivery.delivered === true) {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,6 +284,21 @@ function buildEpochKey(
|
|||
].join(':');
|
||||
}
|
||||
|
||||
function buildOpenCodeNoProgressEpochKey(args: {
|
||||
task: TeamTask;
|
||||
intervalStartedAt: string;
|
||||
owner: string;
|
||||
}): string {
|
||||
return [
|
||||
args.task.id,
|
||||
'work',
|
||||
'opencode_no_owner_progress',
|
||||
args.owner,
|
||||
args.intervalStartedAt,
|
||||
args.task.updatedAt ?? args.task.createdAt ?? 'unknown',
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function buildAlertEvaluation(args: {
|
||||
task: TeamTask;
|
||||
branch: TaskStallBranch;
|
||||
|
|
@ -303,11 +318,35 @@ function buildAlertEvaluation(args: {
|
|||
};
|
||||
}
|
||||
|
||||
function buildOpenCodeNoProgressAlertEvaluation(args: {
|
||||
task: TeamTask;
|
||||
owner: string;
|
||||
intervalStartedAt: string;
|
||||
reason: string;
|
||||
}): TaskStallEvaluation {
|
||||
return {
|
||||
status: 'alert',
|
||||
taskId: args.task.id,
|
||||
branch: 'work',
|
||||
signal: 'mid_turn_after_touch',
|
||||
progressSignal: 'unknown',
|
||||
epochKey: buildOpenCodeNoProgressEpochKey(args),
|
||||
reason: args.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMemberNameKey(name: string | undefined): string | null {
|
||||
const normalized = name?.trim().toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function resolveOwnerProviderId(
|
||||
snapshot: TeamTaskStallSnapshot,
|
||||
owner: string | undefined
|
||||
): string | null {
|
||||
return snapshot.providerByMemberName.get(normalizeMemberNameKey(owner) ?? '') ?? null;
|
||||
}
|
||||
|
||||
export class TeamTaskStallPolicy {
|
||||
evaluateWork(args: {
|
||||
now: Date;
|
||||
|
|
@ -347,7 +386,25 @@ export class TeamTaskStallPolicy {
|
|||
}
|
||||
|
||||
const records = snapshot.recordsByTaskId.get(task.id) ?? [];
|
||||
const ownerProviderId = resolveOwnerProviderId(snapshot, task.owner);
|
||||
const isOpenCodeOwner = ownerProviderId === 'opencode';
|
||||
if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) {
|
||||
if (isOpenCodeOwner) {
|
||||
const elapsedMs = args.now.getTime() - Date.parse(openWorkInterval.startedAt);
|
||||
if (elapsedMs >= getOpenCodeWeakStartStallThresholdMs()) {
|
||||
return buildOpenCodeNoProgressAlertEvaluation({
|
||||
task,
|
||||
owner: task.owner,
|
||||
intervalStartedAt: openWorkInterval.startedAt,
|
||||
reason: 'Potential OpenCode task stall without owner progress evidence.',
|
||||
});
|
||||
}
|
||||
return skip(
|
||||
task.id,
|
||||
'OpenCode task has no owner progress evidence yet but is below the stall threshold',
|
||||
'below_threshold'
|
||||
);
|
||||
}
|
||||
return skip(
|
||||
task.id,
|
||||
'Task run is not instrumented enough for stall evaluation',
|
||||
|
|
@ -369,6 +426,22 @@ export class TeamTaskStallPolicy {
|
|||
})();
|
||||
|
||||
if (!workContext) {
|
||||
if (isOpenCodeOwner) {
|
||||
const elapsedMs = args.now.getTime() - Date.parse(openWorkInterval.startedAt);
|
||||
if (elapsedMs >= getOpenCodeWeakStartStallThresholdMs()) {
|
||||
return buildOpenCodeNoProgressAlertEvaluation({
|
||||
task,
|
||||
owner: task.owner,
|
||||
intervalStartedAt: openWorkInterval.startedAt,
|
||||
reason: 'Potential OpenCode task stall without owner work touch.',
|
||||
});
|
||||
}
|
||||
return skip(
|
||||
task.id,
|
||||
'OpenCode task has no owner work touch yet but is below the stall threshold',
|
||||
'below_threshold'
|
||||
);
|
||||
}
|
||||
return skip(
|
||||
task.id,
|
||||
'No positive work touch found in current work interval',
|
||||
|
|
@ -396,8 +469,6 @@ export class TeamTaskStallPolicy {
|
|||
task,
|
||||
record: workContext.lastMeaningfulTouch,
|
||||
});
|
||||
const ownerProviderId =
|
||||
snapshot.providerByMemberName.get(normalizeMemberNameKey(task.owner) ?? '') ?? null;
|
||||
const isOpenCodeWeakStartOnly =
|
||||
ownerProviderId === 'opencode' && progressClassification.signal === 'weak_start_only';
|
||||
const elapsedMs = args.now.getTime() - Date.parse(workContext.lastMeaningfulTouchAt);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { TeamTaskReader } from '../TeamTaskReader';
|
|||
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
|
||||
|
||||
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
||||
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
|
||||
import { buildResolvedReviewerIndex } from './reviewerResolution';
|
||||
import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader';
|
||||
import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader';
|
||||
|
|
@ -16,7 +17,7 @@ import {
|
|||
} from '@shared/utils/teamProvider';
|
||||
|
||||
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
|
||||
import type { TeamTaskStallSnapshot } from './TeamTaskStallTypes';
|
||||
import type { TeamTaskStallExactRow, TeamTaskStallSnapshot } from './TeamTaskStallTypes';
|
||||
import type { TeamConfig, TeamMember, TeamProviderId, TeamTask } from '@shared/types';
|
||||
|
||||
function resolveLeadNameFromConfig(config: TeamConfig): string {
|
||||
|
|
@ -69,7 +70,8 @@ export class TeamTaskStallSnapshotSource {
|
|||
private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(),
|
||||
private readonly freshnessReader: TeamTaskLogFreshnessReader = new TeamTaskLogFreshnessReader(),
|
||||
private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader(),
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly openCodeEvidenceSource: OpenCodeTaskStallEvidenceSource = new OpenCodeTaskStallEvidenceSource()
|
||||
) {}
|
||||
|
||||
async getSnapshot(teamName: string): Promise<TeamTaskStallSnapshot | null> {
|
||||
|
|
@ -117,7 +119,7 @@ export class TeamTaskStallSnapshotSource {
|
|||
relevantMonitorTasks,
|
||||
recordsByTaskId
|
||||
);
|
||||
const [freshnessByTaskId, exactRowsByFilePath] = await Promise.all([
|
||||
const [freshnessByTaskId, exactRowsByFilePath, openCodeEvidence] = await Promise.all([
|
||||
this.freshnessReader.readSignals(
|
||||
transcriptContext.projectDir,
|
||||
relevantMonitorTasks.map((task) => task.id)
|
||||
|
|
@ -125,7 +127,25 @@ export class TeamTaskStallSnapshotSource {
|
|||
exactReadsEnabled
|
||||
? this.exactRowReader.parseFiles(relevantExactFiles)
|
||||
: Promise.resolve(new Map()),
|
||||
activityReadsEnabled && exactReadsEnabled
|
||||
? this.openCodeEvidenceSource.readEvidence({
|
||||
teamName,
|
||||
tasks: relevantMonitorTasks,
|
||||
providerByMemberName,
|
||||
})
|
||||
: Promise.resolve({
|
||||
recordsByTaskId: new Map(),
|
||||
exactRowsByFilePath: new Map(),
|
||||
}),
|
||||
]);
|
||||
const mergedRecordsByTaskId = this.mergeActivityRecords(
|
||||
recordsByTaskId,
|
||||
openCodeEvidence.recordsByTaskId
|
||||
);
|
||||
const mergedExactRowsByFilePath = this.mergeExactRows(
|
||||
exactRowsByFilePath,
|
||||
openCodeEvidence.exactRowsByFilePath
|
||||
);
|
||||
|
||||
return {
|
||||
teamName,
|
||||
|
|
@ -142,13 +162,72 @@ export class TeamTaskStallSnapshotSource {
|
|||
inProgressTasks,
|
||||
reviewOpenTasks,
|
||||
resolvedReviewersByTaskId,
|
||||
recordsByTaskId,
|
||||
recordsByTaskId: mergedRecordsByTaskId,
|
||||
freshnessByTaskId,
|
||||
exactRowsByFilePath,
|
||||
exactRowsByFilePath: mergedExactRowsByFilePath,
|
||||
providerByMemberName,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeActivityRecords(
|
||||
base: Map<string, BoardTaskActivityRecord[]>,
|
||||
extra: Map<string, BoardTaskActivityRecord[]>
|
||||
): Map<string, BoardTaskActivityRecord[]> {
|
||||
if (extra.size === 0) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const merged = new Map(base);
|
||||
for (const [taskId, records] of extra.entries()) {
|
||||
const existing = merged.get(taskId) ?? [];
|
||||
const seen = new Set(existing.map((record) => record.id));
|
||||
const next = [...existing];
|
||||
for (const record of records) {
|
||||
if (!seen.has(record.id)) {
|
||||
next.push(record);
|
||||
seen.add(record.id);
|
||||
}
|
||||
}
|
||||
next.sort((left, right) => {
|
||||
const timeDiff = Date.parse(left.timestamp) - Date.parse(right.timestamp);
|
||||
return timeDiff !== 0 ? timeDiff : left.source.sourceOrder - right.source.sourceOrder;
|
||||
});
|
||||
merged.set(taskId, next);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private mergeExactRows(
|
||||
base: Map<string, TeamTaskStallExactRow[]>,
|
||||
extra: Map<string, TeamTaskStallExactRow[]>
|
||||
): Map<string, TeamTaskStallExactRow[]> {
|
||||
if (extra.size === 0) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const merged = new Map(base);
|
||||
for (const [filePath, rows] of extra.entries()) {
|
||||
const existing = merged.get(filePath) ?? [];
|
||||
const seen = new Set(existing.map((row) => `${row.messageUuid}:${row.sourceOrder}`));
|
||||
const next = [...existing];
|
||||
for (const row of rows) {
|
||||
const key = `${row.messageUuid}:${row.sourceOrder}`;
|
||||
if (!seen.has(key)) {
|
||||
next.push(row);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
next.sort((left, right) => {
|
||||
const orderDiff = left.sourceOrder - right.sourceOrder;
|
||||
return orderDiff !== 0
|
||||
? orderDiff
|
||||
: Date.parse(left.timestamp) - Date.parse(right.timestamp);
|
||||
});
|
||||
merged.set(filePath, next);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private collectRelevantExactFiles(
|
||||
inProgressTasks: TeamTask[],
|
||||
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>
|
||||
|
|
|
|||
|
|
@ -560,7 +560,7 @@ export const CreateTeamDialog = ({
|
|||
effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree');
|
||||
const worktreeGitReadiness = useWorktreeGitReadiness(
|
||||
effectiveCwd || null,
|
||||
open && canCreate && !soloTeam
|
||||
open && canCreate && hasSelectedWorktreeIsolation
|
||||
);
|
||||
const worktreeIsolationDisabledReason =
|
||||
!soloTeam && canCreate ? getWorktreeGitControlDisabledReason(worktreeGitReadiness) : null;
|
||||
|
|
@ -1840,7 +1840,7 @@ export const CreateTeamDialog = ({
|
|||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!soloTeam && canCreate ? (
|
||||
{canCreate && hasSelectedWorktreeIsolation ? (
|
||||
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1340,7 +1340,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const hasSelectedWorktreeIsolation =
|
||||
isLaunchMode &&
|
||||
effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree');
|
||||
const worktreeGitReadiness = useWorktreeGitReadiness(effectiveCwd || null, open && isLaunchMode);
|
||||
const worktreeGitReadiness = useWorktreeGitReadiness(
|
||||
effectiveCwd || null,
|
||||
open && hasSelectedWorktreeIsolation
|
||||
);
|
||||
const worktreeIsolationDisabledReason = isLaunchMode
|
||||
? getWorktreeGitControlDisabledReason(worktreeGitReadiness)
|
||||
: null;
|
||||
|
|
@ -2505,7 +2508,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
memberModelIssueById={memberModelIssueById}
|
||||
softDeleteMembers
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
headerBottom={<WorktreeGitReadinessBanner state={worktreeGitReadiness} />}
|
||||
headerBottom={
|
||||
hasSelectedWorktreeIsolation ? (
|
||||
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
|
|
|
|||
1
src/types/agent-teams-controller.d.ts
vendored
1
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -2,6 +2,7 @@ declare module 'agent-teams-controller' {
|
|||
export interface ControllerContextOptions {
|
||||
teamName: string;
|
||||
claudeDir?: string;
|
||||
allowUserMessageSender?: boolean;
|
||||
}
|
||||
|
||||
export interface ControllerTaskApi {
|
||||
|
|
|
|||
67
test/main/services/runtime/cliSettingsArgs.test.ts
Normal file
67
test/main/services/runtime/cliSettingsArgs.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mergeJsonSettingsArgs } from '../../../../src/main/services/runtime/cliSettingsArgs';
|
||||
|
||||
function getSettingsValues(args: string[]): string[] {
|
||||
const values: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
if (args[i] === '--settings' && typeof args[i + 1] === 'string') {
|
||||
values.push(args[i + 1]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
describe('mergeJsonSettingsArgs', () => {
|
||||
it('merges app and provider JSON settings into a single settings argument', () => {
|
||||
const merged = mergeJsonSettingsArgs([
|
||||
'--settings',
|
||||
'{"fastMode":false}',
|
||||
'--model',
|
||||
'gpt-5.4',
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
'--settings',
|
||||
'{"fastMode":false,"codex":{"forced_login_method":"chatgpt"}}',
|
||||
'--model',
|
||||
'gpt-5.4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deep merges nested JSON settings and lets later values win', () => {
|
||||
const merged = mergeJsonSettingsArgs([
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"api","existing":true}}',
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(getSettingsValues(merged)[0] ?? '{}')).toEqual({
|
||||
codex: {
|
||||
forced_login_method: 'chatgpt',
|
||||
existing: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves non-JSON settings values while merging JSON settings', () => {
|
||||
const merged = mergeJsonSettingsArgs([
|
||||
'--settings',
|
||||
'/tmp/settings.json',
|
||||
'--settings={"fastMode":false}',
|
||||
'--settings',
|
||||
'{"codex":{"forced_login_method":"chatgpt"}}',
|
||||
]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
'--settings',
|
||||
'/tmp/settings.json',
|
||||
'--settings',
|
||||
'{"fastMode":false,"codex":{"forced_login_method":"chatgpt"}}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OpenCodeTaskStallEvidenceSource } from '../../../../../src/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource';
|
||||
|
||||
import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../../../src/main/services/runtime/ClaudeMultimodelBridgeService';
|
||||
import type { TeamTask } from '../../../../../src/shared/types';
|
||||
|
||||
function createMessage(
|
||||
overrides: Partial<OpenCodeRuntimeTranscriptLogMessage>
|
||||
): OpenCodeRuntimeTranscriptLogMessage {
|
||||
return {
|
||||
uuid: 'msg-1',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-19T12:00:00.000Z',
|
||||
content: '',
|
||||
isMeta: false,
|
||||
sessionId: 'session-open',
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('OpenCodeTaskStallEvidenceSource', () => {
|
||||
it('projects OpenCode task marker tools into stall records and exact rows', async () => {
|
||||
const task: TeamTask = {
|
||||
id: 'task-a',
|
||||
displayId: 'abcd1234',
|
||||
subject: 'Task A',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-04-19T11:55:00.000Z' }],
|
||||
};
|
||||
const runtimeBridge = {
|
||||
getOpenCodeTranscript: vi.fn(async () => ({
|
||||
sessionId: 'session-open',
|
||||
logProjection: {
|
||||
messages: [
|
||||
createMessage({
|
||||
uuid: 'msg-native',
|
||||
timestamp: '2026-04-19T11:59:00.000Z',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-read',
|
||||
name: 'read',
|
||||
input: { filePath: '/tmp/a.ts' },
|
||||
isTask: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
createMessage({
|
||||
uuid: 'msg-start',
|
||||
timestamp: '2026-04-19T12:00:00.000Z',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-start',
|
||||
name: 'agent-teams_task_start',
|
||||
input: { teamName: 'demo', taskId: 'task-a' },
|
||||
isTask: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
createMessage({
|
||||
uuid: 'msg-foreign',
|
||||
timestamp: '2026-04-19T12:01:00.000Z',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-foreign',
|
||||
name: 'agent-teams_task_start',
|
||||
input: { teamName: 'other-team', taskId: 'task-a' },
|
||||
isTask: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
const source = new OpenCodeTaskStallEvidenceSource(
|
||||
runtimeBridge as never,
|
||||
{ resolve: vi.fn(async () => '/tmp/orchestrator-cli') }
|
||||
);
|
||||
|
||||
const evidence = await source.readEvidence({
|
||||
teamName: 'demo',
|
||||
tasks: [task],
|
||||
providerByMemberName: new Map([['bob', 'opencode']]),
|
||||
});
|
||||
|
||||
expect(runtimeBridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/orchestrator-cli', {
|
||||
teamId: 'demo',
|
||||
memberName: 'bob',
|
||||
limit: 500,
|
||||
});
|
||||
expect(evidence.recordsByTaskId.get('task-a')).toHaveLength(1);
|
||||
expect(evidence.recordsByTaskId.get('task-a')?.[0]).toMatchObject({
|
||||
timestamp: '2026-04-19T12:00:00.000Z',
|
||||
actor: {
|
||||
memberName: 'bob',
|
||||
role: 'member',
|
||||
sessionId: 'session-open',
|
||||
},
|
||||
action: {
|
||||
canonicalToolName: 'task_start',
|
||||
toolUseId: 'tool-start',
|
||||
},
|
||||
});
|
||||
const exactRows = [...evidence.exactRowsByFilePath.values()][0] ?? [];
|
||||
expect(exactRows.map((row) => row.messageUuid)).toEqual([
|
||||
'msg-native',
|
||||
'msg-start',
|
||||
'msg-foreign',
|
||||
]);
|
||||
expect(exactRows[0]?.toolUseIds).toEqual(['tool-read']);
|
||||
});
|
||||
|
||||
it('does not call OpenCode when no task owner is an OpenCode member', async () => {
|
||||
const runtimeBridge = {
|
||||
getOpenCodeTranscript: vi.fn(),
|
||||
};
|
||||
const binaryResolver = {
|
||||
resolve: vi.fn(async () => '/tmp/orchestrator-cli'),
|
||||
};
|
||||
const source = new OpenCodeTaskStallEvidenceSource(
|
||||
runtimeBridge as never,
|
||||
binaryResolver
|
||||
);
|
||||
|
||||
const evidence = await source.readEvidence({
|
||||
teamName: 'demo',
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: 'abcd1234',
|
||||
subject: 'Task A',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
},
|
||||
],
|
||||
providerByMemberName: new Map([['alice', 'codex']]),
|
||||
});
|
||||
|
||||
expect(binaryResolver.resolve).not.toHaveBeenCalled();
|
||||
expect(runtimeBridge.getOpenCodeTranscript).not.toHaveBeenCalled();
|
||||
expect(evidence.recordsByTaskId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -170,7 +170,7 @@ describe('TeamTaskStallNotifier', () => {
|
|||
await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mark response-pending delivery as remediated even after runtime acceptance', async () => {
|
||||
it('marks accepted response-pending delivery as remediated and leaves follow-up to the delivery ledger', async () => {
|
||||
const relay = vi.fn(async () => ({
|
||||
relayed: 1,
|
||||
attempted: 1,
|
||||
|
|
@ -191,7 +191,8 @@ describe('TeamTaskStallNotifier', () => {
|
|||
{ sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never
|
||||
);
|
||||
|
||||
await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]);
|
||||
const alert = createAlert();
|
||||
await expect(notifier.notifyOpenCodeOwners('demo', [alert])).resolves.toEqual([alert]);
|
||||
expect(relay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -415,6 +415,104 @@ describe('TeamTaskStallPolicy', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('alerts OpenCode-owned tasks with no instrumented owner progress after threshold', () => {
|
||||
const task: TeamTask = {
|
||||
id: 'task-open-no-progress',
|
||||
displayId: 'feed4444',
|
||||
subject: 'OpenCode no progress',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }],
|
||||
};
|
||||
const snapshot = createSnapshot({
|
||||
activeTasks: [task],
|
||||
allTasksById: new Map([[task.id, task]]),
|
||||
inProgressTasks: [task],
|
||||
providerByMemberName: new Map([['alice', 'opencode']]),
|
||||
});
|
||||
|
||||
const evaluation = policy.evaluateWork({
|
||||
now: new Date('2026-04-19T12:07:00.000Z'),
|
||||
task,
|
||||
snapshot,
|
||||
});
|
||||
|
||||
expect(evaluation).toMatchObject({
|
||||
status: 'alert',
|
||||
taskId: 'task-open-no-progress',
|
||||
branch: 'work',
|
||||
signal: 'mid_turn_after_touch',
|
||||
progressSignal: 'unknown',
|
||||
reason: 'Potential OpenCode task stall without owner progress evidence.',
|
||||
});
|
||||
expect(evaluation.epochKey).toContain('opencode_no_owner_progress');
|
||||
});
|
||||
|
||||
it('keeps non-OpenCode no-progress tasks on the existing non-instrumented skip path', () => {
|
||||
const task: TeamTask = {
|
||||
id: 'task-codex-no-progress',
|
||||
displayId: 'feed5555',
|
||||
subject: 'Codex no progress',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }],
|
||||
};
|
||||
|
||||
const evaluation = policy.evaluateWork({
|
||||
now: new Date('2026-04-19T12:30:00.000Z'),
|
||||
task,
|
||||
snapshot: createSnapshot({
|
||||
activeTasks: [task],
|
||||
allTasksById: new Map([[task.id, task]]),
|
||||
inProgressTasks: [task],
|
||||
providerByMemberName: new Map([['alice', 'codex']]),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(evaluation).toMatchObject({
|
||||
status: 'skip',
|
||||
taskId: 'task-codex-no-progress',
|
||||
skipReason: 'non_instrumented_run',
|
||||
});
|
||||
});
|
||||
|
||||
it('alerts OpenCode-owned tasks with records but no owner work touch after threshold', () => {
|
||||
const task: TeamTask = {
|
||||
id: 'task-open-no-touch',
|
||||
displayId: 'feed6666',
|
||||
subject: 'OpenCode no owner touch',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }],
|
||||
};
|
||||
const record = createRecord({
|
||||
actor: {
|
||||
memberName: 'bob',
|
||||
role: 'member',
|
||||
sessionId: 'session-b',
|
||||
isSidechain: true,
|
||||
},
|
||||
});
|
||||
|
||||
const evaluation = policy.evaluateWork({
|
||||
now: new Date('2026-04-19T12:07:00.000Z'),
|
||||
task,
|
||||
snapshot: createSnapshot({
|
||||
activeTasks: [task],
|
||||
allTasksById: new Map([[task.id, task]]),
|
||||
inProgressTasks: [task],
|
||||
providerByMemberName: new Map([['alice', 'opencode']]),
|
||||
recordsByTaskId: new Map([[task.id, [record]]]),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(evaluation).toMatchObject({
|
||||
status: 'alert',
|
||||
taskId: 'task-open-no-touch',
|
||||
reason: 'Potential OpenCode task stall without owner work touch.',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed on review branch when review has not started yet', () => {
|
||||
const task: TeamTask = {
|
||||
id: 'task-b',
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ describe('TeamTaskStallSnapshotSource', () => {
|
|||
const membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'opencode' }]),
|
||||
};
|
||||
const openCodeEvidenceSource = {
|
||||
readEvidence: vi.fn(async () => ({
|
||||
recordsByTaskId: new Map(),
|
||||
exactRowsByFilePath: new Map(),
|
||||
})),
|
||||
};
|
||||
|
||||
const source = new TeamTaskStallSnapshotSource(
|
||||
locator as never,
|
||||
|
|
@ -125,7 +131,8 @@ describe('TeamTaskStallSnapshotSource', () => {
|
|||
batchIndexer as never,
|
||||
freshnessReader as never,
|
||||
exactRowReader as never,
|
||||
membersMetaStore as never
|
||||
membersMetaStore as never,
|
||||
openCodeEvidenceSource as never
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot('demo');
|
||||
|
|
@ -138,6 +145,14 @@ describe('TeamTaskStallSnapshotSource', () => {
|
|||
});
|
||||
expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']);
|
||||
expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']);
|
||||
expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({
|
||||
teamName: 'demo',
|
||||
tasks: [activeTasks[0], activeTasks[1]],
|
||||
providerByMemberName: new Map([
|
||||
['team-lead', 'codex'],
|
||||
['alice', 'opencode'],
|
||||
]),
|
||||
});
|
||||
expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']);
|
||||
expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']);
|
||||
expect(snapshot?.leadName).toBe('team-lead');
|
||||
|
|
@ -153,4 +168,95 @@ describe('TeamTaskStallSnapshotSource', () => {
|
|||
});
|
||||
expect(snapshot?.recordsByTaskId).toBe(recordsByTaskId);
|
||||
});
|
||||
|
||||
it('merges OpenCode runtime evidence even when no Claude transcript files are available', async () => {
|
||||
const task = {
|
||||
id: 'task-open',
|
||||
displayId: 'opencode1',
|
||||
subject: 'OpenCode task',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
};
|
||||
const openCodeRecord = {
|
||||
id: 'opencode-rec',
|
||||
timestamp: '2026-04-19T12:00:00.000Z',
|
||||
source: {
|
||||
filePath: 'opencode-runtime:demo:bob',
|
||||
sourceOrder: 1,
|
||||
},
|
||||
};
|
||||
const openCodeRows = [
|
||||
{
|
||||
filePath: 'opencode-runtime:demo:bob',
|
||||
sourceOrder: 1,
|
||||
messageUuid: 'msg-open',
|
||||
timestamp: '2026-04-19T12:00:00.000Z',
|
||||
parsedMessage: {
|
||||
uuid: 'msg-open',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: new Date('2026-04-19T12:00:00.000Z'),
|
||||
content: '',
|
||||
isSidechain: true,
|
||||
isMeta: false,
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
},
|
||||
toolUseIds: [],
|
||||
toolResultIds: [],
|
||||
},
|
||||
];
|
||||
const source = new TeamTaskStallSnapshotSource(
|
||||
{
|
||||
getContext: vi.fn(async () => ({
|
||||
projectDir: '/tmp/project',
|
||||
projectId: 'project-id',
|
||||
config: {
|
||||
members: [
|
||||
{ name: 'team-lead', role: 'team lead', providerId: 'codex' },
|
||||
{ name: 'bob', role: 'Developer', providerId: 'opencode' },
|
||||
],
|
||||
},
|
||||
sessionIds: [],
|
||||
transcriptFiles: [],
|
||||
})),
|
||||
} as never,
|
||||
{
|
||||
getTasks: vi.fn(async () => [task]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'demo', tasks: {} })),
|
||||
} as never,
|
||||
{
|
||||
readFiles: vi.fn(async () => {
|
||||
throw new Error('transcript reader should not be called');
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
buildIndex: vi.fn(() => new Map()),
|
||||
} as never,
|
||||
{
|
||||
readSignals: vi.fn(async () => new Map()),
|
||||
} as never,
|
||||
{
|
||||
parseFiles: vi.fn(async () => new Map()),
|
||||
} as never,
|
||||
{
|
||||
getMembers: vi.fn(async () => []),
|
||||
} as never,
|
||||
{
|
||||
readEvidence: vi.fn(async () => ({
|
||||
recordsByTaskId: new Map([['task-open', [openCodeRecord]]]),
|
||||
exactRowsByFilePath: new Map([['opencode-runtime:demo:bob', openCodeRows]]),
|
||||
})),
|
||||
} as never
|
||||
);
|
||||
|
||||
const snapshot = await source.getSnapshot('demo');
|
||||
|
||||
expect(snapshot?.recordsByTaskId.get('task-open')).toEqual([openCodeRecord]);
|
||||
expect(snapshot?.exactRowsByFilePath.get('opencode-runtime:demo:bob')).toEqual(openCodeRows);
|
||||
expect(snapshot?.transcriptFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue