merge(dev): sync dev into main
This commit is contained in:
commit
9a8ea6af08
151 changed files with 11976 additions and 645 deletions
|
|
@ -475,13 +475,34 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
});
|
||||
}
|
||||
|
||||
function setTaskOwner(paths, taskRef, owner) {
|
||||
function normalizeOwnerValue(owner) {
|
||||
if (owner == null || owner === 'clear' || owner === 'none') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = String(owner).trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function setTaskOwner(paths, taskRef, owner, actor) {
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
if (owner == null || owner === 'clear' || owner === 'none') {
|
||||
delete task.owner;
|
||||
const previousOwner = normalizeOwnerValue(task.owner);
|
||||
const nextOwner = normalizeOwnerValue(owner);
|
||||
|
||||
if (nextOwner) {
|
||||
task.owner = nextOwner;
|
||||
} else {
|
||||
task.owner = String(owner).trim();
|
||||
delete task.owner;
|
||||
}
|
||||
|
||||
if (previousOwner !== nextOwner) {
|
||||
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
||||
type: 'owner_changed',
|
||||
...(previousOwner ? { from: previousOwner } : {}),
|
||||
...(nextOwner ? { to: nextOwner } : {}),
|
||||
...(actor ? { actor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -472,13 +472,13 @@ function restoreTask(context, taskId, actor) {
|
|||
});
|
||||
}
|
||||
|
||||
function setTaskOwner(context, taskId, owner) {
|
||||
function setTaskOwner(context, taskId, owner, actor) {
|
||||
const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
|
||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const nextOwner = isClearOwnerValue(owner)
|
||||
? owner
|
||||
: assertKnownTaskActor(context, owner, 'task owner');
|
||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner, normalizeActorName(actor) || undefined);
|
||||
return {
|
||||
previousTask: before,
|
||||
updatedTask: after,
|
||||
|
|
@ -707,7 +707,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
|
|||
- Human-facing summaries should use the short display label like #abcd1234 for readability.
|
||||
1. If you are about to do implementation/fix work on a task yourself, make sure the owner reflects the actual implementer:
|
||||
- If the task is unassigned or assigned to someone else, FIRST reassign it to yourself with MCP tool task_set_owner:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>" }
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>", actor: "<your-name>" }
|
||||
- Do this only when you are genuinely taking over the work.
|
||||
- Reviewing, approving, or leaving comments does NOT require changing ownership.
|
||||
2. Use MCP tool task_start to mark task started:
|
||||
|
|
@ -911,14 +911,17 @@ async function memberBriefing(context, memberName, options = {}) {
|
|||
config.projectPath.trim() :
|
||||
'';
|
||||
|
||||
const activeProcesses = processStore
|
||||
.listProcesses(context.paths)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
entry.alive &&
|
||||
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
|
||||
);
|
||||
const includeActiveProcesses = options.includeActiveProcesses !== false;
|
||||
const activeProcesses = includeActiveProcesses ?
|
||||
processStore
|
||||
.listProcesses(context.paths)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
entry.alive &&
|
||||
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
|
||||
) :
|
||||
[];
|
||||
|
||||
const taskQueue = await taskBriefing(context, requestedMemberName);
|
||||
const completionNotifyExample = messagingProtocol.buildLeadMessageExample({
|
||||
|
|
|
|||
|
|
@ -717,6 +717,27 @@ describe('agent-teams-controller API', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('tracks owner assignment history without duplicate same-owner events', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Owner history' });
|
||||
|
||||
controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead');
|
||||
controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead');
|
||||
controller.tasks.setTaskOwner(task.id, 'alice', 'team-lead');
|
||||
controller.tasks.setTaskOwner(task.id, null, 'team-lead');
|
||||
|
||||
const ownerEvents = controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'owner_changed');
|
||||
|
||||
expect(ownerEvents).toHaveLength(3);
|
||||
expect(ownerEvents[0]).toMatchObject({ to: 'bob', actor: 'team-lead' });
|
||||
expect(ownerEvents[1]).toMatchObject({ from: 'bob', to: 'alice', actor: 'team-lead' });
|
||||
expect(ownerEvents[2]).toMatchObject({ from: 'alice', actor: 'team-lead' });
|
||||
expect(ownerEvents[2].to).toBeUndefined();
|
||||
});
|
||||
|
||||
it('wraps review instructions in the canonical agent block format used by the UI', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
4
mcp-server/src/agent-teams-controller.d.ts
vendored
4
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -31,7 +31,7 @@ declare module 'agent-teams-controller' {
|
|||
completeTask(taskId: string, actor?: string): unknown;
|
||||
softDeleteTask(taskId: string, actor?: string): unknown;
|
||||
restoreTask(taskId: string, actor?: string): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null, actor?: string): unknown;
|
||||
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
|
||||
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
|
|
@ -43,7 +43,7 @@ declare module 'agent-teams-controller' {
|
|||
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
memberBriefing(
|
||||
memberName: string,
|
||||
options?: { runtimeProvider?: 'native' | 'opencode' }
|
||||
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
|
||||
): Promise<string>;
|
||||
leadBriefing(): Promise<string>;
|
||||
taskBriefing(memberName: string): Promise<string>;
|
||||
|
|
|
|||
|
|
@ -413,18 +413,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
name: 'task_set_owner',
|
||||
description: 'Assign or clear task owner',
|
||||
description: 'Assign, reassign, or clear task owner',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
owner: z.string().nullable(),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) => {
|
||||
execute: async ({ teamName, claudeDir, taskId, owner, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
|
||||
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
|
@ -622,8 +623,15 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
||||
includeActiveProcesses: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => {
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
memberName,
|
||||
runtimeProvider,
|
||||
includeActiveProcesses,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
|
|
@ -631,6 +639,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
|
||||
...(runtimeProvider ? { runtimeProvider } : {}),
|
||||
...(includeActiveProcesses !== undefined ? { includeActiveProcesses } : {}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -601,15 +601,36 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id);
|
||||
|
||||
await getTool('task_set_owner').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
owner: null,
|
||||
actor: 'lead',
|
||||
});
|
||||
|
||||
const owned = parseJsonToolResult(
|
||||
await getTool('task_set_owner').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
owner: 'alice',
|
||||
actor: 'lead',
|
||||
})
|
||||
);
|
||||
expect(owned.owner).toBe('alice');
|
||||
const ownedFull = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
})
|
||||
);
|
||||
expect(ownedFull.historyEvents.at(-1)).toMatchObject({
|
||||
type: 'owner_changed',
|
||||
to: 'alice',
|
||||
actor: 'lead',
|
||||
});
|
||||
|
||||
const commented = parseJsonToolResult(
|
||||
await getTool('task_add_comment').execute({
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@
|
|||
"dev:web": "node ./scripts/dev-web.mjs",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||
"opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs",
|
||||
"opencode:prove-semantic-messaging": "node ./scripts/prove-opencode-semantic-messaging.mjs",
|
||||
"opencode:prove-semantic-model-matrix": "node ./scripts/prove-opencode-semantic-model-matrix.mjs",
|
||||
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
|
||||
"team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs",
|
||||
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
||||
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build",
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function drawTasks(
|
|||
ctx.globalAlpha = opacity;
|
||||
|
||||
if (simplify) {
|
||||
drawTaskPillLod(ctx, x, y, node, isSelected, isHovered);
|
||||
drawTaskPillLod(ctx, x, y, node, time, isSelected, isHovered);
|
||||
} else {
|
||||
drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
|
||||
}
|
||||
|
|
@ -145,6 +145,10 @@ function drawTaskPill(
|
|||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (node.hasLiveTaskLogs) {
|
||||
drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time);
|
||||
}
|
||||
|
||||
// Subject (main title — large)
|
||||
if (node.sublabel) {
|
||||
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
|
||||
|
|
@ -235,6 +239,7 @@ function drawTaskPillLod(
|
|||
x: number,
|
||||
y: number,
|
||||
node: GraphNode,
|
||||
time: number,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean
|
||||
): void {
|
||||
|
|
@ -276,6 +281,45 @@ function drawTaskPillLod(
|
|||
ctx.fill();
|
||||
}
|
||||
|
||||
if (node.hasLiveTaskLogs) {
|
||||
drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time, true);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawLiveTaskLogIndicator(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
time: number,
|
||||
compact = false
|
||||
): void {
|
||||
const coreRadius = compact ? 2.5 : 3.4;
|
||||
const glowRadius = compact ? 7 : 10;
|
||||
const pulse = 0.55 + 0.25 * Math.sin(time * 6);
|
||||
const color = COLORS.reviewApproved;
|
||||
|
||||
const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius);
|
||||
glow.addColorStop(0, hexWithAlpha(color, 0.35 + pulse * 0.28));
|
||||
glow.addColorStop(1, hexWithAlpha(color, 0));
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, glowRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = hexWithAlpha(color, 0.95);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, coreRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = hexWithAlpha(color, pulse);
|
||||
ctx.lineWidth = compact ? 0.8 : 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, coreRadius + (compact ? 1.2 : 1.8), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ export interface GraphNode {
|
|||
totalCommentCount?: number;
|
||||
/** Unread comment count on this task */
|
||||
unreadCommentCount?: number;
|
||||
/** Recent live log activity is arriving for this task */
|
||||
hasLiveTaskLogs?: boolean;
|
||||
/** Synthetic overflow stack node instead of hidden task tails */
|
||||
isOverflowStack?: boolean;
|
||||
/** Number of hidden tasks behind this overflow stack */
|
||||
|
|
|
|||
|
|
@ -283,6 +283,7 @@
|
|||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
|
|
@ -480,6 +481,7 @@
|
|||
"supports_vision": true,
|
||||
"supports_prompt_caching": false,
|
||||
"supports_reasoning": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"global.anthropic.claude-opus-4-7": {
|
||||
|
|
@ -627,6 +629,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -656,6 +659,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -685,6 +689,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -713,6 +718,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -741,6 +747,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -1018,6 +1025,7 @@
|
|||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
|
|
@ -1141,6 +1149,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -1711,6 +1720,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -1718,6 +1728,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -1853,6 +1864,7 @@
|
|||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
|
|
@ -1880,6 +1892,7 @@
|
|||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
|
|
@ -1901,6 +1914,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -1934,6 +1948,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -1967,6 +1982,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -2001,6 +2017,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -2144,6 +2161,7 @@
|
|||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4": {
|
||||
|
|
@ -2655,7 +2673,8 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 0.000025,
|
||||
"supports_function_calling": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"gmi/anthropic/claude-sonnet-4.5": {
|
||||
"input_cost_per_token": 0.000003,
|
||||
|
|
@ -3304,6 +3323,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 159,
|
||||
|
|
@ -3322,6 +3342,7 @@
|
|||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
|
|
@ -3343,6 +3364,7 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -3408,6 +3430,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
|
|
@ -3786,6 +3809,7 @@
|
|||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -3814,6 +3838,7 @@
|
|||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -3841,6 +3866,7 @@
|
|||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -4107,6 +4133,7 @@
|
|||
"output_cost_per_token": 0.000025,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -4446,6 +4473,7 @@
|
|||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -4472,6 +4500,7 @@
|
|||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_minimal_reasoning_effort": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -4638,6 +4667,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
@ -4778,6 +4808,7 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.18",
|
||||
"sourceRef": "v0.0.18",
|
||||
"version": "0.0.21",
|
||||
"sourceRef": "v0.0.21",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.18.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.21.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.18.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.21.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.18.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.21.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.18.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
43
scripts/prove-agent-cli-launch.mjs
Normal file
43
scripts/prove-agent-cli-launch.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
AGENT_CLI_LAUNCH_LIVE_E2E: '1',
|
||||
};
|
||||
|
||||
console.log('Running agent CLI launch live smoke');
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/utils/AgentCliLaunch.live-e2e.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run agent CLI launch smoke: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
73
scripts/prove-opencode-semantic-gauntlet.mjs
Normal file
73
scripts/prove-opencode-semantic-gauntlet.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_SEMANTIC_MODEL_GAUNTLET: '1',
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_E2E_GAUNTLET_RUNS: process.env.OPENCODE_E2E_GAUNTLET_RUNS?.trim() || '1',
|
||||
OPENCODE_E2E_GAUNTLET_MIN_AVERAGE_SCORE:
|
||||
process.env.OPENCODE_E2E_GAUNTLET_MIN_AVERAGE_SCORE?.trim() || '80',
|
||||
OPENCODE_E2E_GAUNTLET_MIN_SUCCESSFUL_RUNS:
|
||||
process.env.OPENCODE_E2E_GAUNTLET_MIN_SUCCESSFUL_RUNS?.trim() || '1',
|
||||
OPENCODE_E2E_GAUNTLET_MIN_CONSISTENCY_SCORE:
|
||||
process.env.OPENCODE_E2E_GAUNTLET_MIN_CONSISTENCY_SCORE?.trim() || '0',
|
||||
OPENCODE_E2E_GAUNTLET_REQUIRE_RECOMMENDED:
|
||||
process.env.OPENCODE_E2E_GAUNTLET_REQUIRE_RECOMMENDED?.trim() || '1',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode semantic gauntlet live smoke');
|
||||
console.log(`Models: ${env.OPENCODE_E2E_MODELS?.trim() || env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Runs per model: ${env.OPENCODE_E2E_GAUNTLET_RUNS}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode semantic gauntlet smoke: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
65
scripts/prove-opencode-semantic-messaging.mjs
Normal file
65
scripts/prove-opencode-semantic-messaging.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_SEMANTIC_MESSAGING: '1',
|
||||
OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode semantic messaging live smoke');
|
||||
console.log(`Model: ${env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/services/team/OpenCodeSemanticMessaging.live.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode semantic messaging smoke: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
63
scripts/prove-opencode-semantic-model-matrix.mjs
Normal file
63
scripts/prove-opencode-semantic-model-matrix.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
exitForSkippedPreflight,
|
||||
preflightOpenCodeLiveEnvironment,
|
||||
} from './lib/opencode-live-preflight.mjs';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_SEMANTIC_MODEL_MATRIX: '1',
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode semantic model matrix live smoke');
|
||||
console.log(`Models: ${env.OPENCODE_E2E_MODELS?.trim() || env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot });
|
||||
exitForSkippedPreflight(preflight);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'--maxWorkers',
|
||||
'1',
|
||||
'--minWorkers',
|
||||
'1',
|
||||
'test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode semantic model matrix smoke: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
|
|
@ -107,6 +107,7 @@ export function collapseOverflowStacksWithMeta(
|
|||
? 'has_changes'
|
||||
: undefined,
|
||||
isBlocked: hiddenTasks.some((task) => task.isBlocked),
|
||||
hasLiveTaskLogs: hiddenTasks.some((task) => task.hasLiveTaskLogs) ? true : undefined,
|
||||
isOverflowStack: true,
|
||||
overflowCount: hiddenTasks.length,
|
||||
overflowTaskIds,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,34 @@
|
|||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskDeleted,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
type TaskColumnInput = Pick<TeamTaskWithKanban, 'status' | 'reviewState' | 'kanbanColumn'>;
|
||||
type TaskColumnInput = Pick<
|
||||
TeamTaskWithKanban,
|
||||
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
|
||||
>;
|
||||
type TaskReviewerInput = Pick<TeamTaskWithKanban, 'reviewer' | 'reviewState' | 'kanbanColumn'>;
|
||||
type TaskBlockInput = Pick<TeamTask, 'blockedBy'>;
|
||||
type TaskBlockState = Pick<TeamTask, 'status'>;
|
||||
type TaskBlockState = Pick<
|
||||
TeamTaskWithKanban,
|
||||
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
|
||||
>;
|
||||
|
||||
export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId {
|
||||
if (task.reviewState === 'approved') return 'approved';
|
||||
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
|
||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
||||
return task.kanbanColumn;
|
||||
}
|
||||
const workflowColumn = getTeamTaskWorkflowColumn(task);
|
||||
if (workflowColumn) return workflowColumn;
|
||||
if (isTeamTaskNeedsFixActionable(task)) return 'review';
|
||||
if (task.status === 'in_progress') return 'in_progress';
|
||||
if (task.status === 'completed') return 'done';
|
||||
return 'todo';
|
||||
}
|
||||
|
||||
export function isTaskInReviewCycle(task: TaskColumnInput): boolean {
|
||||
return (
|
||||
task.reviewState === 'review' ||
|
||||
task.reviewState === 'needsFix' ||
|
||||
task.kanbanColumn === 'review'
|
||||
);
|
||||
return isTeamTaskNeedsFixActionable(task) || getTeamTaskWorkflowColumn(task) === 'review';
|
||||
}
|
||||
|
||||
export function resolveTaskReviewer(
|
||||
|
|
@ -43,6 +50,6 @@ export function isTaskBlocked(
|
|||
|
||||
return blockedBy.some((taskId) => {
|
||||
const blocker = taskStateById.get(taskId);
|
||||
return !blocker || (blocker.status !== 'completed' && blocker.status !== 'deleted');
|
||||
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import {
|
|||
} from '@shared/utils/idleNotificationSemantics';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||
|
||||
import {
|
||||
|
|
@ -41,6 +45,7 @@ import {
|
|||
import {
|
||||
isTaskBlocked,
|
||||
isTaskInReviewCycle,
|
||||
resolveTaskGraphColumn,
|
||||
resolveTaskReviewer,
|
||||
} from '../../core/domain/taskGraphSemantics';
|
||||
|
||||
|
|
@ -118,7 +123,8 @@ export class TeamGraphAdapter {
|
|||
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
||||
layoutMode: GraphLayoutMode = 'radial',
|
||||
gridOwnerOrder?: readonly string[]
|
||||
gridOwnerOrder?: readonly string[],
|
||||
activeTaskLogActivity?: Record<string, true>
|
||||
): GraphDataPort {
|
||||
if (teamData?.teamName !== teamName) {
|
||||
return TeamGraphAdapter.#emptyResult(teamName);
|
||||
|
|
@ -203,7 +209,8 @@ export class TeamGraphAdapter {
|
|||
commentReadState,
|
||||
memberNodeIdByAlias,
|
||||
leadId,
|
||||
leadName
|
||||
leadName,
|
||||
activeTaskLogActivity
|
||||
);
|
||||
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||
|
|
@ -539,8 +546,17 @@ export class TeamGraphAdapter {
|
|||
spawn,
|
||||
pendingApprovalAgents?.has(member.name) ?? false
|
||||
);
|
||||
const currentTask = member.currentTaskId
|
||||
? data.tasks.find((task) => task.id === member.currentTaskId)
|
||||
: undefined;
|
||||
const displayableCurrentTask =
|
||||
currentTask && isTeamTaskActivelyWorked(currentTask) ? currentTask : undefined;
|
||||
const presentationMember =
|
||||
member.currentTaskId && !displayableCurrentTask
|
||||
? { ...member, currentTaskId: null }
|
||||
: member;
|
||||
const launchPresentation = buildMemberLaunchPresentation({
|
||||
member,
|
||||
member: presentationMember,
|
||||
spawnStatus: spawn?.status,
|
||||
spawnLaunchState: spawn?.launchState,
|
||||
spawnLivenessSource: spawn?.livenessSource,
|
||||
|
|
@ -577,10 +593,8 @@ export class TeamGraphAdapter {
|
|||
? (launchPresentation.launchStatusLabel ?? undefined)
|
||||
: undefined,
|
||||
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
|
||||
currentTaskId: member.currentTaskId ?? undefined,
|
||||
currentTaskSubject: member.currentTaskId
|
||||
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
||||
: undefined,
|
||||
currentTaskId: displayableCurrentTask?.id,
|
||||
currentTaskSubject: displayableCurrentTask?.subject,
|
||||
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
||||
exceptionTone: exception?.exceptionTone,
|
||||
exceptionLabel: exception?.exceptionLabel,
|
||||
|
|
@ -627,14 +641,23 @@ export class TeamGraphAdapter {
|
|||
commentReadState?: Record<string, unknown>,
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||
leadId?: string,
|
||||
leadName?: string
|
||||
leadName?: string,
|
||||
activeTaskLogActivity?: Record<string, true>
|
||||
): void {
|
||||
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
||||
const taskStateById = new Map<
|
||||
string,
|
||||
Pick<TeamGraphData['tasks'][number], 'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'>
|
||||
>();
|
||||
const taskDisplayIds = new Map<string, string>();
|
||||
const memberColorByName = new Map<string, string>();
|
||||
|
||||
for (const t of data.tasks) {
|
||||
taskStateById.set(t.id, { status: t.status });
|
||||
taskStateById.set(t.id, {
|
||||
status: t.status,
|
||||
...(t.reviewState ? { reviewState: t.reviewState } : {}),
|
||||
...(t.kanbanColumn ? { kanbanColumn: t.kanbanColumn } : {}),
|
||||
...(t.deletedAt ? { deletedAt: t.deletedAt } : {}),
|
||||
});
|
||||
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
||||
}
|
||||
for (const member of data.members) {
|
||||
|
|
@ -657,9 +680,19 @@ export class TeamGraphAdapter {
|
|||
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||
const isReviewCycle = isTaskInReviewCycle(task);
|
||||
|
||||
const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
|
||||
const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState);
|
||||
const graphColumn = resolveTaskGraphColumn(task);
|
||||
const taskStatus =
|
||||
graphColumn === 'approved'
|
||||
? 'completed'
|
||||
: TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
|
||||
const reviewState =
|
||||
graphColumn === 'approved'
|
||||
? 'approved'
|
||||
: graphColumn === 'review'
|
||||
? isTeamTaskNeedsFixActionable(task)
|
||||
? 'needsFix'
|
||||
: 'review'
|
||||
: TeamGraphAdapter.#mapReviewState(task.reviewState);
|
||||
|
||||
const blockedByDisplayIds = task.blockedBy?.length
|
||||
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||
|
|
@ -683,7 +716,8 @@ export class TeamGraphAdapter {
|
|||
kind: 'task',
|
||||
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
|
||||
sublabel: task.subject,
|
||||
state: TeamGraphAdapter.#mapTaskStatus(task.status),
|
||||
state:
|
||||
graphColumn === 'approved' ? 'complete' : TeamGraphAdapter.#mapTaskStatus(task.status),
|
||||
taskStatus,
|
||||
reviewState,
|
||||
reviewerName: isReviewCycle ? reviewerName : null,
|
||||
|
|
@ -698,6 +732,7 @@ export class TeamGraphAdapter {
|
|||
blocksDisplayIds,
|
||||
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
||||
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
||||
hasLiveTaskLogs: activeTaskLogActivity?.[task.id] === true ? true : undefined,
|
||||
domainRef: { kind: 'task', teamName, taskId: task.id },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export function useTeamGraphAdapter(
|
|||
gridOwnerOrder,
|
||||
slotAssignments,
|
||||
graphLayoutSession,
|
||||
activeTaskLogActivity,
|
||||
ensureTeamGraphSlotAssignments,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -92,6 +93,8 @@ export function useTeamGraphAdapter(
|
|||
gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
|
||||
slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
||||
graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
||||
activeTaskLogActivity:
|
||||
isActive && teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
|
||||
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
||||
}))
|
||||
);
|
||||
|
|
@ -189,7 +192,8 @@ export function useTeamGraphAdapter(
|
|||
memberSpawnSnapshot,
|
||||
effectiveSlotAssignments,
|
||||
graphLayoutMode ?? 'radial',
|
||||
gridOwnerOrder
|
||||
gridOwnerOrder,
|
||||
activeTaskLogActivity
|
||||
);
|
||||
}, [
|
||||
isActive,
|
||||
|
|
@ -208,6 +212,7 @@ export function useTeamGraphAdapter(
|
|||
effectiveSlotAssignments,
|
||||
graphLayoutMode,
|
||||
gridOwnerOrder,
|
||||
activeTaskLogActivity,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
buildMemberAvatarMap,
|
||||
buildMemberLaunchPresentation,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||
|
||||
|
|
@ -309,6 +310,17 @@ const MemberPopoverContent = ({
|
|||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
|
||||
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const currentTaskCandidate =
|
||||
member?.currentTaskId && teamData
|
||||
? (teamData.tasks.find((task) => task.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate)
|
||||
? currentTaskCandidate
|
||||
: null;
|
||||
const currentTaskIndicatorId =
|
||||
displayableCurrentTask?.id ?? (!teamData ? node.currentTaskId : undefined);
|
||||
const currentTaskIndicatorSubject =
|
||||
displayableCurrentTask?.subject ?? (!teamData ? node.currentTaskSubject : undefined);
|
||||
const provisioningPresentation =
|
||||
teamData && teamName
|
||||
? buildTeamProvisioningPresentation({
|
||||
|
|
@ -320,7 +332,10 @@ const MemberPopoverContent = ({
|
|||
: null;
|
||||
const launchPresentation = member
|
||||
? buildMemberLaunchPresentation({
|
||||
member,
|
||||
member:
|
||||
member.currentTaskId && !displayableCurrentTask
|
||||
? { ...member, currentTaskId: null }
|
||||
: member,
|
||||
spawnStatus: spawnEntry?.status,
|
||||
spawnLaunchState: spawnEntry?.launchState,
|
||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||
|
|
@ -444,7 +459,7 @@ const MemberPopoverContent = ({
|
|||
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
|
||||
|
||||
{/* Current task indicator — reuses same pattern as MemberCard */}
|
||||
{node.currentTaskId && node.currentTaskSubject && (
|
||||
{currentTaskIndicatorId && currentTaskIndicatorSubject && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[10px]">
|
||||
<Loader2
|
||||
className="size-3 shrink-0 animate-spin"
|
||||
|
|
@ -457,13 +472,13 @@ const MemberPopoverContent = ({
|
|||
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTask?.(node.currentTaskId!);
|
||||
onOpenTask?.(currentTaskIndicatorId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{node.currentTaskSubject.length > 30
|
||||
? `${node.currentTaskSubject.slice(0, 30)}…`
|
||||
: node.currentTaskSubject}
|
||||
{currentTaskIndicatorSubject.length > 30
|
||||
? `${currentTaskIndicatorSubject.slice(0, 30)}…`
|
||||
: currentTaskIndicatorSubject}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
||||
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
|
||||
|
||||
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
|
||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||
|
|
@ -49,7 +50,7 @@ function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap<string, TeamTask>): R
|
|||
boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)',
|
||||
};
|
||||
case 'review':
|
||||
return task.reviewState === 'needsFix'
|
||||
return isTeamTaskNeedsFixActionable(task)
|
||||
? { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' }
|
||||
: { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' };
|
||||
case 'approved':
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import type { MemberWorkSyncStatus, MemberWorkSyncTeamMetrics } from '../../contracts';
|
||||
|
||||
export type MemberWorkSyncNudgeActivationReason =
|
||||
| 'shadow_ready'
|
||||
| 'opencode_targeted_shadow_collecting'
|
||||
| 'status_not_nudgeable'
|
||||
| 'blocking_metrics'
|
||||
| 'phase2_not_ready';
|
||||
|
||||
export interface MemberWorkSyncNudgeActivationDecision {
|
||||
active: boolean;
|
||||
reason: MemberWorkSyncNudgeActivationReason;
|
||||
}
|
||||
|
||||
const BLOCKING_PHASE2_REASONS = new Set([
|
||||
'would_nudge_rate_high',
|
||||
'fingerprint_churn_high',
|
||||
'report_rejection_rate_high',
|
||||
]);
|
||||
|
||||
function hasBlockingMetrics(metrics: MemberWorkSyncTeamMetrics): boolean {
|
||||
return metrics.phase2Readiness.reasons.some((reason) => BLOCKING_PHASE2_REASONS.has(reason));
|
||||
}
|
||||
|
||||
function isOpenCodeTargetedCandidate(status: MemberWorkSyncStatus): boolean {
|
||||
return (
|
||||
status.providerId === 'opencode' &&
|
||||
status.state === 'needs_sync' &&
|
||||
status.agenda.items.length > 0 &&
|
||||
status.shadow?.wouldNudge === true
|
||||
);
|
||||
}
|
||||
|
||||
export function decideMemberWorkSyncNudgeActivation(input: {
|
||||
status: MemberWorkSyncStatus;
|
||||
metrics: MemberWorkSyncTeamMetrics;
|
||||
}): MemberWorkSyncNudgeActivationDecision {
|
||||
if (input.status.state !== 'needs_sync' || input.status.agenda.items.length === 0) {
|
||||
return { active: false, reason: 'status_not_nudgeable' };
|
||||
}
|
||||
|
||||
if (hasBlockingMetrics(input.metrics)) {
|
||||
return { active: false, reason: 'blocking_metrics' };
|
||||
}
|
||||
|
||||
if (input.metrics.phase2Readiness.state === 'shadow_ready') {
|
||||
return { active: true, reason: 'shadow_ready' };
|
||||
}
|
||||
|
||||
if (
|
||||
input.metrics.phase2Readiness.state === 'collecting_shadow_data' &&
|
||||
isOpenCodeTargetedCandidate(input.status)
|
||||
) {
|
||||
return { active: true, reason: 'opencode_targeted_shadow_collecting' };
|
||||
}
|
||||
|
||||
return { active: false, reason: 'phase2_not_ready' };
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
|
||||
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||
import { decideMemberWorkSyncStatus } from '../domain';
|
||||
|
||||
import type { MemberWorkSyncOutboxItem } from '../../contracts';
|
||||
import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts';
|
||||
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
||||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||
|
|
@ -151,6 +152,12 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
nowIso,
|
||||
});
|
||||
await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted');
|
||||
await this.scheduleDeliveryWake(
|
||||
item,
|
||||
inserted.messageId,
|
||||
inserted.inserted,
|
||||
revalidation.providerId
|
||||
);
|
||||
return 'delivered';
|
||||
} catch (error) {
|
||||
await outbox.markFailed({
|
||||
|
|
@ -188,7 +195,8 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
item: MemberWorkSyncOutboxItem,
|
||||
nowIso: string
|
||||
): Promise<
|
||||
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
|
||||
| { ok: true; providerId?: MemberWorkSyncStatus['providerId'] }
|
||||
| { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
|
||||
> {
|
||||
const teamActive = this.deps.lifecycle
|
||||
? await this.deps.lifecycle.isTeamActive(item.teamName)
|
||||
|
|
@ -221,6 +229,24 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
nowIso,
|
||||
inactive: source.inactive || !teamActive,
|
||||
});
|
||||
const providerId = source.providerId ?? previous.providerId;
|
||||
const revalidatedStatus: MemberWorkSyncStatus = {
|
||||
...previous,
|
||||
state: decision.state,
|
||||
agenda,
|
||||
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
|
||||
shadow: {
|
||||
...previous.shadow,
|
||||
reconciledBy: previous.shadow?.reconciledBy ?? 'queue',
|
||||
wouldNudge: decision.state === 'needs_sync' && agenda.items.length > 0,
|
||||
fingerprintChanged:
|
||||
Boolean(previous.agenda.fingerprint) &&
|
||||
previous.agenda.fingerprint !== agenda.fingerprint,
|
||||
},
|
||||
evaluatedAt: nowIso,
|
||||
diagnostics: [...agenda.diagnostics, ...decision.diagnostics],
|
||||
...(providerId ? { providerId } : {}),
|
||||
};
|
||||
if (
|
||||
decision.state !== 'needs_sync' ||
|
||||
agenda.items.length === 0 ||
|
||||
|
|
@ -233,7 +259,11 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
return { ok: false, reason: 'metrics_unavailable', retryable: true };
|
||||
}
|
||||
const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName);
|
||||
if (metrics.phase2Readiness.state !== 'shadow_ready') {
|
||||
const activation = decideMemberWorkSyncNudgeActivation({
|
||||
status: revalidatedStatus,
|
||||
metrics,
|
||||
});
|
||||
if (!activation.active) {
|
||||
return { ok: false, reason: 'phase2_not_ready', retryable: true };
|
||||
}
|
||||
|
||||
|
|
@ -281,6 +311,37 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
return { ok: false, reason: 'watchdog_cooldown_active', retryable: true };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
return { ok: true, ...(providerId ? { providerId } : {}) };
|
||||
}
|
||||
|
||||
private async scheduleDeliveryWake(
|
||||
item: MemberWorkSyncOutboxItem,
|
||||
messageId: string,
|
||||
inserted: boolean,
|
||||
providerId?: MemberWorkSyncStatus['providerId']
|
||||
): Promise<void> {
|
||||
if (!this.deps.nudgeDeliveryWake) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.deps.nudgeDeliveryWake.schedule({
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
messageId,
|
||||
...(providerId ? { providerId } : {}),
|
||||
reason: inserted ? 'member_work_sync_nudge_inserted' : 'member_work_sync_nudge_existing',
|
||||
delayMs: 500,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = `nudge_wake_failed:${String(error)}`;
|
||||
await this.appendDispatchAudit(item, 'nudge_wake_failed', reason);
|
||||
this.deps.logger?.warn('member work sync nudge delivery wake failed', {
|
||||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
messageId,
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
|
||||
|
||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||
|
|
@ -38,7 +39,8 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
|||
}
|
||||
|
||||
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
|
||||
if (metrics.phase2Readiness.state !== 'shadow_ready') {
|
||||
const activation = decideMemberWorkSyncNudgeActivation({ status, metrics });
|
||||
if (!activation.active) {
|
||||
await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' });
|
||||
return { planned: false, code: 'phase2_not_ready' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export * from './MemberWorkSyncAudit';
|
||||
export * from './MemberWorkSyncDiagnosticsReader';
|
||||
export * from './MemberWorkSyncMetricsReader';
|
||||
export * from './MemberWorkSyncNudgeActivationPolicy';
|
||||
export * from './MemberWorkSyncNudgeDispatcher';
|
||||
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
||||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export type MemberWorkSyncAuditEventName =
|
|||
| 'report_rejected'
|
||||
| 'nudge_planned'
|
||||
| 'nudge_delivered'
|
||||
| 'nudge_wake_failed'
|
||||
| 'nudge_skipped'
|
||||
| 'nudge_retryable'
|
||||
| 'nudge_superseded'
|
||||
|
|
@ -181,6 +182,17 @@ export interface MemberWorkSyncBusySignalPort {
|
|||
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncNudgeDeliveryWakePort {
|
||||
schedule(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
messageId: string;
|
||||
providerId?: MemberWorkSyncProviderId | null;
|
||||
reason: 'member_work_sync_nudge_inserted' | 'member_work_sync_nudge_existing';
|
||||
delayMs?: number;
|
||||
}): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncUseCaseDeps {
|
||||
clock: MemberWorkSyncClockPort;
|
||||
hash: MemberWorkSyncHashPort;
|
||||
|
|
@ -191,6 +203,7 @@ export interface MemberWorkSyncUseCaseDeps {
|
|||
inboxNudge?: MemberWorkSyncInboxNudgePort;
|
||||
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
|
||||
busySignal?: MemberWorkSyncBusySignalPort;
|
||||
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
|
||||
reportToken?: MemberWorkSyncReportTokenPort;
|
||||
auditJournal?: MemberWorkSyncAuditJournalPort;
|
||||
lifecycle?: MemberWorkSyncLifecyclePort;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
isTeamTaskTerminalForActionableWork,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
import {
|
||||
buildAgendaFingerprintPayload,
|
||||
canonicalizeAgendaFingerprintPayload,
|
||||
|
|
@ -19,6 +26,7 @@ export interface MemberWorkSyncTaskLike {
|
|||
status: string;
|
||||
owner?: string | null;
|
||||
reviewState?: string | null;
|
||||
kanbanColumn?: string | null;
|
||||
needsClarification?: 'lead' | 'user' | null;
|
||||
blockedBy?: string[];
|
||||
blocks?: string[];
|
||||
|
|
@ -45,10 +53,6 @@ export interface BuildActionableWorkAgendaInput {
|
|||
hash: (canonicalPayload: string) => string;
|
||||
}
|
||||
|
||||
function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean {
|
||||
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
|
||||
}
|
||||
|
||||
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
|
||||
return new Set(
|
||||
members
|
||||
|
|
@ -114,7 +118,9 @@ export function buildActionableWorkAgenda(
|
|||
|
||||
if (activeMemberNames.has(memberName)) {
|
||||
for (const task of input.tasks) {
|
||||
if (!task.id || isCompletedOrDeleted(task)) {
|
||||
const workflowColumn = getTeamTaskWorkflowColumn(task);
|
||||
const isReviewWorkflow = workflowColumn === 'review';
|
||||
if (!task.id || (isTeamTaskTerminalForActionableWork(task) && !isReviewWorkflow)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +134,7 @@ export function buildActionableWorkAgenda(
|
|||
const dependency = tasksByReference.get(dependencyId) ?? null;
|
||||
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
||||
brokenDependencyIds.push(dependencyId);
|
||||
} else if (dependency.status !== 'completed') {
|
||||
} else if (!isTeamTaskFinishedForDependency(dependency)) {
|
||||
waitingDependencyIds.push(dependencyId);
|
||||
}
|
||||
}
|
||||
|
|
@ -174,11 +180,13 @@ export function buildActionableWorkAgenda(
|
|||
continue;
|
||||
}
|
||||
|
||||
const reviewOwner = resolveCurrentReviewOwner({
|
||||
reviewState: task.reviewState,
|
||||
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
|
||||
historyEvents: task.historyEvents,
|
||||
});
|
||||
const reviewOwner = isReviewWorkflow
|
||||
? resolveCurrentReviewOwner({
|
||||
reviewState: workflowColumn,
|
||||
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
|
||||
historyEvents: task.historyEvents,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
|
||||
items.push({
|
||||
|
|
@ -199,6 +207,10 @@ export function buildActionableWorkAgenda(
|
|||
continue;
|
||||
}
|
||||
|
||||
if (isReviewWorkflow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sameMemberName(owner, memberName)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -214,18 +226,17 @@ export function buildActionableWorkAgenda(
|
|||
if (
|
||||
task.status === 'pending' ||
|
||||
task.status === 'in_progress' ||
|
||||
task.reviewState === 'needsFix'
|
||||
isTeamTaskNeedsFixActionable(task)
|
||||
) {
|
||||
items.push({
|
||||
...base,
|
||||
kind: 'work',
|
||||
priority: 'normal',
|
||||
reason:
|
||||
task.reviewState === 'needsFix'
|
||||
? 'review_changes_requested'
|
||||
: task.status === 'pending'
|
||||
? 'owned_pending_task'
|
||||
: 'owned_in_progress_task',
|
||||
reason: isTeamTaskNeedsFixActionable(task)
|
||||
? 'review_changes_requested'
|
||||
: task.status === 'pending'
|
||||
? 'owned_pending_task'
|
||||
: 'owned_in_progress_task',
|
||||
evidence: {
|
||||
status: task.status,
|
||||
owner: memberName,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export interface ReviewHistoryEventLike {
|
|||
timestamp?: string;
|
||||
actor?: string;
|
||||
reviewer?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface CurrentReviewOwner {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskTerminalForActionableWork,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
|
||||
|
||||
|
|
@ -20,10 +24,6 @@ export interface MemberWorkSyncTaskImpactResolverResult {
|
|||
diagnostics: string[];
|
||||
}
|
||||
|
||||
function isTerminalTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
||||
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
|
||||
}
|
||||
|
||||
function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
||||
return task.status === 'deleted' || Boolean(task.deletedAt);
|
||||
}
|
||||
|
|
@ -127,13 +127,6 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
|
||||
addMember(task.owner);
|
||||
|
||||
const reviewOwner = resolveCurrentReviewOwner({
|
||||
reviewState: task.reviewState,
|
||||
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
|
||||
historyEvents: task.historyEvents,
|
||||
});
|
||||
addMember(reviewOwner?.reviewer);
|
||||
|
||||
if (!normalizeMemberName(task.owner)) {
|
||||
addLead();
|
||||
addDiagnostic('task_owner_missing');
|
||||
|
|
@ -142,7 +135,23 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
addDiagnostic('task_owner_inactive');
|
||||
}
|
||||
|
||||
if (task.reviewState === 'review' && !reviewOwner?.reviewer) {
|
||||
const taskKanbanColumn = kanban.tasks[task.id]?.column;
|
||||
const taskWorkflowColumn = getTeamTaskWorkflowColumn({
|
||||
...task,
|
||||
...(taskKanbanColumn ? { kanbanColumn: taskKanbanColumn } : {}),
|
||||
});
|
||||
|
||||
const reviewOwner =
|
||||
taskWorkflowColumn === 'review'
|
||||
? resolveCurrentReviewOwner({
|
||||
reviewState: task.reviewState,
|
||||
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
|
||||
historyEvents: task.historyEvents,
|
||||
})
|
||||
: null;
|
||||
addMember(reviewOwner?.reviewer);
|
||||
|
||||
if (taskWorkflowColumn === 'review' && !reviewOwner?.reviewer) {
|
||||
addLead();
|
||||
addDiagnostic('task_reviewer_missing');
|
||||
}
|
||||
|
|
@ -166,7 +175,14 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
}
|
||||
|
||||
for (const candidate of tasks) {
|
||||
if (candidate.id === task.id || isTerminalTask(candidate)) {
|
||||
const kanbanColumn = kanban.tasks[candidate.id]?.column;
|
||||
if (
|
||||
candidate.id === task.id ||
|
||||
isTeamTaskTerminalForActionableWork({
|
||||
...candidate,
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -122,7 +122,13 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
|
|||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
generatedAt: this.deps.clock.now().toISOString(),
|
||||
tasks,
|
||||
tasks: tasks.map((task) => {
|
||||
const kanbanColumn = kanban.tasks[task.id]?.column;
|
||||
return {
|
||||
...task,
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
};
|
||||
}),
|
||||
members: members.map(toMemberLike),
|
||||
kanbanReviewersByTaskId: Object.fromEntries(
|
||||
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
|||
import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown';
|
||||
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
||||
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
|
||||
import { CompositeMemberWorkSyncBusySignal } from '../infrastructure/CompositeMemberWorkSyncBusySignal';
|
||||
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
|
||||
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
|
||||
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
||||
|
|
@ -46,7 +47,11 @@ import type {
|
|||
MemberWorkSyncStatusRequest,
|
||||
MemberWorkSyncTeamMetrics,
|
||||
} from '../../contracts';
|
||||
import type { MemberWorkSyncLoggerPort } from '../../core/application';
|
||||
import type {
|
||||
MemberWorkSyncBusySignalPort,
|
||||
MemberWorkSyncLoggerPort,
|
||||
MemberWorkSyncNudgeDeliveryWakePort,
|
||||
} from '../../core/application';
|
||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||
|
|
@ -93,6 +98,8 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
||||
queueQuietWindowMs?: number;
|
||||
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
|
||||
extraBusySignals?: MemberWorkSyncBusySignalPort[];
|
||||
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
|
||||
logger?: MemberWorkSyncLoggerPort;
|
||||
}): MemberWorkSyncFeatureFacade {
|
||||
const clock = new SystemClockAdapter();
|
||||
|
|
@ -138,7 +145,12 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
});
|
||||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
|
||||
const busySignal = new MemberWorkSyncToolActivityBusySignal();
|
||||
const toolActivityBusySignal = new MemberWorkSyncToolActivityBusySignal();
|
||||
const busySignals = [toolActivityBusySignal, ...(deps.extraBusySignals ?? [])];
|
||||
const busySignal =
|
||||
busySignals.length === 1
|
||||
? toolActivityBusySignal
|
||||
: new CompositeMemberWorkSyncBusySignal(busySignals, deps.logger);
|
||||
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
|
||||
const useCaseDeps = {
|
||||
clock,
|
||||
|
|
@ -150,6 +162,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
inboxNudge,
|
||||
watchdogCooldown,
|
||||
busySignal,
|
||||
...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}),
|
||||
reportToken,
|
||||
auditJournal,
|
||||
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
||||
|
|
@ -233,7 +246,7 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
getMetrics: (request) => metricsReader.execute(request),
|
||||
report: (request) => reporter.execute(request),
|
||||
noteTeamChange: (event) => {
|
||||
busySignal.noteTeamChange(event);
|
||||
toolActivityBusySignal.noteTeamChange(event);
|
||||
router.noteTeamChange(event);
|
||||
},
|
||||
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import type {
|
||||
MemberWorkSyncBusySignalPort,
|
||||
MemberWorkSyncLoggerPort,
|
||||
} from '../../core/application';
|
||||
|
||||
export class CompositeMemberWorkSyncBusySignal implements MemberWorkSyncBusySignalPort {
|
||||
constructor(
|
||||
private readonly signals: MemberWorkSyncBusySignalPort[],
|
||||
private readonly logger?: MemberWorkSyncLoggerPort
|
||||
) {}
|
||||
|
||||
async isBusy(input: Parameters<MemberWorkSyncBusySignalPort['isBusy']>[0]) {
|
||||
for (const signal of this.signals) {
|
||||
try {
|
||||
const result = await signal.isBusy(input);
|
||||
if (result.busy) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.warn('member work sync busy signal failed', {
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
error: String(error),
|
||||
});
|
||||
const nowMs = Date.parse(input.nowIso);
|
||||
return {
|
||||
busy: true,
|
||||
reason: 'busy_signal_error',
|
||||
retryAfterIso: new Date(
|
||||
(Number.isFinite(nowMs) ? nowMs : Date.now()) + 60_000
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { busy: false };
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@ import type {
|
|||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatusEntry,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
OpenCodeBootstrapMode,
|
||||
PersistedTeamLaunchMemberSources,
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchPhase,
|
||||
|
|
@ -43,6 +46,9 @@ export interface MixedSecondaryLaneMemberStateInput {
|
|||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
sessionId?: string;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
|
|
@ -348,6 +354,9 @@ function createSecondaryLaneMemberState(
|
|||
? Math.trunc(evidence.runtimePid)
|
||||
: undefined,
|
||||
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
|
||||
bootstrapEvidenceSource: evidence?.bootstrapEvidenceSource,
|
||||
bootstrapMode: evidence?.bootstrapMode,
|
||||
appManagedBootstrapCandidate: evidence?.appManagedBootstrapCandidate,
|
||||
livenessKind: evidence?.livenessKind,
|
||||
pidSource: evidence?.pidSource,
|
||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import {
|
|||
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
||||
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
|
@ -470,6 +471,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
const msg = newMessages[i];
|
||||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs,
|
||||
// not user-visible conversation messages.
|
||||
if (isTeamInternalControlMessageEnvelope(msg)) continue;
|
||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
||||
|
||||
|
|
@ -1140,6 +1144,9 @@ async function initializeServices(): Promise<void> {
|
|||
teamDataService = new TeamDataService();
|
||||
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
|
||||
teamProvisioningService = new TeamProvisioningService();
|
||||
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
|
||||
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
|
||||
});
|
||||
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
|
||||
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
|
||||
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
|
||||
|
|
@ -1358,6 +1365,24 @@ async function initializeServices(): Promise<void> {
|
|||
)
|
||||
.map((team) => team.teamName);
|
||||
},
|
||||
extraBusySignals: [
|
||||
{
|
||||
isBusy: (input) => teamProvisioningService.getOpenCodeMemberDeliveryBusyStatus(input),
|
||||
},
|
||||
],
|
||||
nudgeDeliveryWake: {
|
||||
schedule: (input) => {
|
||||
if (input.providerId !== 'opencode') {
|
||||
return;
|
||||
}
|
||||
teamProvisioningService.scheduleOpenCodeMemberInboxDeliveryWake({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
messageId: input.messageId,
|
||||
delayMs: input.delayMs,
|
||||
});
|
||||
},
|
||||
},
|
||||
logger: createLogger('Feature:MemberWorkSync'),
|
||||
});
|
||||
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
||||
|
|
|
|||
|
|
@ -826,6 +826,9 @@ export class NotificationManager extends EventEmitter {
|
|||
.catch(() => undefined)
|
||||
.then(() => writeNotificationsFileAtomically(notificationsPath, data))
|
||||
.catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
logger.error('Error saving notifications:', error);
|
||||
});
|
||||
}
|
||||
|
|
@ -1031,7 +1034,7 @@ export class NotificationManager extends EventEmitter {
|
|||
): void {
|
||||
const NotificationClass = getNotificationClass();
|
||||
if (!NotificationClass || !this.isNativeNotificationSupported()) {
|
||||
logger.warn('[team-toast] native notifications not supported — skipping');
|
||||
logger.debug('[team-toast] native notifications not supported - skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState';
|
||||
import { getReviewStateFromTask } from '@shared/utils/reviewState';
|
||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
|
|
@ -62,6 +62,7 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio
|
|||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||
import { getTeamTaskWorkflowColumn, selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
||||
|
||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||
|
|
@ -550,13 +551,7 @@ export class TeamDataService {
|
|||
const launchIdentity = teamMeta?.launchIdentity;
|
||||
const leadName = 'team-lead';
|
||||
const ownedTasks = tasks.filter((task) => task.owner === leadName);
|
||||
const currentTask =
|
||||
ownedTasks.find(
|
||||
(task) =>
|
||||
task.status === 'in_progress' &&
|
||||
task.reviewState !== 'approved' &&
|
||||
task.kanbanColumn !== 'approved'
|
||||
) ?? null;
|
||||
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
|
||||
|
||||
members.unshift({
|
||||
name: leadName,
|
||||
|
|
@ -600,12 +595,35 @@ export class TeamDataService {
|
|||
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
||||
kanbanTaskState?: KanbanState['tasks'][string]
|
||||
): 'none' | 'review' | 'needsFix' | 'approved' {
|
||||
return getReviewStateFromTask({
|
||||
const kanbanColumn = kanbanTaskState?.column;
|
||||
const kanbanWorkflowColumn = kanbanColumn
|
||||
? getTeamTaskWorkflowColumn({
|
||||
status: task.status,
|
||||
reviewState: 'none',
|
||||
kanbanColumn,
|
||||
})
|
||||
: undefined;
|
||||
if (kanbanWorkflowColumn) {
|
||||
return kanbanWorkflowColumn;
|
||||
}
|
||||
|
||||
const reviewState = getReviewStateFromTask({
|
||||
historyEvents: task.historyEvents,
|
||||
reviewState: task.reviewState,
|
||||
status: task.status,
|
||||
kanbanColumn: kanbanTaskState?.column,
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
});
|
||||
const workflowColumn = getTeamTaskWorkflowColumn({
|
||||
status: task.status,
|
||||
reviewState,
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
});
|
||||
|
||||
if (workflowColumn) {
|
||||
return workflowColumn;
|
||||
}
|
||||
|
||||
return reviewState;
|
||||
}
|
||||
|
||||
private attachKanbanCompatibility(
|
||||
|
|
@ -614,14 +632,27 @@ export class TeamDataService {
|
|||
): TeamTaskWithKanban {
|
||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
||||
const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState);
|
||||
return {
|
||||
...task,
|
||||
reviewState,
|
||||
kanbanColumn: getKanbanColumnFromReviewState(reviewState),
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
reviewer,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveTaskKanbanColumn(
|
||||
task: Pick<TeamTask, 'status'>,
|
||||
kanbanTaskState?: KanbanState['tasks'][string],
|
||||
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = 'none'
|
||||
): 'review' | 'approved' | undefined {
|
||||
return getTeamTaskWorkflowColumn({
|
||||
status: task.status,
|
||||
reviewState,
|
||||
...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract reviewer name from the current review cycle history.
|
||||
* For legacy boards that stored reviewer only in kanban state, preserve that
|
||||
|
|
@ -1023,7 +1054,7 @@ export class TeamDataService {
|
|||
const info = teamInfoMap.get(task.teamName)!;
|
||||
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
|
||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
||||
const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState);
|
||||
|
||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||
// Return a "light" task object and defer heavy details to team/task detail views.
|
||||
|
|
@ -2137,7 +2168,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||
this.getController(teamName).tasks.setTaskOwner(taskId, owner);
|
||||
this.getController(teamName).tasks.setTaskOwner(taskId, owner, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatusEntry,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
PersistedTeamLaunchMemberSources,
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchPhase,
|
||||
|
|
@ -176,6 +177,60 @@ function normalizeOptionalString(value: unknown): string | undefined {
|
|||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeOpenCodeAppManagedBootstrapCandidate(
|
||||
value: unknown
|
||||
): OpenCodeAppManagedBootstrapCandidate | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.schemaVersion !== 1 || record.source !== 'app_managed_bootstrap') {
|
||||
return undefined;
|
||||
}
|
||||
const teamName = normalizeOptionalString(record.teamName);
|
||||
const memberName = normalizeOptionalString(record.memberName);
|
||||
const runId = normalizeOptionalString(record.runId);
|
||||
const laneId = normalizeOptionalString(record.laneId);
|
||||
const runtimeSessionId = normalizeOptionalString(record.runtimeSessionId);
|
||||
const messageID = normalizeOptionalString(record.messageID);
|
||||
const contextHash = normalizeOptionalString(record.contextHash);
|
||||
const briefingHash = normalizeOptionalString(record.briefingHash);
|
||||
const injectionVerifiedAt = normalizeOptionalString(record.injectionVerifiedAt);
|
||||
const candidateAt = normalizeOptionalString(record.candidateAt);
|
||||
if (
|
||||
!teamName ||
|
||||
!memberName ||
|
||||
!runId ||
|
||||
!laneId ||
|
||||
!runtimeSessionId ||
|
||||
!messageID ||
|
||||
!contextHash ||
|
||||
!briefingHash ||
|
||||
!injectionVerifiedAt ||
|
||||
!candidateAt
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const model = normalizeOptionalString(record.model);
|
||||
const agent = normalizeOptionalString(record.agent);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'app_managed_bootstrap',
|
||||
teamName,
|
||||
memberName,
|
||||
runId,
|
||||
laneId,
|
||||
runtimeSessionId,
|
||||
messageID,
|
||||
contextHash,
|
||||
briefingHash,
|
||||
injectionVerifiedAt,
|
||||
candidateAt,
|
||||
...(model ? { model } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeJsonStringLiteral(value: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${value}"`) as string;
|
||||
|
|
@ -601,6 +656,19 @@ function normalizePersistedMemberState(
|
|||
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
||||
runtimeRunId: normalizeOptionalString(parsed.runtimeRunId),
|
||||
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
|
||||
bootstrapEvidenceSource:
|
||||
parsed.bootstrapEvidenceSource === 'runtime_bootstrap_checkin' ||
|
||||
parsed.bootstrapEvidenceSource === 'app_managed_bootstrap'
|
||||
? parsed.bootstrapEvidenceSource
|
||||
: undefined,
|
||||
bootstrapMode:
|
||||
parsed.bootstrapMode === 'model_tool_checkin' ||
|
||||
parsed.bootstrapMode === 'app_managed_context'
|
||||
? parsed.bootstrapMode
|
||||
: undefined,
|
||||
appManagedBootstrapCandidate: normalizeOpenCodeAppManagedBootstrapCandidate(
|
||||
parsed.appManagedBootstrapCandidate
|
||||
),
|
||||
livenessKind,
|
||||
pidSource: normalizePidSource(parsed.pidSource),
|
||||
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
|
||||
|
|
|
|||
|
|
@ -69,10 +69,22 @@ type DecodedFreshnessTaskId =
|
|||
| { kind: 'opaque-safe-segment' }
|
||||
| { kind: 'invalid' };
|
||||
|
||||
type TaskFreshnessSignalKind = NonNullable<TeamChangeEvent['taskSignalKind']>;
|
||||
|
||||
function isOpaqueSafeTaskIdSegment(segment: string): boolean {
|
||||
return /^task-id-[0-9a-f]{32}$/.test(segment);
|
||||
}
|
||||
|
||||
function pushUniqueNormalizedPath(paths: string[], candidate: string | undefined): void {
|
||||
if (!candidate || !path.isAbsolute(candidate)) {
|
||||
return;
|
||||
}
|
||||
const normalized = path.normalize(candidate);
|
||||
if (!paths.some((existing) => path.normalize(existing) === normalized)) {
|
||||
paths.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldIgnoreLogSourceWatcherPath(
|
||||
projectDir: string,
|
||||
watchedPath: string,
|
||||
|
|
@ -368,14 +380,20 @@ export class TeamLogSourceTracker {
|
|||
return;
|
||||
}
|
||||
|
||||
await this.ensureLogSourceFreshnessDirs(context.projectDir).catch((error) => {
|
||||
const taskFreshnessRootDirs = this.getTaskFreshnessRootDirs(context);
|
||||
const taskFreshnessWatchRootDirs = await this.ensureLogSourceFreshnessDirs(
|
||||
context.projectDir,
|
||||
taskFreshnessRootDirs
|
||||
).catch((error) => {
|
||||
logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`);
|
||||
return [path.normalize(context.projectDir)];
|
||||
});
|
||||
|
||||
const { targets, scopedSessionIds } = await this.buildScopedWatchTargets(
|
||||
context.projectDir,
|
||||
context.watchSessionIds,
|
||||
this.getPendingUnknownSessionIds(state)
|
||||
this.getPendingUnknownSessionIds(state),
|
||||
taskFreshnessWatchRootDirs
|
||||
);
|
||||
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||
return;
|
||||
|
|
@ -411,6 +429,18 @@ export class TeamLogSourceTracker {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
const eventTaskFreshnessRootDirs = this.getTaskFreshnessRootDirs(current.activeContext);
|
||||
pushUniqueNormalizedPath(eventTaskFreshnessRootDirs, current.projectDir);
|
||||
if (
|
||||
this.handleTaskFreshnessSignalChangeForRoots(
|
||||
teamName,
|
||||
changedPath,
|
||||
eventTaskFreshnessRootDirs
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = classifyLogSourceWatcherEvent({
|
||||
projectDir: current.projectDir,
|
||||
changedPath,
|
||||
|
|
@ -420,21 +450,6 @@ export class TeamLogSourceTracker {
|
|||
});
|
||||
|
||||
if (action.kind === 'task-freshness') {
|
||||
if (
|
||||
!this.handleTaskFreshnessSignalChange(
|
||||
teamName,
|
||||
current.projectDir,
|
||||
changedPath,
|
||||
BOARD_TASK_LOG_FRESHNESS_DIRNAME
|
||||
)
|
||||
) {
|
||||
this.handleTaskFreshnessSignalChange(
|
||||
teamName,
|
||||
current.projectDir,
|
||||
changedPath,
|
||||
BOARD_TASK_CHANGE_FRESHNESS_DIRNAME
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -458,24 +473,74 @@ export class TeamLogSourceTracker {
|
|||
});
|
||||
}
|
||||
|
||||
private async ensureLogSourceFreshnessDirs(projectDir: string): Promise<void> {
|
||||
private getTaskFreshnessRootDirs(context: TeamLogSourceLiveContext | null): string[] {
|
||||
const roots: string[] = [];
|
||||
pushUniqueNormalizedPath(roots, context?.projectDir);
|
||||
pushUniqueNormalizedPath(roots, context?.projectPath);
|
||||
for (const rootDir of context?.taskFreshnessRootDirs ?? []) {
|
||||
pushUniqueNormalizedPath(roots, rootDir);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
private async ensureLogSourceFreshnessDirs(
|
||||
transcriptProjectDir: string,
|
||||
projectDirs: readonly string[]
|
||||
): Promise<string[]> {
|
||||
const watchRootDirs: string[] = [];
|
||||
const normalizedTranscriptProjectDir = path.normalize(transcriptProjectDir);
|
||||
pushUniqueNormalizedPath(watchRootDirs, normalizedTranscriptProjectDir);
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), { recursive: true }),
|
||||
fs.mkdir(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { recursive: true }),
|
||||
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
|
||||
recursive: true,
|
||||
}),
|
||||
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
|
||||
recursive: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await Promise.all(
|
||||
projectDirs.map(async (projectDir) => {
|
||||
try {
|
||||
const normalizedProjectDir = path.normalize(projectDir);
|
||||
if (normalizedProjectDir === normalizedTranscriptProjectDir) {
|
||||
return;
|
||||
}
|
||||
if (!(await this.isDirectory(normalizedProjectDir))) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
|
||||
recursive: true,
|
||||
}),
|
||||
fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), {
|
||||
recursive: true,
|
||||
}),
|
||||
]);
|
||||
pushUniqueNormalizedPath(watchRootDirs, normalizedProjectDir);
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to ensure task freshness dirs in ${projectDir}: ${String(error)}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
return watchRootDirs;
|
||||
}
|
||||
|
||||
private async buildScopedWatchTargets(
|
||||
projectDir: string,
|
||||
confirmedSessionIds: readonly string[],
|
||||
pendingRootSessionIds: readonly string[]
|
||||
pendingRootSessionIds: readonly string[],
|
||||
taskFreshnessRootDirs: readonly string[] = [projectDir]
|
||||
): Promise<{ targets: string[]; scopedSessionIds: Set<string> }> {
|
||||
const targets = new Set<string>();
|
||||
const scopedSessionIds = new Set<string>();
|
||||
|
||||
targets.add(projectDir);
|
||||
targets.add(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
|
||||
targets.add(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
|
||||
for (const freshnessRootDir of taskFreshnessRootDirs) {
|
||||
targets.add(path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
|
||||
targets.add(path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
|
||||
}
|
||||
|
||||
for (const rawSessionId of confirmedSessionIds) {
|
||||
const sessionId = normalizeLogSourceSessionId(rawSessionId);
|
||||
|
|
@ -664,11 +729,10 @@ export class TeamLogSourceTracker {
|
|||
|
||||
private handleTaskFreshnessSignalChange(
|
||||
teamName: string,
|
||||
projectDir: string,
|
||||
changedPath: string,
|
||||
signalDirName: string
|
||||
signalDir: string,
|
||||
taskSignalKind: TaskFreshnessSignalKind
|
||||
): boolean {
|
||||
const signalDir = path.join(projectDir, signalDirName);
|
||||
const relativePath = path.relative(signalDir, changedPath);
|
||||
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return path.normalize(changedPath) === path.normalize(signalDir);
|
||||
|
|
@ -687,7 +751,7 @@ export class TeamLogSourceTracker {
|
|||
return true;
|
||||
}
|
||||
if (decoded.kind === 'opaque-safe-segment') {
|
||||
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath);
|
||||
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath, taskSignalKind);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -695,6 +759,7 @@ export class TeamLogSourceTracker {
|
|||
type: 'task-log-change',
|
||||
teamName,
|
||||
taskId: decoded.taskId,
|
||||
taskSignalKind,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
@ -720,7 +785,11 @@ export class TeamLogSourceTracker {
|
|||
}
|
||||
}
|
||||
|
||||
private async emitTaskFreshnessSignalFromFile(teamName: string, filePath: string): Promise<void> {
|
||||
private async emitTaskFreshnessSignalFromFile(
|
||||
teamName: string,
|
||||
filePath: string,
|
||||
taskSignalKind: TaskFreshnessSignalKind
|
||||
): Promise<void> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
|
@ -733,6 +802,7 @@ export class TeamLogSourceTracker {
|
|||
type: 'task-log-change',
|
||||
teamName,
|
||||
taskId,
|
||||
taskSignalKind,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -742,6 +812,36 @@ export class TeamLogSourceTracker {
|
|||
this.emitLogSourceChange(teamName);
|
||||
}
|
||||
|
||||
private handleTaskFreshnessSignalChangeForRoots(
|
||||
teamName: string,
|
||||
changedPath: string,
|
||||
taskFreshnessRootDirs: readonly string[]
|
||||
): boolean {
|
||||
for (const freshnessRootDir of taskFreshnessRootDirs) {
|
||||
if (
|
||||
this.handleTaskFreshnessSignalChange(
|
||||
teamName,
|
||||
changedPath,
|
||||
path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME),
|
||||
'log'
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
this.handleTaskFreshnessSignalChange(
|
||||
teamName,
|
||||
changedPath,
|
||||
path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME),
|
||||
'change'
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (this.getActiveConsumerCount(state) === 0) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const SCAN_CONCURRENCY = 15;
|
|||
|
||||
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */
|
||||
const DISCOVERY_CACHE_TTL = 30_000;
|
||||
const MAX_TASK_FRESHNESS_ROOT_DIRS = 64;
|
||||
|
||||
/** Signal sources for subagent member attribution, ordered by reliability. */
|
||||
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
|
||||
|
|
@ -116,6 +117,7 @@ export interface MemberLogFileRef {
|
|||
export interface TeamLogSourceLiveContext {
|
||||
projectDir: string;
|
||||
projectPath?: string;
|
||||
taskFreshnessRootDirs?: string[];
|
||||
leadSessionId?: string;
|
||||
sessionIds: string[];
|
||||
watchSessionIds: string[];
|
||||
|
|
@ -143,6 +145,30 @@ async function mapLimit<T, R>(
|
|||
return results;
|
||||
}
|
||||
|
||||
function collectTaskFreshnessRootDirs(candidates: readonly unknown[]): string[] {
|
||||
const roots: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || !path.isAbsolute(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = path.normalize(trimmed);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
roots.push(normalized);
|
||||
if (roots.length >= MAX_TASK_FRESHNESS_ROOT_DIRS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export class TeamMemberLogsFinder {
|
||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||
private readonly attributionCache = new Map<
|
||||
|
|
@ -286,13 +312,13 @@ export class TeamMemberLogsFinder {
|
|||
readBootstrapLaunchSnapshot(teamName).catch(() => null),
|
||||
]);
|
||||
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
||||
const extraProjectPathCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
|
||||
const runtimeMemberCwdCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
|
||||
(member) => member.cwd
|
||||
);
|
||||
|
||||
const base = await this.projectResolver.getLiveBaseContext(teamName, {
|
||||
forceRefresh: options?.forceRefresh,
|
||||
extraProjectPathCandidates,
|
||||
extraProjectPathCandidates: runtimeMemberCwdCandidates,
|
||||
});
|
||||
if (!base) {
|
||||
return null;
|
||||
|
|
@ -308,6 +334,11 @@ export class TeamMemberLogsFinder {
|
|||
return {
|
||||
projectDir: base.projectDir,
|
||||
projectPath: base.config.projectPath,
|
||||
taskFreshnessRootDirs: collectTaskFreshnessRootDirs([
|
||||
base.config.projectPath,
|
||||
...(base.config.members ?? []).map((member) => member.cwd),
|
||||
...runtimeMemberCwdCandidates,
|
||||
]),
|
||||
leadSessionId: base.config.leadSessionId ?? preferredSnapshot?.leadSessionId,
|
||||
sessionIds: watchSessionIds,
|
||||
watchSessionIds,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
|
||||
import { selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
||||
|
||||
import type {
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamConfig,
|
||||
|
|
@ -282,13 +284,7 @@ export class TeamMemberResolver {
|
|||
const members: TeamMemberSnapshot[] = [];
|
||||
for (const name of names) {
|
||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||
const currentTask =
|
||||
ownedTasks.find(
|
||||
(task) =>
|
||||
task.status === 'in_progress' &&
|
||||
task.reviewState !== 'approved' &&
|
||||
task.kanbanColumn !== 'approved'
|
||||
) ?? null;
|
||||
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
|
||||
const configMember = configMemberMap.get(name);
|
||||
const metaMember = metaMemberMap.get(name);
|
||||
const launchMember = launchMemberMap.get(name);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import {
|
||||
createOpenCodePromptDeliveryLedgerStore,
|
||||
type OpenCodePromptDeliveryLedgerRecord,
|
||||
} from './opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||
import { selectOpenCodeRuntimeDeliveryReason } from './opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics';
|
||||
import {
|
||||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
|
||||
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
|
|
@ -29,11 +40,15 @@ const CACHE_TTL_MS = 30_000;
|
|||
const TAIL_BYTES = 64 * 1024;
|
||||
const BATCH_WARN_MS = 1_000;
|
||||
const ADVISORY_FETCH_CONCURRENCY = 2;
|
||||
const OPENCODE_DELIVERY_ERROR_LOOKBACK_MS = 30 * 60 * 1000;
|
||||
const QUOTA_EXHAUSTED_TOKENS = [
|
||||
'exhausted your capacity',
|
||||
'capacity exceeded',
|
||||
'quota exceeded',
|
||||
'quota exhausted',
|
||||
'insufficient credits',
|
||||
'key limit exceeded',
|
||||
'total limit',
|
||||
];
|
||||
const RATE_LIMITED_TOKENS = [
|
||||
'rate limit',
|
||||
|
|
@ -70,7 +85,18 @@ const PROVIDER_OVERLOADED_TOKENS = [
|
|||
'service unavailable',
|
||||
'503',
|
||||
];
|
||||
|
||||
const PROTOCOL_PROOF_MISSING_TOKENS = [
|
||||
'non_visible_tool_without_task_progress',
|
||||
'visible_reply_still_required',
|
||||
'visible_reply_ack_only_still_requires_answer',
|
||||
'plain_text_ack_only_still_requires_answer',
|
||||
'visible_reply_destination_not_found_yet',
|
||||
'visible_reply_missing_relayofmessageid',
|
||||
'did not create a visible reply',
|
||||
'did not create a visible message_send reply',
|
||||
'did not create a visible reply or task progress proof',
|
||||
'without the required relayofmessageid correlation',
|
||||
];
|
||||
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
|
||||
|
||||
interface CachedRuntimeAdvisory {
|
||||
|
|
@ -111,9 +137,49 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory
|
|||
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
|
||||
return 'provider_overloaded';
|
||||
}
|
||||
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
|
||||
return 'protocol_proof_missing';
|
||||
}
|
||||
return 'backend_error';
|
||||
}
|
||||
|
||||
function getRecordTimeMs(record: OpenCodePromptDeliveryLedgerRecord): number {
|
||||
const candidates = [
|
||||
record.failedAt,
|
||||
record.respondedAt,
|
||||
record.lastObservedAt,
|
||||
record.updatedAt,
|
||||
record.createdAt,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const time = Date.parse(candidate ?? '');
|
||||
if (Number.isFinite(time)) {
|
||||
return time;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isTerminalSuccessfulRecord(record: OpenCodePromptDeliveryLedgerRecord): boolean {
|
||||
return (
|
||||
record.status === 'responded' &&
|
||||
Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function isPotentialRuntimeDeliveryError(record: OpenCodePromptDeliveryLedgerRecord): boolean {
|
||||
if (record.status === 'failed_terminal') {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
record.status !== 'responded' &&
|
||||
(record.responseState === 'session_error' ||
|
||||
record.responseState === 'tool_error' ||
|
||||
record.responseState === 'permission_blocked' ||
|
||||
record.responseState === 'reconcile_failed')
|
||||
);
|
||||
}
|
||||
|
||||
async function mapLimit<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
|
|
@ -137,8 +203,10 @@ async function mapLimit<T, R>(
|
|||
}
|
||||
|
||||
export class TeamMemberRuntimeAdvisoryService {
|
||||
private readonly inboxReader = new TeamInboxReader();
|
||||
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
|
||||
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
|
||||
private readonly cacheGenerationByTeam = new Map<string, number>();
|
||||
private readonly inFlightBatchRequests = new Map<
|
||||
string,
|
||||
Promise<Map<string, MemberRuntimeAdvisory>>
|
||||
|
|
@ -148,6 +216,23 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder()
|
||||
) {}
|
||||
|
||||
invalidateMemberAdvisory(teamName: string, memberName: string): void {
|
||||
const teamKey = this.normalizeToken(teamName);
|
||||
const memberKey = this.normalizeToken(memberName);
|
||||
if (!teamKey || !memberKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cacheGenerationByTeam.set(teamKey, (this.cacheGenerationByTeam.get(teamKey) ?? 0) + 1);
|
||||
this.memberCache.delete(`${teamKey}::${memberKey}`);
|
||||
this.teamBatchCacheByTeam.delete(teamKey);
|
||||
for (const key of this.inFlightBatchRequests.keys()) {
|
||||
if (key.startsWith(`${teamKey}::`)) {
|
||||
this.inFlightBatchRequests.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getMemberAdvisories(
|
||||
teamName: string,
|
||||
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
|
||||
|
|
@ -187,17 +272,21 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<MemberRuntimeAdvisory | null> {
|
||||
const teamKey = this.normalizeToken(teamName);
|
||||
const cacheKey = this.getMemberCacheKey(teamName, memberName);
|
||||
const cached = this.memberCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value ? this.cloneAdvisory(cached.value) : null;
|
||||
}
|
||||
|
||||
const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0;
|
||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
||||
this.memberCache.set(cacheKey, {
|
||||
value: advisory,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
if ((this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart) {
|
||||
this.memberCache.set(cacheKey, {
|
||||
value: advisory,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
return advisory ? this.cloneAdvisory(advisory) : null;
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +298,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
): Promise<Map<string, MemberRuntimeAdvisory>> {
|
||||
const startedAt = performance.now();
|
||||
const now = Date.now();
|
||||
const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0;
|
||||
const result = new Map<string, MemberRuntimeAdvisory>();
|
||||
const membersToFetch: string[] = [];
|
||||
let memberCacheHits = 0;
|
||||
|
|
@ -233,23 +323,29 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
if (membersToFetch.length > 0) {
|
||||
const fetched = await this.findRecentMemberAdvisories(teamName, membersToFetch);
|
||||
const fetchedAt = Date.now();
|
||||
const cacheStillCurrent =
|
||||
(this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart;
|
||||
for (const [memberName, advisory] of fetched) {
|
||||
const normalizedMemberName = this.normalizeToken(memberName);
|
||||
this.memberCache.set(`${teamKey}::${normalizedMemberName}`, {
|
||||
value: advisory,
|
||||
expiresAt: fetchedAt + CACHE_TTL_MS,
|
||||
});
|
||||
if (cacheStillCurrent) {
|
||||
this.memberCache.set(`${teamKey}::${normalizedMemberName}`, {
|
||||
value: advisory,
|
||||
expiresAt: fetchedAt + CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
if (advisory) {
|
||||
result.set(normalizedMemberName, this.cloneAdvisory(advisory));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.teamBatchCacheByTeam.set(teamKey, {
|
||||
membersSignature,
|
||||
value: this.cloneNormalizedAdvisories(result),
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
if ((this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart) {
|
||||
this.teamBatchCacheByTeam.set(teamKey, {
|
||||
membersSignature,
|
||||
value: this.cloneNormalizedAdvisories(result),
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const totalMs = performance.now() - startedAt;
|
||||
if (totalMs >= BATCH_WARN_MS) {
|
||||
|
|
@ -305,6 +401,11 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<MemberRuntimeAdvisory | null> {
|
||||
const openCodeAdvisory = await this.findRecentOpenCodeDeliveryAdvisory(teamName, memberName);
|
||||
if (openCodeAdvisory) {
|
||||
return openCodeAdvisory;
|
||||
}
|
||||
|
||||
const summaries = await this.logsFinder.findMemberLogs(
|
||||
teamName,
|
||||
memberName,
|
||||
|
|
@ -319,9 +420,33 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
teamName: string,
|
||||
memberNames: readonly string[]
|
||||
): Promise<readonly (readonly [string, MemberRuntimeAdvisory | null])[]> {
|
||||
const openCodeAdvisories = await this.findRecentOpenCodeDeliveryAdvisories(
|
||||
teamName,
|
||||
memberNames
|
||||
);
|
||||
const remainingMemberNames = memberNames.filter(
|
||||
(memberName) => !openCodeAdvisories.has(memberName)
|
||||
);
|
||||
if (remainingMemberNames.length === 0) {
|
||||
return memberNames.map(
|
||||
(memberName) => [memberName, openCodeAdvisories.get(memberName) ?? null] as const
|
||||
);
|
||||
}
|
||||
|
||||
if (this.logsFinder.findRecentMemberLogFileRefsByMember) {
|
||||
try {
|
||||
return await this.findRecentMemberAdvisoriesFromBatchRefs(teamName, memberNames);
|
||||
const logAdvisories = await this.findRecentMemberAdvisoriesFromBatchRefs(
|
||||
teamName,
|
||||
remainingMemberNames
|
||||
);
|
||||
const logMap = new Map(logAdvisories);
|
||||
return memberNames.map(
|
||||
(memberName) =>
|
||||
[
|
||||
memberName,
|
||||
openCodeAdvisories.get(memberName) ?? logMap.get(memberName) ?? null,
|
||||
] as const
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('batch member runtime advisory log lookup failed; falling back', {
|
||||
teamName,
|
||||
|
|
@ -330,10 +455,226 @@ export class TeamMemberRuntimeAdvisoryService {
|
|||
}
|
||||
}
|
||||
|
||||
return mapLimit(memberNames, ADVISORY_FETCH_CONCURRENCY, async (memberName) => {
|
||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
||||
return [memberName, advisory] as const;
|
||||
const logAdvisories = await mapLimit(
|
||||
remainingMemberNames,
|
||||
ADVISORY_FETCH_CONCURRENCY,
|
||||
async (memberName) => {
|
||||
const summaries = await this.logsFinder.findMemberLogs(
|
||||
teamName,
|
||||
memberName,
|
||||
Date.now() - LOOKBACK_MS
|
||||
);
|
||||
return [
|
||||
memberName,
|
||||
await this.findRecentMemberAdvisoryInFiles(
|
||||
summaries.flatMap((summary) => summary.filePath ?? [])
|
||||
),
|
||||
] as const;
|
||||
}
|
||||
);
|
||||
const logMap = new Map(logAdvisories);
|
||||
return memberNames.map(
|
||||
(memberName) =>
|
||||
[memberName, openCodeAdvisories.get(memberName) ?? logMap.get(memberName) ?? null] as const
|
||||
);
|
||||
}
|
||||
|
||||
private async findRecentOpenCodeDeliveryAdvisory(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<MemberRuntimeAdvisory | null> {
|
||||
const advisories = await this.findRecentOpenCodeDeliveryAdvisories(teamName, [memberName]);
|
||||
return advisories.get(memberName) ?? null;
|
||||
}
|
||||
|
||||
private async findRecentOpenCodeDeliveryAdvisories(
|
||||
teamName: string,
|
||||
memberNames: readonly string[]
|
||||
): Promise<Map<string, MemberRuntimeAdvisory>> {
|
||||
const activeMembersByKey = new Map<string, string>();
|
||||
for (const memberName of memberNames) {
|
||||
const normalized = this.normalizeToken(memberName);
|
||||
if (normalized && !activeMembersByKey.has(normalized)) {
|
||||
activeMembersByKey.set(normalized, memberName);
|
||||
}
|
||||
}
|
||||
if (activeMembersByKey.size === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(
|
||||
() => null
|
||||
);
|
||||
if (!laneIndex) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const recordsByMember = new Map<string, OpenCodePromptDeliveryLedgerRecord[]>();
|
||||
for (const lane of Object.values(laneIndex.lanes)) {
|
||||
if (lane.state === 'stopped') {
|
||||
continue;
|
||||
}
|
||||
const laneMember = this.getOpenCodeLaneMemberName(lane.laneId);
|
||||
if (!laneMember || !activeMembersByKey.has(this.normalizeToken(laneMember))) {
|
||||
continue;
|
||||
}
|
||||
const ledger = createOpenCodePromptDeliveryLedgerStore({
|
||||
filePath: getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName,
|
||||
laneId: lane.laneId,
|
||||
fileName: 'opencode-prompt-delivery-ledger.json',
|
||||
}),
|
||||
});
|
||||
const records = await ledger.list().catch(() => []);
|
||||
const existing = recordsByMember.get(this.normalizeToken(laneMember)) ?? [];
|
||||
existing.push(...records);
|
||||
recordsByMember.set(this.normalizeToken(laneMember), existing);
|
||||
}
|
||||
|
||||
const memberKeysWithRecentErrors = new Set<string>();
|
||||
for (const [memberKey, records] of recordsByMember) {
|
||||
if (
|
||||
records.some((record) => {
|
||||
const observedAt = getRecordTimeMs(record);
|
||||
return (
|
||||
isPotentialRuntimeDeliveryError(record) &&
|
||||
Number.isFinite(observedAt) &&
|
||||
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
|
||||
);
|
||||
})
|
||||
) {
|
||||
memberKeysWithRecentErrors.add(memberKey);
|
||||
}
|
||||
}
|
||||
if (memberKeysWithRecentErrors.size === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const visibleRuntimeReplyTimes = await this.readVisibleOpenCodeRuntimeDeliveryReplyTimes(
|
||||
teamName,
|
||||
memberKeysWithRecentErrors
|
||||
);
|
||||
const result = new Map<string, MemberRuntimeAdvisory>();
|
||||
for (const [memberKey, records] of recordsByMember) {
|
||||
if (!memberKeysWithRecentErrors.has(memberKey)) {
|
||||
continue;
|
||||
}
|
||||
const originalName = activeMembersByKey.get(memberKey);
|
||||
const advisory = originalName
|
||||
? this.buildOpenCodeDeliveryAdvisoryFromRecords(
|
||||
originalName,
|
||||
records,
|
||||
now,
|
||||
visibleRuntimeReplyTimes
|
||||
)
|
||||
: null;
|
||||
if (advisory && originalName) {
|
||||
result.set(originalName, advisory);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private getOpenCodeLaneMemberName(laneId: string): string | null {
|
||||
const parts = laneId.split(':');
|
||||
if (parts.length < 3 || parts[0] !== 'secondary' || parts[1] !== 'opencode') {
|
||||
return null;
|
||||
}
|
||||
return parts.slice(2).join(':').trim() || null;
|
||||
}
|
||||
|
||||
private buildOpenCodeDeliveryAdvisoryFromRecords(
|
||||
memberName: string,
|
||||
records: readonly OpenCodePromptDeliveryLedgerRecord[],
|
||||
now: number,
|
||||
visibleRuntimeReplyTimes: ReadonlyMap<string, number>
|
||||
): MemberRuntimeAdvisory | null {
|
||||
const ordered = records
|
||||
.slice()
|
||||
.sort((left, right) => getRecordTimeMs(right) - getRecordTimeMs(left));
|
||||
const latestSuccess = ordered.find(isTerminalSuccessfulRecord);
|
||||
const latestError = ordered.find((record) => {
|
||||
const observedAt = getRecordTimeMs(record);
|
||||
return (
|
||||
isPotentialRuntimeDeliveryError(record) &&
|
||||
Number.isFinite(observedAt) &&
|
||||
now - observedAt <= OPENCODE_DELIVERY_ERROR_LOOKBACK_MS
|
||||
);
|
||||
});
|
||||
if (!latestError) {
|
||||
return null;
|
||||
}
|
||||
if (latestSuccess && getRecordTimeMs(latestSuccess) > getRecordTimeMs(latestError)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
this.hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
|
||||
memberName,
|
||||
latestError,
|
||||
visibleRuntimeReplyTimes
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = selectOpenCodeRuntimeDeliveryReason(latestError);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
const observedAt = getRecordTimeMs(latestError);
|
||||
return {
|
||||
kind: 'api_error',
|
||||
observedAt: new Date(Number.isFinite(observedAt) ? observedAt : now).toISOString(),
|
||||
reasonCode: classifyRetryReason(message),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
private async readVisibleOpenCodeRuntimeDeliveryReplyTimes(
|
||||
teamName: string,
|
||||
activeMemberKeys: ReadonlySet<string>
|
||||
): Promise<Map<string, number>> {
|
||||
const result = new Map<string, number>();
|
||||
const inboxNames = await this.inboxReader.listInboxNames(teamName).catch(() => []);
|
||||
await mapLimit(inboxNames, ADVISORY_FETCH_CONCURRENCY, async (inboxName) => {
|
||||
const messages = await this.inboxReader.getMessagesFor(teamName, inboxName).catch(() => []);
|
||||
for (const message of messages) {
|
||||
if (message.source !== 'runtime_delivery' || !message.relayOfMessageId) {
|
||||
continue;
|
||||
}
|
||||
const memberKey = this.normalizeToken(message.from);
|
||||
if (activeMemberKeys.has(memberKey)) {
|
||||
const observedAt = Date.parse(message.timestamp);
|
||||
if (!Number.isFinite(observedAt)) {
|
||||
continue;
|
||||
}
|
||||
const key = this.getOpenCodeRuntimeReplyKey(memberKey, message.relayOfMessageId);
|
||||
result.set(key, Math.max(result.get(key) ?? 0, observedAt));
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private hasVisibleRuntimeReplyForOpenCodeDeliveryRecord(
|
||||
memberName: string,
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
visibleRuntimeReplyTimes: ReadonlyMap<string, number>
|
||||
): boolean {
|
||||
const relayOfMessageId = record.inboxMessageId?.trim();
|
||||
if (!relayOfMessageId) {
|
||||
return false;
|
||||
}
|
||||
const replyObservedAt = visibleRuntimeReplyTimes.get(
|
||||
this.getOpenCodeRuntimeReplyKey(this.normalizeToken(memberName), relayOfMessageId)
|
||||
);
|
||||
return typeof replyObservedAt === 'number' && replyObservedAt > getRecordTimeMs(record);
|
||||
}
|
||||
|
||||
private getOpenCodeRuntimeReplyKey(memberKey: string, relayOfMessageId: string): string {
|
||||
return `${memberKey}::${relayOfMessageId.trim()}`;
|
||||
}
|
||||
|
||||
private async findRecentMemberAdvisoriesFromBatchRefs(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||
|
|
@ -84,7 +85,7 @@ function resolveLeadName(config: TeamConfig): string {
|
|||
return lead?.name?.trim() || 'team-lead';
|
||||
}
|
||||
|
||||
function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string {
|
||||
function resolveSyntheticBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string {
|
||||
const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt;
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
return new Date(raw).toISOString();
|
||||
|
|
@ -98,46 +99,55 @@ function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfi
|
|||
return new Date(0).toISOString();
|
||||
}
|
||||
|
||||
function buildOpenCodeBootstrapDisplayPrompt(config: TeamConfig, member: TeamConfigMember): string {
|
||||
function buildSyntheticBootstrapDisplayPrompt(
|
||||
config: TeamConfig,
|
||||
member: TeamConfigMember
|
||||
): string {
|
||||
const role = member.role?.trim() || member.agentType?.trim() || 'team member';
|
||||
const displayName = config.description?.trim() || config.name;
|
||||
const providerLine = '\nProvider override for this teammate: opencode.';
|
||||
const providerId = member.providerId?.trim();
|
||||
const providerLine = providerId ? `\nProvider override for this teammate: ${providerId}.` : '';
|
||||
const modelLine = member.model?.trim()
|
||||
? `\nModel override for this teammate: ${member.model.trim()}.`
|
||||
: '';
|
||||
const runtimeProviderField = providerId === 'opencode' ? ', runtimeProvider: "opencode"' : '';
|
||||
|
||||
return `You are ${member.name}, a ${role} on team "${displayName}" (${config.name}).${providerLine}${modelLine}
|
||||
|
||||
The team has already been created and you are being attached as a persistent teammate.
|
||||
Your FIRST action: call MCP tool member_briefing on the "agent-teams" server with:
|
||||
{ teamName: "${config.name}", memberName: "${member.name}", runtimeProvider: "opencode" }
|
||||
{ teamName: "${config.name}", memberName: "${member.name}"${runtimeProviderField} }
|
||||
Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a delegated helper for this step.
|
||||
After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`;
|
||||
}
|
||||
|
||||
function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessage[] {
|
||||
function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] {
|
||||
const members = Array.isArray(config.members) ? config.members : [];
|
||||
const leadName = resolveLeadName(config);
|
||||
const normalizedLeadName = leadName.trim().toLowerCase();
|
||||
return members
|
||||
.filter(
|
||||
(member) =>
|
||||
member &&
|
||||
member.name?.trim() &&
|
||||
member.providerId === 'opencode' &&
|
||||
member.removedAt == null &&
|
||||
(member as { isActive?: unknown }).isActive !== false
|
||||
member.name.trim().toLowerCase() !== normalizedLeadName &&
|
||||
member.removedAt == null
|
||||
)
|
||||
.map((member) => ({
|
||||
from: leadName,
|
||||
to: member.name,
|
||||
text: buildOpenCodeBootstrapDisplayPrompt(config, member),
|
||||
timestamp: resolveOpenCodeBootstrapTimestamp(config, member),
|
||||
text: buildSyntheticBootstrapDisplayPrompt(config, member),
|
||||
timestamp: resolveSyntheticBootstrapTimestamp(config, member),
|
||||
read: true,
|
||||
source: 'system_notification' as const,
|
||||
messageId: `opencode-bootstrap-start:${config.name}:${member.name}`,
|
||||
messageId: `bootstrap-start:${config.name}:${member.name}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function isVisibleTeamMessage(message: InboxMessage): boolean {
|
||||
return !isTeamInternalControlMessageEnvelope(message);
|
||||
}
|
||||
|
||||
function annotateSlashCommandResponses(messages: InboxMessage[]): void {
|
||||
let pendingSlash = null as InboxMessage['slashCommand'] | null;
|
||||
|
||||
|
|
@ -498,8 +508,10 @@ export class TeamMessageFeedService {
|
|||
const sourceMs = Date.now() - sourceStartedAt;
|
||||
|
||||
const normalizeStartedAt = Date.now();
|
||||
const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config);
|
||||
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages];
|
||||
const syntheticMessages = buildSyntheticBootstrapMessages(config);
|
||||
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter(
|
||||
isVisibleTeamMessage
|
||||
);
|
||||
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
||||
messages = ensureEffectiveMessageIds(messages);
|
||||
messages = dedupeByMessageId(messages);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -378,11 +378,25 @@ export class TeamTaskWriter {
|
|||
}
|
||||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
if (owner) {
|
||||
task.owner = owner;
|
||||
const previousOwner =
|
||||
typeof task.owner === 'string' && task.owner.trim() ? task.owner.trim() : undefined;
|
||||
const nextOwner = typeof owner === 'string' && owner.trim() ? owner.trim() : undefined;
|
||||
if (nextOwner) {
|
||||
task.owner = nextOwner;
|
||||
} else {
|
||||
delete task.owner;
|
||||
}
|
||||
if (previousOwner !== nextOwner) {
|
||||
task.historyEvents = appendHistoryEvent(
|
||||
Array.isArray(task.historyEvents) ? task.historyEvents : undefined,
|
||||
{
|
||||
type: 'owner_changed',
|
||||
...(previousOwner ? { from: previousOwner } : {}),
|
||||
...(nextOwner ? { to: nextOwner } : {}),
|
||||
actor: 'user',
|
||||
} as Omit<TaskHistoryEvent, 'id' | 'timestamp'>
|
||||
);
|
||||
}
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
225
src/main/services/team/bootstrap/BootstrapProofValidation.ts
Normal file
225
src/main/services/team/bootstrap/BootstrapProofValidation.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
export const LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE = 'member_briefing_tool_success';
|
||||
export const NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE =
|
||||
'native_app_managed_bootstrap_private_turn';
|
||||
|
||||
type BootstrapProofField =
|
||||
| 'source'
|
||||
| 'bootstrapProofToken'
|
||||
| 'contextHash'
|
||||
| 'briefingHash'
|
||||
| 'runId';
|
||||
|
||||
export type BootstrapProofSource =
|
||||
| typeof LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE
|
||||
| typeof NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE;
|
||||
|
||||
export type BootstrapProofValidationFailureReason =
|
||||
| 'wrong_event_type'
|
||||
| 'wrong_team'
|
||||
| 'stale_timestamp'
|
||||
| 'unsupported_source'
|
||||
| 'missing_team'
|
||||
| 'missing_token'
|
||||
| 'token_mismatch'
|
||||
| 'missing_run_id'
|
||||
| 'run_id_mismatch'
|
||||
| 'missing_hash'
|
||||
| 'hash_mismatch'
|
||||
| 'wrong_proof_mode';
|
||||
|
||||
export type BootstrapProofValidationResult =
|
||||
| { ok: true; source: BootstrapProofSource }
|
||||
| { ok: false; reason: BootstrapProofValidationFailureReason; diagnostic: string };
|
||||
|
||||
export interface BootstrapRuntimeProofEventLike {
|
||||
type?: unknown;
|
||||
timestamp?: unknown;
|
||||
teamName?: unknown;
|
||||
source?: unknown;
|
||||
bootstrapProofToken?: unknown;
|
||||
contextHash?: unknown;
|
||||
briefingHash?: unknown;
|
||||
runId?: unknown;
|
||||
detail?: unknown;
|
||||
}
|
||||
|
||||
export interface BootstrapRuntimeProofExpected {
|
||||
teamName: string;
|
||||
boundaryMs: number;
|
||||
proofToken?: string;
|
||||
proofMode?: string;
|
||||
contextHash?: string;
|
||||
briefingHash?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export function parseBootstrapRuntimeProofDetail(detail: unknown): Record<string, unknown> {
|
||||
if (typeof detail !== 'string' || detail.trim().length === 0) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(detail) as unknown;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readProofField(
|
||||
event: BootstrapRuntimeProofEventLike,
|
||||
detail: Record<string, unknown>,
|
||||
field: BootstrapProofField
|
||||
): string | undefined {
|
||||
const direct = event[field];
|
||||
if (typeof direct === 'string' && direct.trim().length > 0) {
|
||||
return direct.trim();
|
||||
}
|
||||
const nested = detail[field];
|
||||
return typeof nested === 'string' && nested.trim().length > 0 ? nested.trim() : undefined;
|
||||
}
|
||||
|
||||
function getBootstrapProofSource(
|
||||
event: BootstrapRuntimeProofEventLike,
|
||||
detail: Record<string, unknown>
|
||||
): BootstrapProofSource | undefined {
|
||||
const source = readProofField(event, detail, 'source');
|
||||
return source === LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE ||
|
||||
source === NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE
|
||||
? source
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function reject(
|
||||
reason: BootstrapProofValidationFailureReason,
|
||||
diagnostic: string
|
||||
): BootstrapProofValidationResult {
|
||||
return { ok: false, reason, diagnostic };
|
||||
}
|
||||
|
||||
function validateExpectedProofToken(input: {
|
||||
event: BootstrapRuntimeProofEventLike;
|
||||
detail: Record<string, unknown>;
|
||||
expected: BootstrapRuntimeProofExpected;
|
||||
}): BootstrapProofValidationResult | null {
|
||||
if (!input.expected.proofToken) {
|
||||
return null;
|
||||
}
|
||||
const eventToken = readProofField(input.event, input.detail, 'bootstrapProofToken');
|
||||
if (!eventToken) {
|
||||
return reject('missing_token', 'Bootstrap proof token is missing');
|
||||
}
|
||||
if (eventToken !== input.expected.proofToken) {
|
||||
return reject('token_mismatch', 'Bootstrap proof token does not match the current attempt');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateLegacyMemberBriefingProof(input: {
|
||||
event: BootstrapRuntimeProofEventLike;
|
||||
detail: Record<string, unknown>;
|
||||
expected: BootstrapRuntimeProofExpected;
|
||||
}): BootstrapProofValidationResult {
|
||||
const tokenFailure = validateExpectedProofToken(input);
|
||||
return tokenFailure ?? { ok: true, source: LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE };
|
||||
}
|
||||
|
||||
function validateNativeAppManagedProof(input: {
|
||||
event: BootstrapRuntimeProofEventLike;
|
||||
detail: Record<string, unknown>;
|
||||
expected: BootstrapRuntimeProofExpected;
|
||||
}): BootstrapProofValidationResult {
|
||||
const eventTeamName = typeof input.event.teamName === 'string' ? input.event.teamName.trim() : '';
|
||||
if (!eventTeamName) {
|
||||
return reject('missing_team', 'Native app-managed bootstrap proof is missing teamName');
|
||||
}
|
||||
if (eventTeamName !== input.expected.teamName) {
|
||||
return reject('wrong_team', 'Native app-managed bootstrap proof teamName does not match');
|
||||
}
|
||||
if (input.expected.proofMode !== 'native_app_managed_context') {
|
||||
return reject('wrong_proof_mode', 'Native app-managed bootstrap proof mode is not expected');
|
||||
}
|
||||
|
||||
const tokenFailure = validateExpectedProofToken(input);
|
||||
if (tokenFailure) {
|
||||
return tokenFailure;
|
||||
}
|
||||
if (!input.expected.proofToken) {
|
||||
return reject('missing_token', 'Native app-managed bootstrap expected proof token is missing');
|
||||
}
|
||||
|
||||
const runId = readProofField(input.event, input.detail, 'runId');
|
||||
if (!input.expected.runId || !runId) {
|
||||
return reject('missing_run_id', 'Native app-managed bootstrap runId is missing');
|
||||
}
|
||||
if (runId !== input.expected.runId) {
|
||||
return reject('run_id_mismatch', 'Native app-managed bootstrap runId does not match');
|
||||
}
|
||||
|
||||
const contextHash = readProofField(input.event, input.detail, 'contextHash');
|
||||
const briefingHash = readProofField(input.event, input.detail, 'briefingHash');
|
||||
if (
|
||||
!input.expected.contextHash ||
|
||||
!input.expected.briefingHash ||
|
||||
!contextHash ||
|
||||
!briefingHash
|
||||
) {
|
||||
return reject('missing_hash', 'Native app-managed bootstrap proof hash metadata is missing');
|
||||
}
|
||||
if (contextHash !== input.expected.contextHash || briefingHash !== input.expected.briefingHash) {
|
||||
return reject('hash_mismatch', 'Native app-managed bootstrap proof hashes do not match');
|
||||
}
|
||||
|
||||
return { ok: true, source: NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE };
|
||||
}
|
||||
|
||||
const BOOTSTRAP_PROOF_VALIDATORS: Record<
|
||||
BootstrapProofSource,
|
||||
(input: {
|
||||
event: BootstrapRuntimeProofEventLike;
|
||||
detail: Record<string, unknown>;
|
||||
expected: BootstrapRuntimeProofExpected;
|
||||
}) => BootstrapProofValidationResult
|
||||
> = {
|
||||
[LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE]: validateLegacyMemberBriefingProof,
|
||||
[NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE]: validateNativeAppManagedProof,
|
||||
};
|
||||
|
||||
export function validateBootstrapRuntimeProofEnvelopeDetailed(input: {
|
||||
event: BootstrapRuntimeProofEventLike;
|
||||
detail?: Record<string, unknown>;
|
||||
expected: BootstrapRuntimeProofExpected;
|
||||
}): BootstrapProofValidationResult {
|
||||
const { event, expected } = input;
|
||||
const detail = input.detail ?? parseBootstrapRuntimeProofDetail(event.detail);
|
||||
if (event.type !== 'bootstrap_confirmed') {
|
||||
return reject('wrong_event_type', 'Runtime event is not bootstrap_confirmed');
|
||||
}
|
||||
if (typeof event.teamName === 'string' && event.teamName.trim() !== expected.teamName) {
|
||||
return reject('wrong_team', 'Bootstrap proof teamName does not match');
|
||||
}
|
||||
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
|
||||
const eventMs = Date.parse(timestamp);
|
||||
if (
|
||||
Number.isFinite(expected.boundaryMs) &&
|
||||
(!Number.isFinite(eventMs) || eventMs < expected.boundaryMs)
|
||||
) {
|
||||
return reject('stale_timestamp', 'Bootstrap proof timestamp is older than the current attempt');
|
||||
}
|
||||
|
||||
const source = getBootstrapProofSource(event, detail);
|
||||
if (!source) {
|
||||
return reject('unsupported_source', 'Bootstrap proof source is missing or unsupported');
|
||||
}
|
||||
|
||||
return BOOTSTRAP_PROOF_VALIDATORS[source]({ event, detail, expected });
|
||||
}
|
||||
|
||||
export function validateBootstrapRuntimeProofEnvelope(input: {
|
||||
event: BootstrapRuntimeProofEventLike;
|
||||
detail?: Record<string, unknown>;
|
||||
expected: BootstrapRuntimeProofExpected;
|
||||
}): boolean {
|
||||
return validateBootstrapRuntimeProofEnvelopeDetailed(input).ok;
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import type { TeamCreateRequest, TeamProviderId } from '@shared/types';
|
||||
|
||||
const { createController } = agentTeamsControllerModule;
|
||||
|
||||
export interface NativeAppManagedBootstrapSpec {
|
||||
schemaVersion: 1;
|
||||
mode: 'startup_context_file';
|
||||
contextText: string;
|
||||
contextHash: string;
|
||||
briefingHash: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
const MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS = 18_000;
|
||||
const MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS = 24_000;
|
||||
const MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS = 96_000;
|
||||
|
||||
export function isNativeAppManagedBootstrapProvider(providerId?: TeamProviderId): boolean {
|
||||
return providerId == null || providerId === 'anthropic' || providerId === 'codex';
|
||||
}
|
||||
|
||||
export function canonicalizeNativeBootstrapContextText(input: string): string {
|
||||
return input
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function hashNativeBootstrapText(input: string): string {
|
||||
return createHash('sha256').update(canonicalizeNativeBootstrapContextText(input)).digest('hex');
|
||||
}
|
||||
|
||||
function redactNativeBootstrapContextText(input: string): string {
|
||||
return input
|
||||
.replace(/sk-ant-[A-Za-z0-9_-]+/g, '[REDACTED_ANTHROPIC_API_KEY]')
|
||||
.replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]')
|
||||
.replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_API_KEY)=\S+/g, '$1=[REDACTED]')
|
||||
.replace(/Bearer\s+[A-Z0-9._-]+/gi, 'Bearer [REDACTED]');
|
||||
}
|
||||
|
||||
function boundText(input: string, maxChars: number): string {
|
||||
const canonical = canonicalizeNativeBootstrapContextText(input);
|
||||
if (canonical.length <= maxChars) {
|
||||
return canonical;
|
||||
}
|
||||
return `${canonical.slice(0, maxChars)}\n[truncated native bootstrap context]`;
|
||||
}
|
||||
|
||||
function buildContextText(params: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
providerId?: TeamProviderId;
|
||||
cwd: string;
|
||||
briefing: string;
|
||||
}): string {
|
||||
const briefing = boundText(
|
||||
redactNativeBootstrapContextText(params.briefing),
|
||||
MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS
|
||||
);
|
||||
return boundText(
|
||||
[
|
||||
'<agent_teams_native_bootstrap_context>',
|
||||
`Team: ${params.teamName}`,
|
||||
`Member: ${params.memberName}`,
|
||||
`Provider: ${params.providerId ?? 'anthropic'}`,
|
||||
`Project: ${params.cwd}`,
|
||||
'',
|
||||
'<member_briefing_context_data>',
|
||||
briefing,
|
||||
'</member_briefing_context_data>',
|
||||
'</agent_teams_native_bootstrap_context>',
|
||||
].join('\n'),
|
||||
MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
function buildLocalNativeMemberBriefing(params: {
|
||||
teamName: string;
|
||||
cwd: string;
|
||||
providerId?: TeamProviderId;
|
||||
member: TeamCreateRequest['members'][number];
|
||||
unavailableReason: string;
|
||||
}): string {
|
||||
const member = params.member;
|
||||
return [
|
||||
`You are ${member.name}, a teammate in team ${params.teamName}.`,
|
||||
`Provider: ${params.providerId ?? 'anthropic'}`,
|
||||
`Project: ${member.cwd?.trim() || params.cwd}`,
|
||||
member.role ? `Role: ${member.role}` : '',
|
||||
member.workflow ? `Workflow: ${member.workflow}` : '',
|
||||
member.model ? `Model: ${member.model}` : '',
|
||||
member.effort ? `Effort: ${member.effort}` : '',
|
||||
'',
|
||||
'The app loaded this startup context from the current team launch request because canonical member_briefing metadata was not available yet.',
|
||||
`Diagnostic: ${params.unavailableReason}`,
|
||||
'',
|
||||
'Startup rules:',
|
||||
'- Treat yourself as unavailable until the private bootstrap turn succeeds.',
|
||||
'- Do not call member_briefing for launch readiness in this flow.',
|
||||
'- Use Agent Teams messaging/task tools only after launch readiness is confirmed.',
|
||||
]
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export async function buildNativeAppManagedBootstrapSpecs(params: {
|
||||
teamName: string;
|
||||
cwd: string;
|
||||
members: TeamCreateRequest['members'];
|
||||
}): Promise<Map<string, NativeAppManagedBootstrapSpec>> {
|
||||
const controller = createController({
|
||||
teamName: params.teamName,
|
||||
claudeDir: getClaudeBasePath(),
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
const result = new Map<string, NativeAppManagedBootstrapSpec>();
|
||||
let totalContextChars = 0;
|
||||
|
||||
for (const member of params.members) {
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? 'anthropic';
|
||||
if (!isNativeAppManagedBootstrapProvider(providerId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let briefing: string;
|
||||
try {
|
||||
briefing = String(
|
||||
await controller.tasks.memberBriefing(member.name, {
|
||||
runtimeProvider: 'native',
|
||||
includeActiveProcesses: false,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes('Member not found in team metadata or inboxes')) {
|
||||
throw error;
|
||||
}
|
||||
// In createTeam, the orchestrator's canonical config/inboxes may not
|
||||
// exist until after the lead process runs. Fail-closed would break team
|
||||
// creation, so use bounded request metadata while keeping readiness tied
|
||||
// to the private bootstrap proof, never to this context load.
|
||||
briefing = buildLocalNativeMemberBriefing({
|
||||
teamName: params.teamName,
|
||||
cwd: params.cwd,
|
||||
providerId,
|
||||
member,
|
||||
unavailableReason: message,
|
||||
});
|
||||
}
|
||||
const boundedBriefing = boundText(
|
||||
redactNativeBootstrapContextText(briefing),
|
||||
MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS
|
||||
);
|
||||
if (!boundedBriefing) {
|
||||
throw new Error(`Native app-managed member briefing was empty for ${member.name}`);
|
||||
}
|
||||
const contextText = buildContextText({
|
||||
teamName: params.teamName,
|
||||
memberName: member.name,
|
||||
providerId,
|
||||
cwd: member.cwd?.trim() || params.cwd,
|
||||
briefing: boundedBriefing,
|
||||
});
|
||||
totalContextChars += contextText.length;
|
||||
if (totalContextChars > MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS) {
|
||||
throw new Error('Native app-managed bootstrap context exceeds aggregate size budget');
|
||||
}
|
||||
|
||||
result.set(member.name, {
|
||||
schemaVersion: 1,
|
||||
mode: 'startup_context_file',
|
||||
contextText,
|
||||
contextHash: hashNativeBootstrapText(contextText),
|
||||
briefingHash: hashNativeBootstrapText(boundedBriefing),
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
import type {
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
OpenCodeBootstrapMode,
|
||||
} from '@shared/types/team';
|
||||
|
||||
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
|
||||
export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION = 1 as const;
|
||||
export const OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION = 1 as const;
|
||||
|
||||
export type OpenCodeBridgeCommandName =
|
||||
| 'opencode.handshake'
|
||||
|
|
@ -65,6 +72,9 @@ export interface OpenCodeLaunchTeamCommandBody {
|
|||
export interface OpenCodeTeamMemberLaunchCommandData {
|
||||
sessionId: string;
|
||||
launchState: OpenCodeTeamMemberLaunchBridgeState;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
diagnostics?: string[];
|
||||
model: string;
|
||||
|
|
@ -166,6 +176,7 @@ export interface OpenCodeSendMessageCommandBody {
|
|||
| 'slash_command'
|
||||
| 'slash_command_result'
|
||||
| 'task_comment_notification'
|
||||
| 'task_stall_remediation'
|
||||
| 'member_work_sync_nudge'
|
||||
| 'agent_error';
|
||||
taskRefs?: { taskId: string; displayId: string; teamName: string }[];
|
||||
|
|
@ -373,6 +384,7 @@ export interface OpenCodeBridgePeerIdentity {
|
|||
currentVersion: number;
|
||||
supportedCommands: OpenCodeBridgeCommandName[];
|
||||
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||
opencodeAppManagedBootstrapContractVersion?: number;
|
||||
};
|
||||
runtime: {
|
||||
providerId: 'opencode';
|
||||
|
|
@ -591,6 +603,26 @@ export function validateOpenCodeBridgeHandshake(input: {
|
|||
return { ok: false, reason: `Bridge server does not support command ${input.requiredCommand}` };
|
||||
}
|
||||
|
||||
if (input.requiredCommand === 'opencode.launchTeam') {
|
||||
if (!input.expectedCapabilitySnapshotId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'OpenCode app-managed bootstrap launch requires a fresh capability snapshot before state-changing launch',
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.handshake.server.bridgeProtocol.opencodeAppManagedBootstrapContractVersion !==
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'OpenCode app-managed bootstrap is required, but the orchestrator does not advertise contract version 1. Update agent_teams_orchestrator and restart the app.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.expectedCapabilitySnapshotId &&
|
||||
input.handshake.server.runtime.capabilitySnapshotId !== input.expectedCapabilitySnapshotId
|
||||
|
|
@ -860,7 +892,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
|
|||
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) ||
|
||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined &&
|
||||
(!Number.isInteger(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion) ||
|
||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1))
|
||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1)) ||
|
||||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion !== undefined &&
|
||||
(!Number.isInteger(bridgeProtocol.opencodeAppManagedBootstrapContractVersion) ||
|
||||
(bridgeProtocol.opencodeAppManagedBootstrapContractVersion as number) < 1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import type {
|
|||
OpenCodeBridgeHandshake,
|
||||
OpenCodeBridgePeerIdentity,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import {
|
||||
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import type {
|
||||
OpenCodeBridgeCommandExecutor,
|
||||
OpenCodeBridgeHandshakePort,
|
||||
|
|
@ -96,6 +100,8 @@ export function createOpenCodeBridgeClientIdentity(input: {
|
|||
'opencode.recoverDeliveryJournal',
|
||||
'opencode.backfillTaskLedger',
|
||||
],
|
||||
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
opencodeAppManagedBootstrapContractVersion: OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||
},
|
||||
runtime: {
|
||||
providerId: 'opencode',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type {
|
||||
OpenCodePromptDeliveryLedgerRecord,
|
||||
OpenCodePromptDeliveryStatus,
|
||||
} from './OpenCodePromptDeliveryLedger';
|
||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
|
||||
export type OpenCodePromptDeliveryRepairKind =
|
||||
| 'none'
|
||||
| 'no_assistant_response'
|
||||
| 'visible_answer_required'
|
||||
| 'missing_visible_reply_correlation'
|
||||
| 'work_sync_report_required'
|
||||
| 'progress_proof_required'
|
||||
| 'app_materialization_pending';
|
||||
|
||||
export type OpenCodePromptDeliveryHardFailureKind = 'none' | 'session' | 'permission' | 'unknown';
|
||||
|
||||
export interface OpenCodePromptDeliveryRepairDecision {
|
||||
kind: OpenCodePromptDeliveryRepairKind;
|
||||
retryable: boolean;
|
||||
controlText: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface OpenCodePromptDeliveryRepairInput {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
inboxMessageId: string;
|
||||
replyRecipient: string;
|
||||
messageKind: InboxMessageKind | null;
|
||||
actionMode: AgentActionMode | null;
|
||||
taskRefs: TaskRef[];
|
||||
status: OpenCodePromptDeliveryStatus;
|
||||
responseState: OpenCodeDeliveryResponseState;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
pendingReason: string;
|
||||
readAllowed: boolean;
|
||||
inboxReadCommitted: boolean;
|
||||
visibleReplyFound: boolean;
|
||||
hasKnownProgressProof: boolean;
|
||||
toolCallNames: string[];
|
||||
acceptanceUnknown: boolean;
|
||||
hardFailureKind: OpenCodePromptDeliveryHardFailureKind;
|
||||
}
|
||||
|
||||
const SIDE_EFFECT_TOOL_NAMES = new Set([
|
||||
'bash',
|
||||
'edit',
|
||||
'write',
|
||||
'patch',
|
||||
'apply_patch',
|
||||
'multiedit',
|
||||
'multi_edit',
|
||||
]);
|
||||
|
||||
function none(reason: string): OpenCodePromptDeliveryRepairDecision {
|
||||
return { kind: 'none', retryable: false, controlText: null, reason };
|
||||
}
|
||||
|
||||
function control(
|
||||
input: OpenCodePromptDeliveryRepairInput,
|
||||
kind: Exclude<OpenCodePromptDeliveryRepairKind, 'none'>,
|
||||
reason: string,
|
||||
lines: string[]
|
||||
): OpenCodePromptDeliveryRepairDecision {
|
||||
const attemptNumber = Math.min(Math.max(input.attempts + 1, 1), input.maxAttempts);
|
||||
return {
|
||||
kind,
|
||||
retryable: true,
|
||||
reason,
|
||||
controlText: [
|
||||
'<opencode_delivery_retry>',
|
||||
`Retry attempt ${attemptNumber}/${input.maxAttempts} for inbound app messageId "${input.inboxMessageId}".`,
|
||||
...lines,
|
||||
'</opencode_delivery_retry>',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolName(toolName: string): string {
|
||||
return toolName
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^mcp__agent[-_]teams__/, '')
|
||||
.replace(/^agent[-_]teams_/, '')
|
||||
.replace(/^mcp__agent_teams__/, '')
|
||||
.replace(/^agent_teams_/, '');
|
||||
}
|
||||
|
||||
function normalizedToolNames(input: OpenCodePromptDeliveryRepairInput): Set<string> {
|
||||
return new Set(input.toolCallNames.map(normalizeToolName).filter(Boolean));
|
||||
}
|
||||
|
||||
function hasTool(tools: Set<string>, toolName: string): boolean {
|
||||
return tools.has(toolName);
|
||||
}
|
||||
|
||||
function hasTaskTool(tools: Set<string>): boolean {
|
||||
for (const tool of tools) {
|
||||
if (tool.startsWith('task_') || tool === 'runtime_task_event') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasSideEffectTool(tools: Set<string>): boolean {
|
||||
for (const tool of tools) {
|
||||
if (SIDE_EFFECT_TOOL_NAMES.has(tool)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function taskIdList(taskRefs: TaskRef[]): string | null {
|
||||
const ids = [
|
||||
...new Set(
|
||||
taskRefs
|
||||
.map((taskRef) => taskRef.taskId?.trim())
|
||||
.filter((taskId): taskId is string => Boolean(taskId))
|
||||
),
|
||||
];
|
||||
return ids.length > 0 ? ids.map((id) => `"${id}"`).join(', ') : null;
|
||||
}
|
||||
|
||||
function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
|
||||
const replyRecipient = input.replyRecipient.trim() || 'user';
|
||||
return [
|
||||
'The app still has no correlated visible reply proof for this message.',
|
||||
`Call agent-teams_message_send or mcp__agent-teams__message_send exactly once with teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", and relayOfMessageId="${input.inboxMessageId}".`,
|
||||
'Use a concrete answer in text and summary. Do not reply only with acknowledgement.',
|
||||
'After the message_send tool succeeds, stop this turn. Do not repeat task/tool work unless the inbound message explicitly asks for new work.',
|
||||
];
|
||||
}
|
||||
|
||||
function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
|
||||
const taskIds = taskIdList(input.taskRefs);
|
||||
return [
|
||||
'This is a member-work-sync control message. A plain acknowledgement is not sufficient proof.',
|
||||
`Call agent-teams_member_work_sync_status or mcp__agent-teams__member_work_sync_status with teamName="${input.teamName}" and memberName="${input.memberName}".`,
|
||||
'Then call agent-teams_member_work_sync_report or mcp__agent-teams__member_work_sync_report using the agendaFingerprint/reportToken returned by status.',
|
||||
taskIds ? `Include taskIds ${taskIds} when reporting if those tasks are still relevant.` : null,
|
||||
'Use state "still_working", "blocked", or "caught_up" according to the status result. Do not invent or reuse a raw report token from this retry text.',
|
||||
].filter((line): line is string => line !== null);
|
||||
}
|
||||
|
||||
function progressControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
|
||||
const taskIds = taskIdList(input.taskRefs);
|
||||
return [
|
||||
'The app saw a tool/action response, but no accepted progress proof for this message.',
|
||||
taskIds
|
||||
? `Produce concrete task/progress proof for taskIds ${taskIds}, or send a visible status reply with relayOfMessageId="${input.inboxMessageId}".`
|
||||
: `Send a concrete visible status reply with relayOfMessageId="${input.inboxMessageId}".`,
|
||||
'Do not repeat side-effectful commands, edits, or writes just because this is a retry.',
|
||||
'If work is blocked, report the blocker instead of silently ending the turn.',
|
||||
];
|
||||
}
|
||||
|
||||
function noAssistantControlLines(input: OpenCodePromptDeliveryRepairInput): string[] {
|
||||
return [
|
||||
'The app saw the prompt but did not observe assistant response proof.',
|
||||
'You must not end this turn empty.',
|
||||
input.messageKind === 'member_work_sync_nudge'
|
||||
? 'Follow the member-work-sync status/report instructions for this message.'
|
||||
: `Send a concrete reply using message_send with relayOfMessageId="${input.inboxMessageId}", or provide a concrete plain-text answer only if message_send is unavailable.`,
|
||||
];
|
||||
}
|
||||
|
||||
function toolErrorControl(input: OpenCodePromptDeliveryRepairInput) {
|
||||
const tools = normalizedToolNames(input);
|
||||
if (hasTool(tools, 'message_send')) {
|
||||
return control(
|
||||
input,
|
||||
'missing_visible_reply_correlation',
|
||||
'message_send_tool_error_without_visible_reply_proof',
|
||||
messageSendControlLines(input)
|
||||
);
|
||||
}
|
||||
if (hasTool(tools, 'member_work_sync_report') || hasTool(tools, 'member_work_sync_status')) {
|
||||
return control(
|
||||
input,
|
||||
'work_sync_report_required',
|
||||
'member_work_sync_tool_error_without_report_proof',
|
||||
workSyncControlLines(input)
|
||||
);
|
||||
}
|
||||
if (hasSideEffectTool(tools)) {
|
||||
return control(
|
||||
input,
|
||||
'progress_proof_required',
|
||||
'side_effect_tool_error_without_progress_proof',
|
||||
progressControlLines(input)
|
||||
);
|
||||
}
|
||||
if (hasTaskTool(tools)) {
|
||||
return control(
|
||||
input,
|
||||
'progress_proof_required',
|
||||
'task_tool_error_without_progress_proof',
|
||||
progressControlLines(input)
|
||||
);
|
||||
}
|
||||
return control(
|
||||
input,
|
||||
'progress_proof_required',
|
||||
'tool_error_without_required_delivery_proof',
|
||||
progressControlLines(input)
|
||||
);
|
||||
}
|
||||
|
||||
export function decideOpenCodePromptDeliveryRepair(
|
||||
input: OpenCodePromptDeliveryRepairInput
|
||||
): OpenCodePromptDeliveryRepairDecision {
|
||||
if (input.readAllowed) {
|
||||
return none('read_commit_allowed');
|
||||
}
|
||||
if (input.inboxReadCommitted) {
|
||||
return none('inbox_read_already_committed');
|
||||
}
|
||||
if (input.status === 'failed_terminal') {
|
||||
return none('terminal_record');
|
||||
}
|
||||
if (input.attempts >= input.maxAttempts) {
|
||||
return none('max_attempts_reached');
|
||||
}
|
||||
if (input.hardFailureKind !== 'none') {
|
||||
return none(`hard_failure:${input.hardFailureKind}`);
|
||||
}
|
||||
if (input.status === 'pending' && input.attempts <= 0 && !input.acceptanceUnknown) {
|
||||
return none('initial_delivery');
|
||||
}
|
||||
|
||||
if (input.acceptanceUnknown) {
|
||||
return control(input, 'no_assistant_response', 'acceptance_unknown', [
|
||||
'The app could not confirm whether the previous OpenCode prompt was accepted.',
|
||||
'Process the inbound message now. If you already completed it, send only the missing proof and do not duplicate side effects.',
|
||||
input.messageKind === 'member_work_sync_nudge'
|
||||
? 'For work-sync, use member_work_sync_status then member_work_sync_report.'
|
||||
: `For visible replies, use relayOfMessageId="${input.inboxMessageId}".`,
|
||||
]);
|
||||
}
|
||||
|
||||
if (input.messageKind === 'member_work_sync_nudge') {
|
||||
return control(
|
||||
input,
|
||||
'work_sync_report_required',
|
||||
input.pendingReason,
|
||||
workSyncControlLines(input)
|
||||
);
|
||||
}
|
||||
|
||||
if (input.pendingReason === 'plain_text_visible_reply_not_materialized_yet') {
|
||||
return {
|
||||
kind: 'app_materialization_pending',
|
||||
retryable: false,
|
||||
controlText: null,
|
||||
reason: input.pendingReason,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
input.pendingReason === 'visible_reply_destination_not_found_yet' ||
|
||||
input.pendingReason === 'visible_reply_missing_relayOfMessageId' ||
|
||||
input.pendingReason === 'visible_reply_still_required' ||
|
||||
(input.responseState === 'responded_visible_message' && !input.visibleReplyFound)
|
||||
) {
|
||||
return control(
|
||||
input,
|
||||
'missing_visible_reply_correlation',
|
||||
input.pendingReason,
|
||||
messageSendControlLines(input)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
input.pendingReason === 'visible_reply_ack_only_still_requires_answer' ||
|
||||
input.pendingReason === 'plain_text_ack_only_still_requires_answer'
|
||||
) {
|
||||
return control(input, 'visible_answer_required', input.pendingReason, [
|
||||
'The previous response looked like acknowledgement only, not a concrete answer.',
|
||||
...messageSendControlLines(input),
|
||||
]);
|
||||
}
|
||||
|
||||
if (input.responseState === 'tool_error') {
|
||||
return toolErrorControl(input);
|
||||
}
|
||||
|
||||
if (
|
||||
input.responseState === 'empty_assistant_turn' ||
|
||||
input.responseState === 'prompt_delivered_no_assistant_message' ||
|
||||
input.responseState === 'not_observed' ||
|
||||
input.responseState === 'reconcile_failed'
|
||||
) {
|
||||
return control(
|
||||
input,
|
||||
'no_assistant_response',
|
||||
input.pendingReason,
|
||||
noAssistantControlLines(input)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(input.responseState === 'responded_non_visible_tool' ||
|
||||
input.responseState === 'responded_tool_call') &&
|
||||
!input.hasKnownProgressProof
|
||||
) {
|
||||
return control(
|
||||
input,
|
||||
'progress_proof_required',
|
||||
input.pendingReason,
|
||||
progressControlLines(input)
|
||||
);
|
||||
}
|
||||
|
||||
return none(input.pendingReason || 'no_repair_needed');
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
|
||||
|
||||
const SECRET_VALUE_PATTERNS = [
|
||||
/\bsk-[A-Z0-9_-]{12,}\b/gi,
|
||||
/\b[A-Z0-9_-]*api[_-]?key[A-Z0-9_-]*[=:]\s*['"]?[^'"\s]+/gi,
|
||||
/\bauthorization:\s*bearer\s+[^'"\s]+/gi,
|
||||
] as const;
|
||||
|
||||
const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [
|
||||
'opencode app mcp was reattached before message delivery',
|
||||
'reattached stale opencode app mcp server',
|
||||
'opencode session reconcile skipped because the stored session is stale',
|
||||
'recreated opencode session before message delivery',
|
||||
'opencode message delivery observe bridge failed',
|
||||
'opencode bridge command timed out',
|
||||
'opencode bootstrap mcp did not complete required tools before assistant response',
|
||||
'existing app mcp config does not expose environment',
|
||||
'empty_assistant_turn',
|
||||
'visible_reply_still_required',
|
||||
'prompt_delivered_no_assistant_message',
|
||||
'plain_text_ack_only_still_requires_answer',
|
||||
'visible_reply_ack_only_still_requires_answer',
|
||||
'visible_reply_destination_not_found_yet',
|
||||
'visible_reply_missing_relayofmessageid',
|
||||
'non_visible_tool_without_task_progress',
|
||||
] as const;
|
||||
|
||||
const ACTION_REQUIRED_DELIVERY_ERROR_TOKENS = [
|
||||
'auth_unavailable',
|
||||
'no auth available',
|
||||
'authentication_failed',
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'invalid api key',
|
||||
'api key',
|
||||
'does not have access',
|
||||
'please run /login',
|
||||
'insufficient credits',
|
||||
'quota exceeded',
|
||||
'quota exhausted',
|
||||
'capacity exceeded',
|
||||
'key limit exceeded',
|
||||
'total limit',
|
||||
] as const;
|
||||
|
||||
export function normalizeOpenCodeRuntimeDeliveryDiagnostic(
|
||||
message: string | null | undefined
|
||||
): string | null {
|
||||
const scrubbed = SECRET_VALUE_PATTERNS.reduce(
|
||||
(current, pattern) => current.replace(pattern, '[redacted]'),
|
||||
message ?? ''
|
||||
);
|
||||
const normalized = scrubbed
|
||||
?.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
|
||||
.replace(/^APIError\s*[-:]\s*/i, '');
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function isGenericOpenCodeRuntimeDeliveryDiagnostic(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return GENERIC_DELIVERY_DIAGNOSTIC_TOKENS.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
export function selectOpenCodeRuntimeDeliveryReason(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): string | null {
|
||||
const candidates = [...record.diagnostics.slice().reverse(), record.lastReason];
|
||||
const normalized = candidates.flatMap((candidate) => {
|
||||
const message = normalizeOpenCodeRuntimeDeliveryDiagnostic(candidate);
|
||||
return message ? [message] : [];
|
||||
});
|
||||
const specific = normalized.find(
|
||||
(message) => !isGenericOpenCodeRuntimeDeliveryDiagnostic(message)
|
||||
);
|
||||
if (specific) {
|
||||
return boundOpenCodeRuntimeDeliveryReason(specific);
|
||||
}
|
||||
|
||||
const fallback = getOpenCodeRuntimeDeliveryStateFallback(record);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return normalized.length > 0 ? 'OpenCode runtime delivery did not complete.' : null;
|
||||
}
|
||||
|
||||
export function isActionRequiredOpenCodeRuntimeDeliveryReason(
|
||||
message: string | null | undefined
|
||||
): boolean {
|
||||
const normalized = normalizeOpenCodeRuntimeDeliveryDiagnostic(message)?.toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return ACTION_REQUIRED_DELIVERY_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
function getOpenCodeRuntimeDeliveryStateFallback(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): string | null {
|
||||
const state = record.responseState?.trim();
|
||||
const reason = record.lastReason?.trim();
|
||||
if (state === 'empty_assistant_turn' || reason === 'empty_assistant_turn') {
|
||||
return 'OpenCode returned an empty assistant turn.';
|
||||
}
|
||||
if (
|
||||
reason === 'visible_reply_still_required' ||
|
||||
reason === 'visible_reply_ack_only_still_requires_answer' ||
|
||||
reason === 'plain_text_ack_only_still_requires_answer'
|
||||
) {
|
||||
return 'OpenCode responded, but did not create a visible message_send reply.';
|
||||
}
|
||||
if (
|
||||
state === 'prompt_delivered_no_assistant_message' ||
|
||||
reason === 'prompt_delivered_no_assistant_message'
|
||||
) {
|
||||
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
|
||||
}
|
||||
if (
|
||||
reason === 'visible_reply_destination_not_found_yet' ||
|
||||
reason === 'visible_reply_missing_relayOfMessageId'
|
||||
) {
|
||||
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
|
||||
}
|
||||
if (reason === 'non_visible_tool_without_task_progress') {
|
||||
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function boundOpenCodeRuntimeDeliveryReason(reason: string): string {
|
||||
return reason.length > 500 ? `${reason.slice(0, 497).trimEnd()}...` : reason;
|
||||
}
|
||||
|
|
@ -15,6 +15,10 @@ import {
|
|||
validateRuntimeStoreManifest,
|
||||
} from './RuntimeStoreManifest';
|
||||
|
||||
import type {
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
} from '@shared/types/team';
|
||||
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
|
||||
|
|
@ -65,7 +69,8 @@ export interface OpenCodeCommittedBootstrapSessionRecord {
|
|||
laneId: string;
|
||||
runId: string | null;
|
||||
observedAt: string | null;
|
||||
source: 'runtime_bootstrap_checkin';
|
||||
source: OpenCodeBootstrapEvidenceSource;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
}
|
||||
|
||||
export interface OpenCodeCommittedBootstrapSessionEvidence {
|
||||
|
|
@ -301,10 +306,20 @@ function normalizeOpenCodeBootstrapSessionRecord(
|
|||
const memberName = normalizeNonEmptyStoreString(record.memberName);
|
||||
const laneId = normalizeNonEmptyStoreString(record.laneId);
|
||||
const source = normalizeNonEmptyStoreString(record.source);
|
||||
if (!id || !teamName || !memberName || !laneId || source !== 'runtime_bootstrap_checkin') {
|
||||
if (
|
||||
!id ||
|
||||
!teamName ||
|
||||
!memberName ||
|
||||
!laneId ||
|
||||
(source !== 'runtime_bootstrap_checkin' && source !== 'app_managed_bootstrap')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const observedAt = normalizeOptionalStoreIso(record.observedAt);
|
||||
const appManagedBootstrapCandidate =
|
||||
source === 'app_managed_bootstrap'
|
||||
? normalizeAppManagedBootstrapCandidate(record.appManagedBootstrapCandidate)
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
teamName,
|
||||
|
|
@ -312,7 +327,62 @@ function normalizeOpenCodeBootstrapSessionRecord(
|
|||
laneId,
|
||||
runId: normalizeNonEmptyStoreString(record.runId),
|
||||
observedAt,
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
source,
|
||||
...(appManagedBootstrapCandidate ? { appManagedBootstrapCandidate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAppManagedBootstrapCandidate(
|
||||
value: unknown
|
||||
): OpenCodeAppManagedBootstrapCandidate | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.schemaVersion !== 1 || record.source !== 'app_managed_bootstrap') {
|
||||
return undefined;
|
||||
}
|
||||
const teamName = normalizeNonEmptyStoreString(record.teamName);
|
||||
const memberName = normalizeNonEmptyStoreString(record.memberName);
|
||||
const runId = normalizeNonEmptyStoreString(record.runId);
|
||||
const laneId = normalizeNonEmptyStoreString(record.laneId);
|
||||
const runtimeSessionId = normalizeNonEmptyStoreString(record.runtimeSessionId);
|
||||
const messageID = normalizeNonEmptyStoreString(record.messageID);
|
||||
const contextHash = normalizeNonEmptyStoreString(record.contextHash);
|
||||
const briefingHash = normalizeNonEmptyStoreString(record.briefingHash);
|
||||
const injectionVerifiedAt = normalizeNonEmptyStoreString(record.injectionVerifiedAt);
|
||||
const candidateAt = normalizeNonEmptyStoreString(record.candidateAt);
|
||||
if (
|
||||
!teamName ||
|
||||
!memberName ||
|
||||
!runId ||
|
||||
!laneId ||
|
||||
!runtimeSessionId ||
|
||||
!messageID ||
|
||||
!contextHash ||
|
||||
!briefingHash ||
|
||||
!injectionVerifiedAt ||
|
||||
!candidateAt
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const model = normalizeNonEmptyStoreString(record.model);
|
||||
const agent = normalizeNonEmptyStoreString(record.agent);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'app_managed_bootstrap',
|
||||
teamName,
|
||||
memberName,
|
||||
runId,
|
||||
laneId,
|
||||
runtimeSessionId,
|
||||
messageID,
|
||||
contextHash,
|
||||
briefingHash,
|
||||
injectionVerifiedAt,
|
||||
candidateAt,
|
||||
...(model ? { model } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -560,7 +630,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: {
|
|||
}
|
||||
);
|
||||
if (sessions.length === 0) {
|
||||
diagnostics.push('OpenCode session store has no committed bootstrap check-in sessions.');
|
||||
diagnostics.push('OpenCode session store has no committed bootstrap sessions.');
|
||||
}
|
||||
return {
|
||||
state: 'healthy',
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ import type {
|
|||
TeamRuntimeStopInput,
|
||||
TeamRuntimeStopResult,
|
||||
} from './TeamRuntimeAdapter';
|
||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
||||
import type {
|
||||
AgentActionMode,
|
||||
InboxMessageKind,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
TaskRef,
|
||||
} from '@shared/types/team';
|
||||
|
||||
export interface OpenCodeTeamRuntimeBridgePort {
|
||||
checkOpenCodeTeamLaunchReadiness(input: {
|
||||
|
|
@ -169,6 +174,15 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
const runtimeSnapshot = skipReadinessPreflight
|
||||
? null
|
||||
: (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null);
|
||||
if (
|
||||
!skipReadinessPreflight &&
|
||||
this.bridge.getLastOpenCodeRuntimeSnapshot &&
|
||||
!runtimeSnapshot?.capabilitySnapshotId
|
||||
) {
|
||||
return blockedLaunchResult(input, 'opencode_capability_snapshot_missing', [
|
||||
'OpenCode app-managed launch requires a fresh capability snapshot before state-changing launch.',
|
||||
]);
|
||||
}
|
||||
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||
const data = await this.bridge.launchOpenCodeTeam({
|
||||
runId: input.runId,
|
||||
|
|
@ -457,18 +471,24 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
checkpointNames.has(name)
|
||||
);
|
||||
const bridgeReady = data.teamLaunchState === 'ready';
|
||||
const isExpectedMemberConfirmed = (memberName: string): boolean => {
|
||||
const bridgeMember = data.members[memberName];
|
||||
return bridgeMember?.launchState === 'confirmed_alive';
|
||||
};
|
||||
const missingExpectedMembers = input.expectedMembers
|
||||
.map((member) => member.name)
|
||||
.filter((memberName) => data.members[memberName] == null);
|
||||
const unconfirmedExpectedMembers = input.expectedMembers
|
||||
.map((member) => member.name)
|
||||
.filter((memberName) => data.members[memberName]?.launchState !== 'confirmed_alive');
|
||||
.filter((memberName) => !isExpectedMemberConfirmed(memberName));
|
||||
const anyExpectedMemberFailed = input.expectedMembers.some(
|
||||
(member) => data.members[member.name]?.launchState === 'failed'
|
||||
);
|
||||
const allExpectedMembersConfirmed =
|
||||
input.expectedMembers.length > 0 && unconfirmedExpectedMembers.length === 0;
|
||||
const success = bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed;
|
||||
const success =
|
||||
(bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed) ||
|
||||
(data.teamLaunchState === 'launching' && allExpectedMembersConfirmed);
|
||||
const checkpointDiagnostic = success
|
||||
? []
|
||||
: bridgeReady && !readyCheckpointsPresent
|
||||
|
|
@ -522,6 +542,12 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
bridgeMember?.pendingPermissionRequestIds,
|
||||
bridgeMember != null,
|
||||
memberDiagnostics,
|
||||
input.runId,
|
||||
input.laneId?.trim() || 'primary',
|
||||
input.teamName,
|
||||
bridgeMember?.bootstrapEvidenceSource,
|
||||
bridgeMember?.bootstrapMode,
|
||||
bridgeMember?.appManagedBootstrapCandidate,
|
||||
selectOpenCodeMemberFailureReason({
|
||||
memberDiagnostics: bridgeMember?.diagnostics ?? [],
|
||||
bridgeDiagnostics: data.diagnostics,
|
||||
|
|
@ -556,6 +582,61 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
};
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function normalizeAppManagedBootstrapCandidate(
|
||||
value: OpenCodeAppManagedBootstrapCandidate | undefined,
|
||||
expected: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
runtimeSessionId?: string;
|
||||
}
|
||||
): OpenCodeAppManagedBootstrapCandidate | undefined {
|
||||
if (!value || value.schemaVersion !== 1 || value.source !== 'app_managed_bootstrap') {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
value.teamName !== expected.teamName ||
|
||||
value.memberName !== expected.memberName ||
|
||||
value.runId !== expected.runId ||
|
||||
value.laneId !== expected.laneId ||
|
||||
(expected.runtimeSessionId && value.runtimeSessionId !== expected.runtimeSessionId)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!isNonEmptyString(value.runtimeSessionId) ||
|
||||
!isNonEmptyString(value.messageID) ||
|
||||
!value.messageID.startsWith('msg') ||
|
||||
!isNonEmptyString(value.contextHash) ||
|
||||
!isNonEmptyString(value.briefingHash) ||
|
||||
!isNonEmptyString(value.injectionVerifiedAt) ||
|
||||
!isNonEmptyString(value.candidateAt)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'app_managed_bootstrap',
|
||||
teamName: value.teamName,
|
||||
memberName: value.memberName,
|
||||
runId: value.runId,
|
||||
laneId: value.laneId,
|
||||
runtimeSessionId: value.runtimeSessionId,
|
||||
messageID: value.messageID,
|
||||
contextHash: value.contextHash,
|
||||
briefingHash: value.briefingHash,
|
||||
injectionVerifiedAt: value.injectionVerifiedAt,
|
||||
candidateAt: value.candidateAt,
|
||||
...(isNonEmptyString(value.model) ? { model: value.model } : {}),
|
||||
...(isNonEmptyString(value.agent) ? { agent: value.agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapBridgeMemberToRuntimeEvidence(
|
||||
memberName: string,
|
||||
launchState: OpenCodeTeamMemberLaunchBridgeState,
|
||||
|
|
@ -564,8 +645,30 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
pendingPermissionRequestIds: string[] | undefined,
|
||||
runtimeMaterialized: boolean,
|
||||
diagnostics: string[],
|
||||
runId: string,
|
||||
laneId: string,
|
||||
teamName: string,
|
||||
bootstrapEvidenceSource: TeamRuntimeMemberLaunchEvidence['bootstrapEvidenceSource'] | undefined,
|
||||
bootstrapMode: TeamRuntimeMemberLaunchEvidence['bootstrapMode'] | undefined,
|
||||
appManagedBootstrapCandidate: OpenCodeAppManagedBootstrapCandidate | undefined,
|
||||
selectedHardFailureReason: string
|
||||
): TeamRuntimeMemberLaunchEvidence {
|
||||
const normalizedAppManagedCandidate = normalizeAppManagedBootstrapCandidate(
|
||||
appManagedBootstrapCandidate,
|
||||
{
|
||||
teamName,
|
||||
memberName,
|
||||
runId,
|
||||
laneId,
|
||||
runtimeSessionId: sessionId,
|
||||
}
|
||||
);
|
||||
const appManagedCandidatePresent =
|
||||
launchState === 'created' &&
|
||||
isNonEmptyString(sessionId) &&
|
||||
bootstrapEvidenceSource === 'app_managed_bootstrap' &&
|
||||
bootstrapMode === 'app_managed_context' &&
|
||||
normalizedAppManagedCandidate != null;
|
||||
const confirmed = launchState === 'confirmed_alive';
|
||||
const failed = launchState === 'failed';
|
||||
const hasRuntimePid =
|
||||
|
|
@ -580,20 +683,24 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
: launchState === 'permission_blocked'
|
||||
? 'permission_blocked'
|
||||
: 'registered_only';
|
||||
const runtimeDiagnostic = pendingRuntimeObserved
|
||||
? hasRuntimePid
|
||||
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||
: 'OpenCode session exists without verified runtime pid'
|
||||
: launchState === 'permission_blocked'
|
||||
? 'OpenCode runtime is waiting for permission approval'
|
||||
: runtimeMaterialized
|
||||
? 'OpenCode bridge did not report a runtime session or pid for this member'
|
||||
const runtimeDiagnostic = appManagedCandidatePresent
|
||||
? 'OpenCode app-managed bootstrap context was injected and verified by the bridge; waiting for app-owned durable evidence commit.'
|
||||
: pendingRuntimeObserved
|
||||
? hasRuntimePid
|
||||
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||
: 'OpenCode session exists without verified runtime pid'
|
||||
: launchState === 'permission_blocked'
|
||||
? 'OpenCode runtime is waiting for permission approval'
|
||||
: runtimeMaterialized
|
||||
? 'OpenCode bridge did not report a runtime session or pid for this member'
|
||||
: undefined;
|
||||
const runtimeDiagnosticSeverity = appManagedCandidatePresent
|
||||
? 'info'
|
||||
: failed
|
||||
? 'error'
|
||||
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
|
||||
? 'warning'
|
||||
: undefined;
|
||||
const runtimeDiagnosticSeverity = failed
|
||||
? 'error'
|
||||
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
|
||||
? 'warning'
|
||||
: undefined;
|
||||
return {
|
||||
memberName,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -618,6 +725,13 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
? [...new Set(pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
sessionId,
|
||||
...(appManagedCandidatePresent
|
||||
? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const }
|
||||
: {}),
|
||||
...(appManagedCandidatePresent ? { bootstrapMode: 'app_managed_context' as const } : {}),
|
||||
...(normalizedAppManagedCandidate
|
||||
? { appManagedBootstrapCandidate: normalizedAppManagedCandidate }
|
||||
: {}),
|
||||
...(hasRuntimePid ? { runtimePid } : {}),
|
||||
livenessKind,
|
||||
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
|
||||
|
|
@ -725,24 +839,24 @@ function buildMemberBootstrapPrompt(
|
|||
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
|
||||
const workflow = member.workflow?.trim();
|
||||
return [
|
||||
'<agent_teams_app_managed_bootstrap_briefing>',
|
||||
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
|
||||
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
|
||||
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
|
||||
workflow ? `Workflow:\n${workflow}` : null,
|
||||
'',
|
||||
'This OpenCode session is already attached by the desktop app. Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
|
||||
'This OpenCode session is created, attached, and launch-verified by the desktop app.',
|
||||
'Do not call runtime_bootstrap_checkin or member_briefing just to prove launch readiness.',
|
||||
'Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
|
||||
'Use the app MCP tools exposed by the "agent-teams" server for team communication and task state.',
|
||||
'The desktop bridge may prepend runtime identity and bootstrap instructions. Follow those first.',
|
||||
'After runtime identity check-in, if you have not already done so, call MCP tool agent-teams_member_briefing (or mcp__agent-teams__member_briefing if that is the exposed name) with:',
|
||||
`{ "teamName": "${input.teamName}", "memberName": "${member.name}", "runtimeProvider": "opencode" }`,
|
||||
'If that tool is not available, stay idle and wait for app-delivered instructions. Do not improvise a replacement workflow.',
|
||||
'Launch bootstrap is a silent attach, not a user/team conversation turn.',
|
||||
'After runtime_bootstrap_checkin and member_briefing both succeed, stop this turn immediately and wait for app-delivered messages or actionable task assignments.',
|
||||
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
|
||||
'If the briefing says there are no actionable tasks, stay idle silently.',
|
||||
'',
|
||||
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
|
||||
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
|
||||
'Do not answer team/app messages only as plain assistant text when agent-teams_message_send is available.',
|
||||
'</agent_teams_app_managed_bootstrap_briefing>',
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
|
|
@ -792,6 +906,10 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
input.taskRefs
|
||||
?.map((ref) => ref.taskId?.trim())
|
||||
.filter((taskId): taskId is string => Boolean(taskId)) ?? [];
|
||||
// Work-sync nudges are health/reporting probes. Requiring a visible
|
||||
// message_send reply here causes false delivery failures, so accept the
|
||||
// dedicated member_work_sync_report proof path while keeping normal user
|
||||
// messages on the visible reply contract.
|
||||
const responseInstructions = isWorkSyncNudge
|
||||
? [
|
||||
'This delivered app message is a member-work-sync nudge.',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type {
|
||||
EffortLevel,
|
||||
MemberLaunchState,
|
||||
OpenCodeAppManagedBootstrapCandidate,
|
||||
OpenCodeBootstrapEvidenceSource,
|
||||
OpenCodeBootstrapMode,
|
||||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamAgentRuntimeBackendType,
|
||||
|
|
@ -79,6 +82,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
|||
hardFailureReason?: string;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
sessionId?: string;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
backendType?: TeamAgentRuntimeBackendType;
|
||||
runtimePid?: number;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ export async function resolveDesktopTeammateModeDecision(
|
|||
};
|
||||
}
|
||||
|
||||
const tmuxAvailable = await isTmuxAvailable();
|
||||
await isTmuxAvailable();
|
||||
|
||||
return {
|
||||
injectedTeammateMode: tmuxAvailable ? 'tmux' : null,
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export class TeamTaskStallNotifier {
|
|||
taskRefs: [args.alert.taskRef],
|
||||
actionMode: 'do',
|
||||
source: 'system_notification',
|
||||
messageKind: 'task_stall_remediation',
|
||||
};
|
||||
await this.inboxWriter.sendMessage(args.teamName, request);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'
|
|||
import { TeamKanbanManager } from '../TeamKanbanManager';
|
||||
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
|
||||
import { TeamTaskReader } from '../TeamTaskReader';
|
||||
import { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked } from '../teamTaskActiveState';
|
||||
|
||||
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
||||
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
|
||||
|
|
@ -87,12 +88,47 @@ export class TeamTaskStallSnapshotSource {
|
|||
this.kanbanManager.getState(teamName),
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
]);
|
||||
const allTasks = [...activeTasks, ...deletedTasks];
|
||||
const withWorkflowOverlay = (task: TeamTask): TeamTask => {
|
||||
const kanbanColumn = kanbanState.tasks[task.id]?.column;
|
||||
const workflowColumn = getTeamTaskWorkflowColumn({
|
||||
...task,
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
});
|
||||
if (workflowColumn) {
|
||||
return task.reviewState !== workflowColumn
|
||||
? { ...task, reviewState: workflowColumn }
|
||||
: task;
|
||||
}
|
||||
return task.reviewState === 'review' || task.reviewState === 'approved'
|
||||
? { ...task, reviewState: 'none' }
|
||||
: task;
|
||||
};
|
||||
const workflowActiveTasks = activeTasks.map(withWorkflowOverlay);
|
||||
const allTasks = [...workflowActiveTasks, ...deletedTasks];
|
||||
const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const));
|
||||
const inProgressTasks = activeTasks.filter(
|
||||
(task) => task.status === 'in_progress' && task.reviewState !== 'review'
|
||||
);
|
||||
const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review');
|
||||
const inProgressTasks = workflowActiveTasks.filter((task) => {
|
||||
const kanbanColumn = kanbanState.tasks[task.id]?.column;
|
||||
const workflowColumn = getTeamTaskWorkflowColumn({
|
||||
...task,
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
});
|
||||
return (
|
||||
workflowColumn !== 'review' &&
|
||||
isTeamTaskActivelyWorked({
|
||||
...task,
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
})
|
||||
);
|
||||
});
|
||||
const reviewOpenTasks = workflowActiveTasks.filter((task) => {
|
||||
const kanbanColumn = kanbanState.tasks[task.id]?.column;
|
||||
return (
|
||||
getTeamTaskWorkflowColumn({
|
||||
...task,
|
||||
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||
}) === 'review'
|
||||
);
|
||||
});
|
||||
const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState);
|
||||
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
|
||||
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
|
||||
|
|
@ -157,7 +193,7 @@ export class TeamTaskStallSnapshotSource {
|
|||
transcriptFiles: transcriptContext.transcriptFiles,
|
||||
activityReadsEnabled,
|
||||
exactReadsEnabled,
|
||||
activeTasks,
|
||||
activeTasks: workflowActiveTasks,
|
||||
deletedTasks,
|
||||
allTasksById,
|
||||
inProgressTasks,
|
||||
|
|
|
|||
70
src/main/services/team/teamTaskActiveState.ts
Normal file
70
src/main/services/team/teamTaskActiveState.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
export {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinalForCompletionNotification,
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
isTeamTaskTerminalForActionableWork,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
function parseIsoTime(value: string | undefined): number {
|
||||
if (!value) return 0;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getActiveWorkStartedAt(task: TeamTaskWithKanban): number {
|
||||
const workIntervals = task.workIntervals ?? [];
|
||||
for (let index = workIntervals.length - 1; index >= 0; index--) {
|
||||
const interval = workIntervals[index];
|
||||
if (interval && !interval.completedAt) {
|
||||
const startedAt = parseIsoTime(interval.startedAt);
|
||||
if (startedAt > 0) {
|
||||
return startedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const historyEvents = task.historyEvents ?? [];
|
||||
for (let index = historyEvents.length - 1; index >= 0; index--) {
|
||||
const event = historyEvents[index];
|
||||
if (event?.type === 'status_changed' && event.to === 'in_progress') {
|
||||
const startedAt = parseIsoTime(event.timestamp);
|
||||
if (startedAt > 0) {
|
||||
return startedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(parseIsoTime(task.updatedAt), parseIsoTime(task.createdAt));
|
||||
}
|
||||
|
||||
function compareCurrentActiveTasks(left: TeamTaskWithKanban, right: TeamTaskWithKanban): number {
|
||||
const byStartedAt = getActiveWorkStartedAt(right) - getActiveWorkStartedAt(left);
|
||||
if (byStartedAt !== 0) return byStartedAt;
|
||||
|
||||
const byUpdatedAt = parseIsoTime(right.updatedAt) - parseIsoTime(left.updatedAt);
|
||||
if (byUpdatedAt !== 0) return byUpdatedAt;
|
||||
|
||||
const byCreatedAt = parseIsoTime(right.createdAt) - parseIsoTime(left.createdAt);
|
||||
if (byCreatedAt !== 0) return byCreatedAt;
|
||||
|
||||
const leftLabel = left.displayId ?? left.id;
|
||||
const rightLabel = right.displayId ?? right.id;
|
||||
return leftLabel.localeCompare(rightLabel, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
export function selectCurrentActiveTeamTask<T extends TeamTaskWithKanban>(
|
||||
tasks: readonly T[]
|
||||
): T | null {
|
||||
const activeTasks = tasks.filter(isTeamTaskActivelyWorked);
|
||||
if (activeTasks.length === 0) return null;
|
||||
return [...activeTasks].sort(compareCurrentActiveTasks)[0] ?? null;
|
||||
}
|
||||
|
|
@ -1239,7 +1239,9 @@ function normalizeFallbackReviewState(value: unknown, status: string): string {
|
|||
if (status === 'in_progress' || status === 'deleted') return 'none';
|
||||
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||
if (status === 'completed') {
|
||||
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
|
||||
return reviewState === 'review' || reviewState === 'approved' || reviewState === 'needsFix'
|
||||
? reviewState
|
||||
: 'none';
|
||||
}
|
||||
return reviewState;
|
||||
}
|
||||
|
|
@ -1444,9 +1446,11 @@ async function readTasksDirForTeam(
|
|||
parsed.status === 'deleted'
|
||||
? (parsed.status as string)
|
||||
: 'pending';
|
||||
const derivedReviewState = deriveReviewStateFromEvents(historyEvents);
|
||||
const reviewState =
|
||||
deriveReviewStateFromEvents(historyEvents) ??
|
||||
normalizeFallbackReviewState(parsed.reviewState, status);
|
||||
derivedReviewState !== null
|
||||
? normalizeFallbackReviewState(derivedReviewState, status)
|
||||
: normalizeFallbackReviewState(parsed.reviewState, status);
|
||||
|
||||
const task = {
|
||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ export const WebPreviewBanner = (): React.JSX.Element | null => {
|
|||
>
|
||||
<FlaskConical className="mt-0.5 size-4 shrink-0 text-amber-600" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-amber-900">Web version is still in development</p>
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
Open the desktop app for full functionality
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-amber-800">
|
||||
Some desktop features are not available in the browser yet. Project actions, integrations,
|
||||
and live status data may be limited or not work as expected.
|
||||
The browser version is still in development. Project actions, integrations, and live
|
||||
status updates may be limited here. Use the desktop app to access all features reliably.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/membe
|
|||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
||||
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -105,7 +108,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
}
|
||||
}, [isRenaming, displaySubject]);
|
||||
|
||||
const reviewColumn = getTaskKanbanColumn(task);
|
||||
const reviewColumn = getTeamTaskWorkflowColumn(task);
|
||||
const cfg =
|
||||
reviewColumn === 'approved'
|
||||
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
||||
|
|
@ -212,7 +215,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
</span>
|
||||
))}
|
||||
{displaySubject}
|
||||
{task.reviewState === 'needsFix' && (
|
||||
{isTeamTaskNeedsFixActionable(task) && (
|
||||
<span
|
||||
className={`ml-1.5 inline-block rounded-full px-1.5 py-0.5 align-middle text-[10px] font-medium leading-none ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
export type TaskStatusFilterId =
|
||||
| 'todo'
|
||||
|
|
@ -50,10 +53,10 @@ export function taskMatchesStatus(
|
|||
if (statusIds.size === 0) return false;
|
||||
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
|
||||
|
||||
const kanbanColumn = getTaskKanbanColumn(task);
|
||||
const inNeedsFix = task.reviewState === 'needsFix';
|
||||
const kanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||
const inNeedsFix = isTeamTaskNeedsFixActionable(task);
|
||||
const inTodo = task.status === 'pending' && !kanbanColumn && !inNeedsFix;
|
||||
const inProgress = task.status === 'in_progress' && !kanbanColumn;
|
||||
const inProgress = task.status === 'in_progress' && !kanbanColumn && !inNeedsFix;
|
||||
const inDone = task.status === 'completed' && !kanbanColumn && !inNeedsFix;
|
||||
const inReview = kanbanColumn === 'review';
|
||||
const inApproved = kanbanColumn === 'approved';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import { useStore } from '@renderer/store';
|
|||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -29,7 +32,7 @@ const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
|
|||
};
|
||||
|
||||
function getEffectiveColumn(task: TeamTaskWithKanban): string {
|
||||
const reviewColumn = getTaskKanbanColumn(task);
|
||||
const reviewColumn = getTeamTaskWorkflowColumn(task);
|
||||
if (reviewColumn) return reviewColumn;
|
||||
if (task.status === 'pending') return 'todo';
|
||||
if (task.status === 'completed') return 'done';
|
||||
|
|
@ -159,7 +162,7 @@ export const TaskTooltip = memo(function TaskTooltip({
|
|||
>
|
||||
{label}
|
||||
</span>
|
||||
{task.reviewState === 'needsFix' ? (
|
||||
{isTeamTaskNeedsFixActionable(task) ? (
|
||||
<span
|
||||
className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1243,6 +1243,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
restoreTask,
|
||||
fetchDeletedTasks,
|
||||
deletedTasks,
|
||||
activeTaskLogActivity,
|
||||
launchParams,
|
||||
messagesPanelMode,
|
||||
messagesPanelWidth,
|
||||
|
|
@ -1299,6 +1300,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
restoreTask: s.restoreTask,
|
||||
fetchDeletedTasks: s.fetchDeletedTasks,
|
||||
deletedTasks: s.deletedTasks,
|
||||
activeTaskLogActivity: teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
|
||||
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
|
||||
messagesPanelMode: s.messagesPanelMode,
|
||||
messagesPanelWidth: s.messagesPanelWidth,
|
||||
|
|
@ -2554,6 +2556,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
sessions={teamSessions}
|
||||
leadSessionId={data.config.leadSessionId}
|
||||
members={activeMembers}
|
||||
activeTaskLogActivity={activeTaskLogActivity}
|
||||
forceShowAllTasks={isKanbanSearchActive}
|
||||
onFilterChange={setKanbanFilter}
|
||||
onSortChange={setKanbanSort}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import {
|
|||
buildMemberColorMap,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -27,7 +29,7 @@ interface ActiveTasksBlockProps {
|
|||
|
||||
interface ActivityEntry {
|
||||
member: ResolvedTeamMember;
|
||||
task: TeamTaskWithKanban | undefined;
|
||||
task: TeamTaskWithKanban;
|
||||
taskId: string;
|
||||
kind: 'working' | 'reviewing';
|
||||
}
|
||||
|
|
@ -53,8 +55,8 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
|
|||
for (const m of members) {
|
||||
if (!m.currentTaskId) continue;
|
||||
const task = taskMap.get(m.currentTaskId);
|
||||
// Defense-in-depth: hide banner for approved/completed tasks even if currentTaskId is stale
|
||||
if (task && (task.reviewState === 'approved' || task.status === 'completed')) continue;
|
||||
// Defense-in-depth: hide stale currentTaskId until backend refresh clears it.
|
||||
if (!isDisplayableCurrentTask(task)) continue;
|
||||
workingMemberNames.add(m.name);
|
||||
entries.push({ member: m, task, taskId: m.currentTaskId, kind: 'working' });
|
||||
}
|
||||
|
|
@ -63,7 +65,7 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
|
|||
for (const m of members) {
|
||||
if (workingMemberNames.has(m.name)) continue;
|
||||
const reviewTask = tasks.find(
|
||||
(t) => t.reviewer === m.name && (t.reviewState === 'review' || t.kanbanColumn === 'review')
|
||||
(t) => t.reviewer === m.name && getTeamTaskWorkflowColumn(t) === 'review'
|
||||
);
|
||||
if (reviewTask) {
|
||||
entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' });
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import {
|
|||
getKnownSlashCommand,
|
||||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -379,6 +380,68 @@ const PassiveIdlePeerSummaryRow = ({
|
|||
);
|
||||
};
|
||||
|
||||
const TaskStallRemediationRow = ({
|
||||
teamName,
|
||||
recipientName,
|
||||
recipientColor,
|
||||
taskRef,
|
||||
timestamp,
|
||||
onMemberNameClick,
|
||||
onTaskIdClick,
|
||||
}: {
|
||||
teamName: string;
|
||||
recipientName: string;
|
||||
recipientColor?: string;
|
||||
taskRef?: NonNullable<InboxMessage['taskRefs']>[number];
|
||||
timestamp: string;
|
||||
onMemberNameClick?: (memberName: string) => void;
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
}): React.JSX.Element => {
|
||||
const taskLabel = taskRef
|
||||
? formatTaskDisplayLabel({ id: taskRef.taskId, displayId: taskRef.displayId })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.82 }}>
|
||||
<span className="bg-amber-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">
|
||||
automation
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
|
||||
stall nudge
|
||||
</span>
|
||||
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||
<MemberBadge
|
||||
name={recipientName}
|
||||
color={recipientColor}
|
||||
teamName={teamName}
|
||||
hideAvatar
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
Asked teammate to continue stalled task
|
||||
{taskRef && taskLabel ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-blue-300 hover:text-blue-200"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onTaskIdClick?.(taskRef.taskId);
|
||||
}}
|
||||
>
|
||||
{taskLabel}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{timestamp}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BootstrapSystemRow = ({
|
||||
teamName,
|
||||
eventKind,
|
||||
|
|
@ -926,6 +989,20 @@ export const ActivityItem = memo(
|
|||
);
|
||||
}
|
||||
|
||||
if (isTaskStallRemediationMessage(message)) {
|
||||
return (
|
||||
<TaskStallRemediationRow
|
||||
teamName={teamName}
|
||||
recipientName={message.to ?? 'teammate'}
|
||||
recipientColor={recipientColor}
|
||||
taskRef={message.taskRefs?.[0]}
|
||||
timestamp={timestamp}
|
||||
onMemberNameClick={onMemberNameClick}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (bootstrapDisplay) {
|
||||
return (
|
||||
<BootstrapSystemRow
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
|||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
|
||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -73,6 +74,7 @@ export function isLeadThought(msg: InboxMessage): boolean {
|
|||
if (msg.messageKind === 'slash_command_result') return false;
|
||||
// Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden
|
||||
if (isThoughtProtocolNoise(msg.text)) return false;
|
||||
if (isTeamInternalControlMessageText(msg.text)) return false;
|
||||
if (msg.source === 'lead_session') return true;
|
||||
if (msg.source === 'lead_process') return true;
|
||||
return false;
|
||||
|
|
@ -90,7 +92,7 @@ export function isLeadThought(msg: InboxMessage): boolean {
|
|||
function isLeadSessionNoise(msg: InboxMessage): boolean {
|
||||
if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false;
|
||||
if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
|
||||
return isThoughtProtocolNoise(msg.text);
|
||||
return isThoughtProtocolNoise(msg.text) || isTeamInternalControlMessageText(msg.text);
|
||||
}
|
||||
|
||||
export type TimelineItem =
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ export const CreateTaskDialog = ({
|
|||
|
||||
// Only show non-internal, non-deleted tasks as candidates for blocking
|
||||
const availableTasks = tasks.filter(
|
||||
(t) => t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved'
|
||||
(t) => t.status !== 'deleted' && getTeamTaskWorkflowColumn(t) !== 'approved'
|
||||
);
|
||||
|
||||
const toggleBlockedBy = (taskId: string): void => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_STYLES,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck } from 'lucide-react';
|
||||
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react';
|
||||
|
||||
import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||
|
||||
|
|
@ -107,6 +107,52 @@ const EventContent = ({
|
|||
<StatusBadge status={event.to} />
|
||||
</span>
|
||||
);
|
||||
case 'owner_changed':
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserRound size={10} className="text-cyan-400" />
|
||||
{event.from && event.to ? (
|
||||
<>
|
||||
Reassigned
|
||||
<MemberBadge
|
||||
name={event.from}
|
||||
color={memberColorMap?.get(event.from)}
|
||||
size="sm"
|
||||
hideAvatar
|
||||
/>
|
||||
<ArrowRight size={10} className="text-[var(--color-text-muted)]" />
|
||||
<MemberBadge
|
||||
name={event.to}
|
||||
color={memberColorMap?.get(event.to)}
|
||||
size="sm"
|
||||
hideAvatar
|
||||
/>
|
||||
</>
|
||||
) : event.to ? (
|
||||
<>
|
||||
Assigned to
|
||||
<MemberBadge
|
||||
name={event.to}
|
||||
color={memberColorMap?.get(event.to)}
|
||||
size="sm"
|
||||
hideAvatar
|
||||
/>
|
||||
</>
|
||||
) : event.from ? (
|
||||
<>
|
||||
Unassigned from
|
||||
<MemberBadge
|
||||
name={event.from}
|
||||
color={memberColorMap?.get(event.from)}
|
||||
size="sm"
|
||||
hideAvatar
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
'Owner changed'
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
case 'review_requested':
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
@ -181,6 +227,8 @@ function dotColor(event: TaskHistoryEvent): string {
|
|||
return dotColorForStatus(event.status);
|
||||
case 'status_changed':
|
||||
return dotColorForStatus(event.to);
|
||||
case 'owner_changed':
|
||||
return 'bg-cyan-400';
|
||||
case 'review_requested':
|
||||
return 'bg-purple-400';
|
||||
case 'review_started':
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ import {
|
|||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import {
|
||||
deriveTaskDisplayId,
|
||||
formatTaskDisplayLabel,
|
||||
|
|
@ -598,12 +602,10 @@ export const TaskDetailDialog = ({
|
|||
);
|
||||
}
|
||||
|
||||
const kanbanColumn =
|
||||
kanbanTaskState?.column ??
|
||||
getTaskKanbanColumn({
|
||||
reviewState: currentTask.reviewState,
|
||||
kanbanColumn: currentTask.kanbanColumn,
|
||||
});
|
||||
const kanbanColumn = getTeamTaskWorkflowColumn({
|
||||
...currentTask,
|
||||
...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}),
|
||||
});
|
||||
const status = currentTask.status;
|
||||
const statusStyle =
|
||||
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
|
||||
|
|
@ -659,13 +661,13 @@ export const TaskDetailDialog = ({
|
|||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
{formatTaskDisplayLabel(currentTask)}
|
||||
</Badge>
|
||||
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') &&
|
||||
{(kanbanColumn === 'approved' || kanbanColumn === 'review') &&
|
||||
currentTask.reviewer &&
|
||||
currentTask.reviewer !== 'user' ? (
|
||||
(() => {
|
||||
const reviewerColor = colorMap.get(currentTask.reviewer);
|
||||
const colors =
|
||||
currentTask.reviewState === 'review'
|
||||
kanbanColumn === 'review'
|
||||
? getTeamColorSet('blue')
|
||||
: getTeamColorSet(reviewerColor ?? '');
|
||||
const reviewerBadgeStyle = {
|
||||
|
|
@ -677,7 +679,7 @@ export const TaskDetailDialog = ({
|
|||
};
|
||||
const lastReviewEvent = currentTask.historyEvents
|
||||
?.filter((e) =>
|
||||
currentTask.reviewState === 'approved'
|
||||
kanbanColumn === 'approved'
|
||||
? e.type === 'review_approved'
|
||||
: e.type === 'review_requested' || e.type === 'review_started'
|
||||
)
|
||||
|
|
@ -731,7 +733,7 @@ export const TaskDetailDialog = ({
|
|||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
{currentTask.reviewState === 'needsFix' ? (
|
||||
{isTeamTaskNeedsFixActionable(currentTask) ? (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||
>
|
||||
|
|
@ -941,7 +943,9 @@ export const TaskDetailDialog = ({
|
|||
</span>
|
||||
{blockedByIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
const isCompleted = depTask?.status === 'completed';
|
||||
const isCompleted = depTask
|
||||
? isTeamTaskFinishedForDependency(depTask)
|
||||
: false;
|
||||
const label = depTask
|
||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||
: `#${deriveTaskDisplayId(id)}`;
|
||||
|
|
@ -977,7 +981,9 @@ export const TaskDetailDialog = ({
|
|||
</span>
|
||||
{blocksIds.map((id) => {
|
||||
const depTask = taskMap.get(id);
|
||||
const isCompleted = depTask?.status === 'completed';
|
||||
const isCompleted = depTask
|
||||
? isTeamTaskFinishedForDependency(depTask)
|
||||
: false;
|
||||
const label = depTask
|
||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||
: `#${deriveTaskDisplayId(id)}`;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
|||
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
|
|
@ -78,6 +79,7 @@ interface KanbanBoardProps {
|
|||
sessions: Session[];
|
||||
leadSessionId?: string;
|
||||
members: ResolvedTeamMember[];
|
||||
activeTaskLogActivity?: Record<string, true>;
|
||||
/** Shows all cards when another UI flow, such as search, must not hide matches. */
|
||||
forceShowAllTasks?: boolean;
|
||||
onFilterChange: (filter: KanbanFilterState) => void;
|
||||
|
|
@ -160,7 +162,7 @@ function estimateGridSkeletonCardHeight(
|
|||
if (task.subject.length > 54) height += 10;
|
||||
if (task.subject.length > 92) height += 8;
|
||||
if (task.needsClarification) height += 16;
|
||||
if (task.reviewState === 'needsFix') height += 14;
|
||||
if (isTeamTaskNeedsFixActionable(task)) height += 14;
|
||||
if ((task.blockedBy?.length ?? 0) > 0) height += 18;
|
||||
if ((task.blocks?.length ?? 0) > 0) height += 18;
|
||||
|
||||
|
|
@ -244,6 +246,7 @@ interface SortableKanbanTaskCardProps {
|
|||
compact?: boolean;
|
||||
taskMap: Map<string, TeamTask>;
|
||||
memberColorMap: Map<string, string>;
|
||||
hasLiveTaskLogs?: boolean;
|
||||
onRequestReview: (taskId: string) => void;
|
||||
onApprove: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
|
|
@ -265,6 +268,7 @@ const SortableKanbanTaskCard = ({
|
|||
compact,
|
||||
taskMap,
|
||||
memberColorMap,
|
||||
hasLiveTaskLogs,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
|
|
@ -300,6 +304,7 @@ const SortableKanbanTaskCard = ({
|
|||
compact={compact}
|
||||
taskMap={taskMap}
|
||||
memberColorMap={memberColorMap}
|
||||
hasLiveTaskLogs={hasLiveTaskLogs}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
|
|
@ -325,6 +330,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
|||
sessions,
|
||||
leadSessionId,
|
||||
members,
|
||||
activeTaskLogActivity,
|
||||
forceShowAllTasks = false,
|
||||
onFilterChange,
|
||||
onSortChange,
|
||||
|
|
@ -578,6 +584,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
|||
compact={compact}
|
||||
taskMap={taskMap}
|
||||
memberColorMap={memberColorMap}
|
||||
hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
|
|
@ -610,6 +617,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
|||
compact={compact}
|
||||
taskMap={taskMap}
|
||||
memberColorMap={memberColorMap}
|
||||
hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
|
|
@ -630,6 +638,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
|||
},
|
||||
[
|
||||
enableTaskSorting,
|
||||
activeTaskLogActivity,
|
||||
handleScrollToTask,
|
||||
hasReviewers,
|
||||
kanbanState,
|
||||
|
|
|
|||
|
|
@ -81,6 +81,41 @@ const baseTask: TeamTaskWithKanban = {
|
|||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
async function renderTaskCard(
|
||||
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
|
||||
): Promise<{ host: HTMLDivElement; root: ReturnType<typeof createRoot> }> {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(KanbanTaskCard, {
|
||||
task: baseTask,
|
||||
teamName: 'my-team',
|
||||
columnId: 'in_progress',
|
||||
hasReviewers: true,
|
||||
compact: false,
|
||||
taskMap: new Map(),
|
||||
memberColorMap: new Map([['alice', 'blue']]),
|
||||
onRequestReview: noop,
|
||||
onApprove: noop,
|
||||
onRequestChanges: noop,
|
||||
onMoveBackToDone: noop,
|
||||
onStartTask: noop,
|
||||
onCompleteTask: noop,
|
||||
onCancelTask: noop,
|
||||
onViewChanges: noop,
|
||||
...props,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe('KanbanTaskCard change badge', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
|
|
@ -197,3 +232,88 @@ describe('KanbanTaskCard change badge', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('KanbanTaskCard blocked border', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('highlights blocked tasks outside final columns', async () => {
|
||||
const { host, root } = await renderTaskCard({
|
||||
task: { ...baseTask, blockedBy: ['task-2'] },
|
||||
columnId: 'in_progress',
|
||||
});
|
||||
|
||||
const card = host.querySelector('[data-task-id="task-1"]');
|
||||
expect(card?.className).toContain('kanban-task-card');
|
||||
expect(card?.className).toContain('border-yellow-500/30');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['done', 'approved'] as const)(
|
||||
'does not highlight blocked tasks in %s',
|
||||
async (columnId) => {
|
||||
const { host, root } = await renderTaskCard({
|
||||
task: { ...baseTask, blockedBy: ['task-2'] },
|
||||
columnId,
|
||||
});
|
||||
|
||||
const card = host.querySelector('[data-task-id="task-1"]');
|
||||
expect(card?.className).not.toContain('border-yellow-500/30');
|
||||
expect(card?.className).toContain('border-[var(--color-border)]');
|
||||
expect(host.textContent).toContain('Blocked by');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('KanbanTaskCard live log indicator', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('shows the live log indicator only when task log activity is active', async () => {
|
||||
const { host, root } = await renderTaskCard({ hasLiveTaskLogs: true });
|
||||
|
||||
expect(host.querySelector('[aria-label="Task logs active"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(KanbanTaskCard, {
|
||||
task: baseTask,
|
||||
teamName: 'my-team',
|
||||
columnId: 'in_progress',
|
||||
hasReviewers: true,
|
||||
compact: false,
|
||||
taskMap: new Map(),
|
||||
memberColorMap: new Map([['alice', 'blue']]),
|
||||
onRequestReview: noop,
|
||||
onApprove: noop,
|
||||
onRequestChanges: noop,
|
||||
onMoveBackToDone: noop,
|
||||
onStartTask: noop,
|
||||
onCompleteTask: noop,
|
||||
onCancelTask: noop,
|
||||
onViewChanges: noop,
|
||||
hasLiveTaskLogs: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Task logs active"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -12,6 +13,10 @@ import {
|
|||
buildTaskChangeRequestOptions,
|
||||
canDisplayTaskChangesForOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import {
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
ArrowLeftFromLine,
|
||||
|
|
@ -38,6 +43,7 @@ interface KanbanTaskCardProps {
|
|||
compact?: boolean;
|
||||
taskMap: Map<string, TeamTask>;
|
||||
memberColorMap: Map<string, string>;
|
||||
hasLiveTaskLogs?: boolean;
|
||||
onRequestReview: (taskId: string) => void;
|
||||
onApprove: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
|
|
@ -63,7 +69,7 @@ const DependencyBadge = ({
|
|||
onScrollToTask,
|
||||
}: DependencyBadgeProps): React.JSX.Element => {
|
||||
const depTask = taskMap.get(taskId);
|
||||
const isCompleted = depTask?.status === 'completed';
|
||||
const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false;
|
||||
const label = depTask
|
||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||
: `#${deriveTaskDisplayId(taskId)}`;
|
||||
|
|
@ -227,6 +233,7 @@ export const KanbanTaskCard = memo(
|
|||
compact,
|
||||
taskMap,
|
||||
memberColorMap,
|
||||
hasLiveTaskLogs = false,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
|
|
@ -245,6 +252,7 @@ export const KanbanTaskCard = memo(
|
|||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
const hasBlockedBy = blockedByIds.length > 0;
|
||||
const hasBlocks = blocksIds.length > 0;
|
||||
const shouldHighlightBlocked = hasBlockedBy && columnId !== 'done' && columnId !== 'approved';
|
||||
const cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]';
|
||||
|
||||
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
|
||||
|
|
@ -288,8 +296,8 @@ export const KanbanTaskCard = memo(
|
|||
return (
|
||||
<div
|
||||
data-task-id={task.id}
|
||||
className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
|
||||
hasBlockedBy
|
||||
className={`kanban-task-card relative cursor-pointer rounded-md border px-1.5 py-3 hover:border-[var(--color-border-emphasis)] ${
|
||||
shouldHighlightBlocked
|
||||
? `border-yellow-500/30 ${cardSurfaceClass}`
|
||||
: `border-[var(--color-border)] ${cardSurfaceClass}`
|
||||
}`}
|
||||
|
|
@ -303,8 +311,13 @@ export const KanbanTaskCard = memo(
|
|||
}
|
||||
}}
|
||||
>
|
||||
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
|
||||
{formatTaskDisplayLabel(task)}
|
||||
<span className="absolute left-[3px] top-[2px] flex max-w-[calc(100%-72px)] items-center gap-1 text-[9px] leading-none text-[var(--color-text-muted)]">
|
||||
<span className="truncate">{formatTaskDisplayLabel(task)}</span>
|
||||
{hasLiveTaskLogs ? (
|
||||
<span aria-label="Task logs active" className="inline-flex">
|
||||
<OngoingIndicator size="sm" title="New task logs arriving" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{task.owner ? (
|
||||
<span className="absolute right-[6px] top-[2px]">
|
||||
|
|
@ -325,7 +338,7 @@ export const KanbanTaskCard = memo(
|
|||
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
||||
</span>
|
||||
) : null}
|
||||
{task.reviewState === 'needsFix' ? (
|
||||
{isTeamTaskNeedsFixActionable(task) ? (
|
||||
<span
|
||||
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||
>
|
||||
|
|
@ -490,6 +503,7 @@ export const KanbanTaskCard = memo(
|
|||
prev.compact === next.compact &&
|
||||
prev.taskMap === next.taskMap &&
|
||||
prev.memberColorMap === next.memberColorMap &&
|
||||
prev.hasLiveTaskLogs === next.hasLiveTaskLogs &&
|
||||
prev.onRequestReview === next.onRequestReview &&
|
||||
prev.onApprove === next.onApprove &&
|
||||
prev.onRequestChanges === next.onRequestChanges &&
|
||||
|
|
|
|||
|
|
@ -163,8 +163,15 @@ export const MemberCard = memo(function MemberCard({
|
|||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||
);
|
||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||
const presentationMember =
|
||||
member.currentTaskId && !currentTask
|
||||
? {
|
||||
...member,
|
||||
currentTaskId: null,
|
||||
}
|
||||
: member;
|
||||
const launchPresentation = buildMemberLaunchPresentation({
|
||||
member,
|
||||
member: presentationMember,
|
||||
spawnStatus,
|
||||
spawnLaunchState,
|
||||
spawnLivenessSource,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ import {
|
|||
getRuntimeMemorySourceLabel,
|
||||
resolveMemberRuntimeSummary,
|
||||
} from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState';
|
||||
import {
|
||||
BarChart3,
|
||||
FileText,
|
||||
|
|
@ -155,12 +157,22 @@ export const MemberDetailDialog = ({
|
|||
}, [member, memberMessages, members, tasks, teamName]);
|
||||
|
||||
const inProgressTasks = useMemo(
|
||||
() => memberTasks.filter((t) => t.status === 'in_progress').length,
|
||||
() => memberTasks.filter(isDisplayableCurrentTask).length,
|
||||
[memberTasks]
|
||||
);
|
||||
const currentTaskCandidate = useMemo(
|
||||
() =>
|
||||
member?.currentTaskId
|
||||
? (tasks.find((task) => task.id === member.currentTaskId) ?? null)
|
||||
: null,
|
||||
[member?.currentTaskId, tasks]
|
||||
);
|
||||
const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate)
|
||||
? currentTaskCandidate
|
||||
: null;
|
||||
|
||||
const completedTasks = useMemo(
|
||||
() => memberTasks.filter((t) => t.status === 'completed').length,
|
||||
() => memberTasks.filter(isTeamTaskFinishedForDependency).length,
|
||||
[memberTasks]
|
||||
);
|
||||
|
||||
|
|
@ -255,7 +267,11 @@ export const MemberDetailDialog = ({
|
|||
<div className="flex items-start gap-4">
|
||||
<DialogHeader className="shrink-0">
|
||||
<MemberDetailHeader
|
||||
member={member}
|
||||
member={
|
||||
member.currentTaskId && !displayableCurrentTask
|
||||
? { ...member, currentTaskId: null }
|
||||
: member
|
||||
}
|
||||
runtimeSummary={runtimeSummary}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
getMemberLaunchDiagnosticsErrorMessage,
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
hasMemberLaunchDiagnosticsError,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -126,8 +128,19 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
|
||||
const colors = getTeamColorSet(color ?? member.color ?? '');
|
||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||
const currentTaskCandidate: TeamTaskWithKanban | null = member.currentTaskId
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const currentTask = isDisplayableCurrentTask(currentTaskCandidate) ? currentTaskCandidate : null;
|
||||
const presentationMember =
|
||||
member.currentTaskId && !currentTask
|
||||
? {
|
||||
...member,
|
||||
currentTaskId: null,
|
||||
}
|
||||
: member;
|
||||
const launchPresentation = buildMemberLaunchPresentation({
|
||||
member,
|
||||
member: presentationMember,
|
||||
spawnStatus: spawnEntry?.status,
|
||||
spawnLaunchState: spawnEntry?.launchState,
|
||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||
|
|
@ -171,15 +184,12 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
const showCopyDiagnostics =
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const reviewTask: TeamTaskWithKanban | null = tasks
|
||||
? (tasks.find(
|
||||
(task) =>
|
||||
task.reviewer === member.name &&
|
||||
task.id !== member.currentTaskId &&
|
||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
||||
task.id !== currentTask?.id &&
|
||||
getTeamTaskWorkflowColumn(task) === 'review'
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||
|
||||
import { MemberCard } from './MemberCard';
|
||||
|
||||
|
|
@ -425,7 +427,7 @@ export const MemberList = memo(function MemberList({
|
|||
const result = new Map<string, TeamTaskWithKanban>();
|
||||
if (!taskMap) return result;
|
||||
for (const task of taskMap.values()) {
|
||||
if (task.reviewer && (task.reviewState === 'review' || task.kanbanColumn === 'review')) {
|
||||
if (task.reviewer && getTeamTaskWorkflowColumn(task) === 'review') {
|
||||
result.set(task.reviewer, task);
|
||||
}
|
||||
}
|
||||
|
|
@ -455,11 +457,14 @@ export const MemberList = memo(function MemberList({
|
|||
<div ref={containerRef} className="flex flex-col gap-1">
|
||||
<div className={gridClass}>
|
||||
{activeMembers.map((member) => {
|
||||
const currentTask =
|
||||
const currentTaskCandidate =
|
||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||
const currentTask = isDisplayableCurrentTask(currentTaskCandidate)
|
||||
? currentTaskCandidate
|
||||
: null;
|
||||
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||
const reviewTask =
|
||||
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
|
||||
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
|
||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import {
|
|||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_STYLES,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -44,7 +47,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
|||
<div className="max-h-[320px] overflow-y-auto">
|
||||
<div className="flex flex-col gap-1">
|
||||
{visibleTasks.map((task) => {
|
||||
const col = getTaskKanbanColumn(task);
|
||||
const col = getTeamTaskWorkflowColumn(task);
|
||||
const style =
|
||||
col && KANBAN_COLUMN_DISPLAY[col]
|
||||
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
|
||||
|
|
@ -71,7 +74,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
|||
>
|
||||
{label}
|
||||
</span>
|
||||
{task.reviewState === 'needsFix' ? (
|
||||
{isTeamTaskNeedsFixActionable(task) ? (
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
|||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||
import {
|
||||
CheckCheck,
|
||||
ChevronsDownUp,
|
||||
|
|
@ -587,6 +588,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
|
||||
const activityTimelineMessages = useMemo(() => {
|
||||
return filterTeamMessages(effectiveMessages, {
|
||||
includeAutomationEvents: true,
|
||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||
leadNames,
|
||||
timeWindow,
|
||||
|
|
@ -600,6 +602,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
effectiveMessages.filter(
|
||||
(m) =>
|
||||
m.messageKind !== 'task_comment_notification' &&
|
||||
!isTaskStallRemediationMessage(m) &&
|
||||
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
|
||||
),
|
||||
[effectiveMessages]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
|
||||
|
|
@ -55,8 +56,7 @@ export const StatusBlock = ({
|
|||
return members.some((m) => {
|
||||
if (!m.currentTaskId) return false;
|
||||
const task = tMap.get(m.currentTaskId);
|
||||
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
|
||||
return true;
|
||||
return isDisplayableCurrentTask(task);
|
||||
});
|
||||
}, [members, tasks]);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useStore } from '@renderer/store';
|
|||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { asEnhancedChunkArray } from '@renderer/types/data';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -375,7 +376,7 @@ export const TaskLogStreamSection = ({
|
|||
}
|
||||
const shouldReload =
|
||||
event.type === 'log-source-change' ||
|
||||
(event.type === 'task-log-change' && event.taskId === taskId);
|
||||
(isTaskLogActivityChangeEvent(event) && event.taskId === taskId);
|
||||
if (!shouldReload) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
|
||||
import { ExecutionSessionsSection } from './ExecutionSessionsSection';
|
||||
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
|
||||
|
|
@ -64,9 +66,10 @@ export const TaskLogsPanel = ({
|
|||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countRequestSeqRef = useRef(0);
|
||||
const taskLogTrackingEnabled =
|
||||
hasOpenedContent && task.status === 'in_progress' && availableTabs.includes('stream');
|
||||
const taskLogSummaryEnabled = hasOpenedContent && availableTabs.includes('stream');
|
||||
const hasTaskLogStream = availableTabs.includes('stream');
|
||||
const taskIsActivelyWorked = isDisplayableCurrentTask(task);
|
||||
const taskLogActivityTrackingEnabled = taskIsActivelyWorked && hasTaskLogStream;
|
||||
const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(defaultTab);
|
||||
|
|
@ -133,7 +136,7 @@ export const TaskLogsPanel = ({
|
|||
}, [task.id, taskLogSummaryEnabled, teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
|
||||
if (!taskLogActivityTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -143,10 +146,10 @@ export const TaskLogsPanel = ({
|
|||
() => undefined
|
||||
);
|
||||
};
|
||||
}, [taskLogTrackingEnabled, teamName]);
|
||||
}, [taskLogActivityTrackingEnabled, teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskLogTrackingEnabled) {
|
||||
if (!taskLogActivityTrackingEnabled) {
|
||||
if (pulseTimerRef.current) {
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = null;
|
||||
|
|
@ -160,7 +163,7 @@ export const TaskLogsPanel = ({
|
|||
}
|
||||
|
||||
const scheduleCountReload = (): void => {
|
||||
if (!api.teams.getTaskLogStreamSummary) {
|
||||
if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) {
|
||||
return;
|
||||
}
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
|
|
@ -187,7 +190,7 @@ export const TaskLogsPanel = ({
|
|||
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
||||
if (
|
||||
event.teamName !== teamName ||
|
||||
event.type !== 'task-log-change' ||
|
||||
!isTaskLogActivityChangeEvent(event) ||
|
||||
event.taskId !== task.id
|
||||
) {
|
||||
return;
|
||||
|
|
@ -230,7 +233,7 @@ export const TaskLogsPanel = ({
|
|||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [task.id, taskLogTrackingEnabled, teamName]);
|
||||
}, [task.id, taskLogActivityTrackingEnabled, taskLogSummaryEnabled, teamName]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
|
|
@ -260,7 +263,7 @@ export const TaskLogsPanel = ({
|
|||
teamName={teamName}
|
||||
taskId={task.id}
|
||||
taskStatus={task.status}
|
||||
liveEnabled={isOpen && task.status === 'in_progress'}
|
||||
liveEnabled={isOpen && taskIsActivelyWorked}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import {
|
|||
REVIEW_STATE_DISPLAY,
|
||||
TASK_STATUS_LABELS,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -17,7 +20,7 @@ interface TaskRowProps {
|
|||
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
|
||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||
const kanbanColumn = getTaskKanbanColumn(task);
|
||||
const kanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||
|
||||
return (
|
||||
<tr className="border-t border-[var(--color-border)]">
|
||||
|
|
@ -35,7 +38,7 @@ export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.
|
|||
? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
|
||||
: (TASK_STATUS_LABELS[task.status] ?? task.status)}
|
||||
</span>
|
||||
{task.reviewState === 'needsFix' ? (
|
||||
{isTeamTaskNeedsFixActionable(task) ? (
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@
|
|||
/* Subtle borders */
|
||||
--color-border-emphasis: rgba(148, 163, 184, 0.12);
|
||||
/* Emphasis borders */
|
||||
--kanban-task-card-hover-shadow:
|
||||
0 0 0 1px rgba(129, 140, 248, 0.28), 0 10px 30px rgba(37, 99, 235, 0.24),
|
||||
0 0 22px rgba(129, 140, 248, 0.16);
|
||||
--color-text: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
|
|
@ -269,6 +272,19 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
.kanban-task-card {
|
||||
box-shadow: none;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
background-color 140ms ease;
|
||||
}
|
||||
|
||||
.kanban-task-card:hover,
|
||||
.kanban-task-card:focus-visible {
|
||||
box-shadow: var(--kanban-task-card-hover-shadow);
|
||||
}
|
||||
|
||||
.kanban-grid-item-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -466,6 +482,9 @@
|
|||
/* Warm subtle border */
|
||||
--color-border-emphasis: #a8a5a0;
|
||||
/* Warm emphasis border */
|
||||
--kanban-task-card-hover-shadow:
|
||||
0 0 0 1px rgba(37, 99, 235, 0.28), 0 10px 26px rgba(37, 99, 235, 0.18),
|
||||
0 0 18px rgba(79, 70, 229, 0.12);
|
||||
--color-text: #1c1b19;
|
||||
/* Warm near-black text */
|
||||
--color-text-secondary: #4d4b46;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
buildTaskChangeRequestOptions,
|
||||
canDisplayTaskChangesForOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
||||
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||
import { create } from 'zustand';
|
||||
|
|
@ -87,6 +89,7 @@ const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
|
|||
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
||||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||
const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
|
||||
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
|
||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
||||
|
|
@ -100,6 +103,7 @@ const RELEVANT_TEAM_CHANGE_EVENT_TYPES = new Set<TeamChangeEvent['type']>([
|
|||
'lead-message',
|
||||
'lead-context',
|
||||
'lead-activity',
|
||||
'member-advisory',
|
||||
'process',
|
||||
'member-spawn',
|
||||
]);
|
||||
|
|
@ -268,9 +272,11 @@ export function initializeNotificationListeners(): () => void {
|
|||
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let memberAdvisorySafetyRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let taskLogActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let processLiteStructuralReconcileTimers = new Map<
|
||||
string,
|
||||
{ firstScheduledAt: number; timer: ReturnType<typeof setTimeout> }
|
||||
|
|
@ -545,6 +551,71 @@ export function initializeNotificationListeners(): () => void {
|
|||
toolActivityTimers.delete(key);
|
||||
}
|
||||
};
|
||||
const buildTaskLogActivityTimerKey = (teamName: string, taskId: string): string =>
|
||||
`${teamName}\u0000${taskId}`;
|
||||
const clearTaskLogActivityTimer = (teamName: string, taskId: string): void => {
|
||||
const key = buildTaskLogActivityTimerKey(teamName, taskId);
|
||||
const existing = taskLogActivityTimers.get(key);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
taskLogActivityTimers.delete(key);
|
||||
}
|
||||
};
|
||||
const clearTaskLogActivityTimersForTeam = (teamName: string): void => {
|
||||
const prefix = `${teamName}\u0000`;
|
||||
for (const [key, timer] of taskLogActivityTimers.entries()) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
clearTimeout(timer);
|
||||
taskLogActivityTimers.delete(key);
|
||||
}
|
||||
};
|
||||
const clearTaskLogActivityStateForTeam = (teamName: string): void => {
|
||||
clearTaskLogActivityTimersForTeam(teamName);
|
||||
useStore.setState((prev) => {
|
||||
if (!(teamName in prev.activeTaskLogActivityByTeam)) {
|
||||
return {};
|
||||
}
|
||||
const next = { ...prev.activeTaskLogActivityByTeam };
|
||||
delete next[teamName];
|
||||
return { activeTaskLogActivityByTeam: next };
|
||||
});
|
||||
};
|
||||
const markTaskLogActivity = (teamName: string, taskId: string): void => {
|
||||
clearTaskLogActivityTimer(teamName, taskId);
|
||||
const isAlreadyActive =
|
||||
useStore.getState().activeTaskLogActivityByTeam[teamName]?.[taskId] === true;
|
||||
if (!isAlreadyActive) {
|
||||
useStore.setState((prev) => ({
|
||||
activeTaskLogActivityByTeam: {
|
||||
...prev.activeTaskLogActivityByTeam,
|
||||
[teamName]: {
|
||||
...(prev.activeTaskLogActivityByTeam[teamName] ?? {}),
|
||||
[taskId]: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
const timerKey = buildTaskLogActivityTimerKey(teamName, taskId);
|
||||
const timer = setTimeout(() => {
|
||||
taskLogActivityTimers.delete(timerKey);
|
||||
useStore.setState((prev) => {
|
||||
const teamActivity = prev.activeTaskLogActivityByTeam[teamName];
|
||||
if (!teamActivity?.[taskId]) {
|
||||
return {};
|
||||
}
|
||||
const nextTeamActivity = { ...teamActivity };
|
||||
delete nextTeamActivity[taskId];
|
||||
const nextByTeam = { ...prev.activeTaskLogActivityByTeam };
|
||||
if (Object.keys(nextTeamActivity).length === 0) {
|
||||
delete nextByTeam[teamName];
|
||||
} else {
|
||||
nextByTeam[teamName] = nextTeamActivity;
|
||||
}
|
||||
return { activeTaskLogActivityByTeam: nextByTeam };
|
||||
});
|
||||
}, TASK_LOG_ACTIVITY_PULSE_MS);
|
||||
taskLogActivityTimers.set(timerKey, timer);
|
||||
};
|
||||
const clearRuntimeToolStateForTeam = (
|
||||
prev: AppState,
|
||||
teamName: string
|
||||
|
|
@ -666,7 +737,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
|
||||
const candidateTasks = teamData.tasks.filter((task) => {
|
||||
if (task.status !== 'in_progress') {
|
||||
if (!isDisplayableCurrentTask(task)) {
|
||||
return false;
|
||||
}
|
||||
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
|
||||
|
|
@ -700,7 +771,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
|
||||
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
|
||||
if (currentTask?.status !== 'in_progress') {
|
||||
if (!isDisplayableCurrentTask(currentTask)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -858,6 +929,10 @@ export function initializeNotificationListeners(): () => void {
|
|||
return getVisibleTeamNamesInAnyPane();
|
||||
};
|
||||
|
||||
const getTrackedTaskLogActivityTeams = (): Set<string> => {
|
||||
return getVisibleTeamNamesInAnyPane();
|
||||
};
|
||||
|
||||
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
|
||||
teamLastRelevantActivityAt.set(teamName, timestamp);
|
||||
};
|
||||
|
|
@ -1218,6 +1293,46 @@ export function initializeNotificationListeners(): () => void {
|
|||
});
|
||||
}
|
||||
|
||||
if (api.teams?.setTaskLogStreamTracking) {
|
||||
let trackedTeamNames = new Set<string>();
|
||||
const syncVisibleTeamTracking = (): void => {
|
||||
const nextTrackedTeamNames = getTrackedTaskLogActivityTeams();
|
||||
|
||||
for (const teamName of nextTrackedTeamNames) {
|
||||
if (!trackedTeamNames.has(teamName)) {
|
||||
void api.teams.setTaskLogStreamTracking(teamName, true).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
for (const teamName of trackedTeamNames) {
|
||||
if (!nextTrackedTeamNames.has(teamName)) {
|
||||
void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined);
|
||||
clearTaskLogActivityStateForTeam(teamName);
|
||||
}
|
||||
}
|
||||
|
||||
trackedTeamNames = nextTrackedTeamNames;
|
||||
};
|
||||
|
||||
syncVisibleTeamTracking();
|
||||
|
||||
const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => {
|
||||
if (state.paneLayout === prevState.paneLayout) {
|
||||
return;
|
||||
}
|
||||
syncVisibleTeamTracking();
|
||||
});
|
||||
|
||||
cleanupFns.push(() => {
|
||||
unsubscribeVisibleTeamTracking();
|
||||
for (const teamName of trackedTeamNames) {
|
||||
void api.teams.setTaskLogStreamTracking(teamName, false).catch(() => undefined);
|
||||
clearTaskLogActivityStateForTeam(teamName);
|
||||
}
|
||||
trackedTeamNames.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for task-list file changes to refresh currently viewed session metadata
|
||||
if (api.onTodoChange) {
|
||||
const cleanup = api.onTodoChange((event) => {
|
||||
|
|
@ -1420,6 +1535,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
|
||||
delete nextState.leadContextByTeam[event.teamName];
|
||||
Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName));
|
||||
nextState.activeTaskLogActivityByTeam = { ...prev.activeTaskLogActivityByTeam };
|
||||
delete nextState.activeTaskLogActivityByTeam[event.teamName];
|
||||
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
|
||||
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
|
||||
nextState.ignoredRuntimeRunIds = event.runId
|
||||
|
|
@ -1429,6 +1546,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
: prev.ignoredRuntimeRunIds;
|
||||
clearToolActivityTimersForTeam(event.teamName);
|
||||
clearTaskLogActivityTimersForTeam(event.teamName);
|
||||
}
|
||||
|
||||
return nextState as typeof prev;
|
||||
|
|
@ -1583,6 +1701,59 @@ export function initializeNotificationListeners(): () => void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'task-log-change') {
|
||||
if (isStaleRuntimeEvent) {
|
||||
return;
|
||||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
const visible = isTeamVisibleInAnyPane(event.teamName);
|
||||
if (event.taskId && visible) {
|
||||
const isLogActivitySignal = isTaskLogActivityChangeEvent(event);
|
||||
if (isLogActivitySignal) {
|
||||
markTaskLogActivity(event.teamName, event.taskId);
|
||||
}
|
||||
if (event.taskSignalKind === 'log') {
|
||||
return;
|
||||
}
|
||||
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: existingDetailTimer ? 'coalesced' : 'scheduled',
|
||||
reason: 'event:task-log-change:task-state-safety',
|
||||
operation: 'refreshTeamData',
|
||||
eventType: event.type,
|
||||
selected: useStore.getState().selectedTeamName === event.teamName,
|
||||
visible,
|
||||
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
||||
});
|
||||
if (!existingDetailTimer) {
|
||||
const timer = setTimeout(() => {
|
||||
teamRefreshTimers.delete(event.teamName);
|
||||
const current = useStore.getState();
|
||||
const visibleAtExecution = isTeamVisibleInAnyPane(event.teamName);
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: visibleAtExecution ? 'executed' : 'skipped',
|
||||
reason: 'event:task-log-change:task-state-safety',
|
||||
operation: 'refreshTeamData',
|
||||
eventType: event.type,
|
||||
selected: current.selectedTeamName === event.teamName,
|
||||
visible: visibleAtExecution,
|
||||
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
||||
});
|
||||
if (!visibleAtExecution) {
|
||||
return;
|
||||
}
|
||||
void current.refreshTeamData(event.teamName, { withDedup: true });
|
||||
}, TEAM_REFRESH_THROTTLE_MS);
|
||||
teamRefreshTimers.set(event.teamName, timer);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Member spawn status change: fetch updated spawn statuses for the team.
|
||||
if (event.type === 'member-spawn') {
|
||||
if (isStaleRuntimeEvent) {
|
||||
|
|
@ -1610,6 +1781,75 @@ export function initializeNotificationListeners(): () => void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'member-advisory') {
|
||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
cancelProcessLiteStructuralReconcile(event.teamName);
|
||||
const eventReason = buildTeamChangeFanoutReason(event.type);
|
||||
const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName;
|
||||
const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName;
|
||||
const existingSafetyTimer = memberAdvisorySafetyRefreshTimers.get(event.teamName);
|
||||
if (existingSafetyTimer) {
|
||||
clearTimeout(existingSafetyTimer);
|
||||
}
|
||||
memberAdvisorySafetyRefreshTimers.set(
|
||||
event.teamName,
|
||||
setTimeout(() => {
|
||||
memberAdvisorySafetyRefreshTimers.delete(event.teamName);
|
||||
if (!isTeamVisibleInAnyPane(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
const current = useStore.getState();
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'executed',
|
||||
reason: `${eventReason}:safety`,
|
||||
operation: 'refreshTeamData',
|
||||
eventType: event.type,
|
||||
selected: current.selectedTeamName === event.teamName,
|
||||
visible: true,
|
||||
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
||||
});
|
||||
void current.refreshTeamData(event.teamName);
|
||||
}, TEAM_REFRESH_THROTTLE_MS + 250)
|
||||
);
|
||||
const existingDetailTimer = teamRefreshTimers.get(event.teamName);
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: existingDetailTimer ? 'coalesced' : 'scheduled',
|
||||
reason: eventReason,
|
||||
operation: 'refreshTeamData',
|
||||
eventType: event.type,
|
||||
selected: selectedForRefresh,
|
||||
visible: true,
|
||||
activeTab: activeTabForRefresh,
|
||||
});
|
||||
if (existingDetailTimer) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
teamRefreshTimers.delete(event.teamName);
|
||||
const current = useStore.getState();
|
||||
noteTeamRefreshFanout({
|
||||
teamName: event.teamName,
|
||||
surface: 'team-change-listener',
|
||||
phase: 'executed',
|
||||
reason: eventReason,
|
||||
operation: 'refreshTeamData',
|
||||
eventType: event.type,
|
||||
selected: current.selectedTeamName === event.teamName,
|
||||
visible: isTeamVisibleInAnyPane(event.teamName),
|
||||
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
||||
});
|
||||
void current.refreshTeamData(event.teamName, { withDedup: true });
|
||||
}, TEAM_REFRESH_THROTTLE_MS);
|
||||
teamRefreshTimers.set(event.teamName, timer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'log-source-change') {
|
||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||
return;
|
||||
|
|
@ -1791,12 +2031,16 @@ export function initializeNotificationListeners(): () => void {
|
|||
teamMessageRefreshTimers = new Map();
|
||||
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
|
||||
teamPresenceRefreshTimers = new Map();
|
||||
for (const t of memberAdvisorySafetyRefreshTimers.values()) clearTimeout(t);
|
||||
memberAdvisorySafetyRefreshTimers = new Map();
|
||||
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
||||
memberSpawnRefreshTimers = new Map();
|
||||
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
|
||||
teamAgentRuntimeRefreshTimers = new Map();
|
||||
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
||||
toolActivityTimers = new Map();
|
||||
for (const t of taskLogActivityTimers.values()) clearTimeout(t);
|
||||
taskLogActivityTimers = new Map();
|
||||
for (const state of processLiteStructuralReconcileTimers.values()) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext
|
|||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskFinalForCompletionNotification,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||
|
|
@ -328,6 +331,7 @@ function collectTeamScopedStateRemovals(
|
|||
| 'provisioningStartedAtFloorByTeam'
|
||||
| 'leadActivityByTeam'
|
||||
| 'leadContextByTeam'
|
||||
| 'activeTaskLogActivityByTeam'
|
||||
| 'activeToolsByTeam'
|
||||
| 'finishedVisibleByTeam'
|
||||
| 'toolHistoryByTeam'
|
||||
|
|
@ -353,6 +357,7 @@ function collectTeamScopedStateRemovals(
|
|||
);
|
||||
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
|
||||
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
|
||||
const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName);
|
||||
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
|
||||
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
|
||||
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
|
||||
|
|
@ -378,6 +383,9 @@ function collectTeamScopedStateRemovals(
|
|||
: {}),
|
||||
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
|
||||
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
|
||||
...(nextActiveTaskLogActivity
|
||||
? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity }
|
||||
: {}),
|
||||
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
|
||||
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
|
||||
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
|
||||
|
|
@ -1382,11 +1390,12 @@ function detectStatusChangeNotifications(
|
|||
if (!oldTask) continue;
|
||||
|
||||
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
|
||||
const taskKanbanColumn = getTaskKanbanColumn(task);
|
||||
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
|
||||
const taskKanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||
const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask);
|
||||
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
|
||||
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
|
||||
const becameNeedsFix = task.reviewState === 'needsFix' && oldTask.reviewState !== 'needsFix';
|
||||
const becameNeedsFix =
|
||||
isTeamTaskNeedsFixActionable(task) && !isTeamTaskNeedsFixActionable(oldTask);
|
||||
|
||||
const statusChanged = oldTask.status !== task.status;
|
||||
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
|
||||
|
|
@ -1681,7 +1690,7 @@ function detectAllTasksCompletedNotification(
|
|||
|
||||
for (const [teamName, tasks] of teamTasks) {
|
||||
if (tasks.length === 0) continue;
|
||||
const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted');
|
||||
const allCompleted = tasks.every(isTeamTaskFinalForCompletionNotification);
|
||||
if (!allCompleted) {
|
||||
// Reset so we can notify again if tasks become all-completed later
|
||||
notifiedAllCompletedTeams.delete(teamName);
|
||||
|
|
@ -1692,8 +1701,7 @@ function detectAllTasksCompletedNotification(
|
|||
// Check that at least one task was NOT completed before (real transition)
|
||||
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
|
||||
const wasAlreadyAllCompleted =
|
||||
oldTeamTasks.length > 0 &&
|
||||
oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted');
|
||||
oldTeamTasks.length > 0 && oldTeamTasks.every(isTeamTaskFinalForCompletionNotification);
|
||||
if (wasAlreadyAllCompleted) {
|
||||
notifiedAllCompletedTeams.add(teamName);
|
||||
continue;
|
||||
|
|
@ -2385,6 +2393,7 @@ export interface TeamSlice {
|
|||
provisioningStartedAtFloorByTeam: Record<string, string>;
|
||||
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||
leadContextByTeam: Record<string, LeadContextUsage>;
|
||||
activeTaskLogActivityByTeam: Record<string, Record<string, true>>;
|
||||
activeToolsByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
|
||||
finishedVisibleByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
|
||||
toolHistoryByTeam: Record<string, Record<string, ActiveToolCall[]>>;
|
||||
|
|
@ -2727,6 +2736,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
provisioningStartedAtFloorByTeam: {},
|
||||
leadActivityByTeam: {},
|
||||
leadContextByTeam: {},
|
||||
activeTaskLogActivityByTeam: {},
|
||||
activeToolsByTeam: {},
|
||||
finishedVisibleByTeam: {},
|
||||
toolHistoryByTeam: {},
|
||||
|
|
@ -3038,13 +3048,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
);
|
||||
}
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||
if (task.reviewState === 'needsFix') {
|
||||
if (isTeamTaskNeedsFixActionable(task)) {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
||||
}
|
||||
if (getTaskKanbanColumn(task) === 'approved') {
|
||||
if (getTeamTaskWorkflowColumn(task) === 'approved') {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||
}
|
||||
if (getTaskKanbanColumn(task) === 'review') {
|
||||
if (getTeamTaskWorkflowColumn(task) === 'review') {
|
||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
||||
}
|
||||
// Seed comment keys to prevent false notifications
|
||||
|
|
@ -3062,7 +3072,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
teamTasksMap.set(task.teamName, list);
|
||||
}
|
||||
for (const [teamName, teamTasks] of teamTasksMap) {
|
||||
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
|
||||
if (teamTasks.every(isTeamTaskFinalForCompletionNotification)) {
|
||||
notifiedAllCompletedTeams.add(teamName);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import {
|
|||
getTeamModelLabel,
|
||||
getTeamProviderLabel,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import {
|
||||
isNativeAppManagedBootstrapCheckText,
|
||||
isTeamInternalControlMessageEnvelope,
|
||||
} from '@shared/utils/teamInternalControlMessages';
|
||||
|
||||
import type { InboxMessage, TeamProviderId } from '@shared/types';
|
||||
|
||||
|
|
@ -125,6 +129,29 @@ export interface BootstrapAcknowledgementDisplay {
|
|||
body: string;
|
||||
}
|
||||
|
||||
export interface InternalControlMessageDisplay {
|
||||
summary: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function getInternalControlMessageDisplay(
|
||||
message: Pick<InboxMessage, 'text'> & Partial<Pick<InboxMessage, 'from' | 'source'>>
|
||||
): InternalControlMessageDisplay | null {
|
||||
if (!isTeamInternalControlMessageEnvelope(message)) {
|
||||
return null;
|
||||
}
|
||||
if (isNativeAppManagedBootstrapCheckText(message.text)) {
|
||||
return {
|
||||
summary: 'Internal bootstrap check',
|
||||
body: 'Internal bootstrap check hidden in the UI.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
summary: 'Internal control message',
|
||||
body: 'Internal control message hidden in the UI.',
|
||||
};
|
||||
}
|
||||
|
||||
export function getBootstrapPromptDisplay(
|
||||
message: Pick<InboxMessage, 'text' | 'to'>
|
||||
): BootstrapPromptDisplay | null {
|
||||
|
|
@ -209,8 +236,11 @@ export function getBootstrapAcknowledgementDisplay(
|
|||
};
|
||||
}
|
||||
|
||||
export function getSanitizedInboxMessageText(message: Pick<InboxMessage, 'text' | 'to'>): string {
|
||||
export function getSanitizedInboxMessageText(
|
||||
message: Pick<InboxMessage, 'text' | 'to'> & Partial<Pick<InboxMessage, 'from' | 'source'>>
|
||||
): string {
|
||||
return (
|
||||
getInternalControlMessageDisplay(message)?.body ??
|
||||
getBootstrapPromptDisplay(message)?.body ??
|
||||
getBootstrapAcknowledgementDisplay(message as Pick<InboxMessage, 'text' | 'from'>)?.body ??
|
||||
message.text ??
|
||||
|
|
@ -219,9 +249,11 @@ export function getSanitizedInboxMessageText(message: Pick<InboxMessage, 'text'
|
|||
}
|
||||
|
||||
export function getSanitizedInboxMessageSummary(
|
||||
message: Pick<InboxMessage, 'text' | 'to' | 'from' | 'summary'>
|
||||
message: Pick<InboxMessage, 'text' | 'to' | 'from' | 'summary'> &
|
||||
Partial<Pick<InboxMessage, 'source'>>
|
||||
): string {
|
||||
return (
|
||||
getInternalControlMessageDisplay(message)?.summary ??
|
||||
getBootstrapPromptDisplay(message)?.summary ??
|
||||
getBootstrapAcknowledgementDisplay(message)?.summary ??
|
||||
message.summary ??
|
||||
|
|
|
|||
|
|
@ -321,10 +321,59 @@ function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined)
|
|||
}
|
||||
|
||||
function appendRuntimeAdvisoryRawMessage(base: string, message: string | undefined): string {
|
||||
const trimmed = message?.trim();
|
||||
const trimmed = formatRuntimeAdvisoryDisplayMessage(message);
|
||||
return trimmed ? `${base}\n\n${trimmed}` : base;
|
||||
}
|
||||
|
||||
function isOpenCodeRuntimeDeliveryAdvisoryMessage(message: string | undefined): boolean {
|
||||
const displayMessage = formatRuntimeAdvisoryDisplayMessage(message);
|
||||
return (
|
||||
displayMessage.startsWith('OpenCode runtime delivery') ||
|
||||
displayMessage.startsWith('OpenCode returned an empty assistant turn') ||
|
||||
displayMessage.startsWith('OpenCode accepted the prompt') ||
|
||||
displayMessage.startsWith('OpenCode responded, but did not create') ||
|
||||
displayMessage.startsWith('OpenCode created a reply without') ||
|
||||
displayMessage.startsWith('OpenCode used tools, but did not create')
|
||||
);
|
||||
}
|
||||
|
||||
function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): string {
|
||||
const trimmed = message?.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
if (trimmed === 'empty_assistant_turn') {
|
||||
return 'OpenCode returned an empty assistant turn.';
|
||||
}
|
||||
if (trimmed === 'prompt_delivered_no_assistant_message') {
|
||||
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
|
||||
}
|
||||
if (
|
||||
trimmed === 'visible_reply_still_required' ||
|
||||
trimmed === 'visible_reply_ack_only_still_requires_answer' ||
|
||||
trimmed === 'plain_text_ack_only_still_requires_answer'
|
||||
) {
|
||||
return 'OpenCode responded, but did not create a visible message_send reply.';
|
||||
}
|
||||
if (
|
||||
trimmed === 'visible_reply_destination_not_found_yet' ||
|
||||
trimmed === 'visible_reply_missing_relayOfMessageId'
|
||||
) {
|
||||
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
|
||||
}
|
||||
if (trimmed === 'non_visible_tool_without_task_progress') {
|
||||
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
|
||||
}
|
||||
if (
|
||||
trimmed.startsWith(
|
||||
'OpenCode bootstrap MCP did not complete required tools before assistant response:'
|
||||
)
|
||||
) {
|
||||
return 'OpenCode runtime delivery did not complete.';
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatRuntimeAdvisoryBaseLabel(
|
||||
advisory: MemberRuntimeAdvisory,
|
||||
providerId: TeamProviderId | undefined
|
||||
|
|
@ -344,8 +393,16 @@ function formatRuntimeAdvisoryBaseLabel(
|
|||
return 'Network error';
|
||||
case 'provider_overloaded':
|
||||
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
|
||||
case 'protocol_proof_missing':
|
||||
return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing';
|
||||
case 'backend_error':
|
||||
case 'unknown':
|
||||
if (
|
||||
providerId === 'opencode' &&
|
||||
isOpenCodeRuntimeDeliveryAdvisoryMessage(advisory.message)
|
||||
) {
|
||||
return 'OpenCode delivery error';
|
||||
}
|
||||
return providerLabel ? `${providerLabel} API error` : 'API error';
|
||||
default:
|
||||
return 'API error';
|
||||
|
|
@ -365,6 +422,8 @@ function formatRuntimeAdvisoryBaseLabel(
|
|||
return 'Network retry';
|
||||
case 'provider_overloaded':
|
||||
return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry';
|
||||
case 'protocol_proof_missing':
|
||||
return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing';
|
||||
case 'backend_error':
|
||||
case 'unknown':
|
||||
return 'Provider retry';
|
||||
|
|
@ -407,8 +466,24 @@ function formatRuntimeAdvisoryTitle(
|
|||
'Provider is temporarily overloaded.',
|
||||
advisory.message
|
||||
);
|
||||
case 'protocol_proof_missing':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
providerId === 'opencode'
|
||||
? 'OpenCode delivery completed without required visible/progress proof.'
|
||||
: 'Runtime delivery completed without required protocol proof.',
|
||||
advisory.message
|
||||
);
|
||||
case 'backend_error':
|
||||
case 'unknown':
|
||||
if (
|
||||
providerId === 'opencode' &&
|
||||
isOpenCodeRuntimeDeliveryAdvisoryMessage(advisory.message)
|
||||
) {
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
'OpenCode runtime delivery error.',
|
||||
advisory.message
|
||||
);
|
||||
}
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
`${providerLabel ?? 'Provider'} API error.`,
|
||||
advisory.message
|
||||
|
|
@ -449,6 +524,13 @@ function formatRuntimeAdvisoryTitle(
|
|||
'Provider is temporarily overloaded. SDK is retrying automatically.',
|
||||
advisory.message
|
||||
);
|
||||
case 'protocol_proof_missing':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
providerId === 'opencode'
|
||||
? 'OpenCode delivery is waiting for required visible/progress proof.'
|
||||
: 'Runtime delivery is waiting for required protocol proof.',
|
||||
advisory.message
|
||||
);
|
||||
case 'backend_error':
|
||||
case 'unknown':
|
||||
return appendRuntimeAdvisoryRawMessage(
|
||||
|
|
@ -505,6 +587,9 @@ export function getMemberRuntimeAdvisoryTone(
|
|||
if (!advisory) {
|
||||
return null;
|
||||
}
|
||||
if (advisory.reasonCode === 'protocol_proof_missing') {
|
||||
return 'warning';
|
||||
}
|
||||
return advisory.kind === 'api_error' ? 'error' : 'warning';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,22 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
|
|||
if (normalized === 'prompt_delivered_no_assistant_message') {
|
||||
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
|
||||
}
|
||||
if (
|
||||
normalized === 'visible_reply_still_required' ||
|
||||
normalized === 'visible_reply_ack_only_still_requires_answer' ||
|
||||
normalized === 'plain_text_ack_only_still_requires_answer'
|
||||
) {
|
||||
return 'OpenCode responded, but did not create a visible message_send reply.';
|
||||
}
|
||||
if (
|
||||
normalized === 'visible_reply_destination_not_found_yet' ||
|
||||
normalized === 'visible_reply_missing_relayOfMessageId'
|
||||
) {
|
||||
return 'OpenCode created a reply without the required relayOfMessageId correlation.';
|
||||
}
|
||||
if (normalized === 'non_visible_tool_without_task_progress') {
|
||||
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskDeleted,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
import type { GlobalTask } from '@shared/types';
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
|
|
@ -15,10 +22,29 @@ export interface TaskStatusCounts {
|
|||
completed: number;
|
||||
}
|
||||
|
||||
function incrementStatus(counts: TaskStatusCounts, status: string): TaskStatusCounts {
|
||||
if (status === 'pending') return { ...counts, pending: counts.pending + 1 };
|
||||
if (status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 };
|
||||
if (status === 'completed') return { ...counts, completed: counts.completed + 1 };
|
||||
function incrementTaskStatus(
|
||||
counts: TaskStatusCounts,
|
||||
task: {
|
||||
status: string;
|
||||
reviewState?: string | null;
|
||||
kanbanColumn?: string | null;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
): TaskStatusCounts {
|
||||
if (isTeamTaskDeleted(task)) return counts;
|
||||
if (getTeamTaskWorkflowColumn(task) === 'approved') {
|
||||
return { ...counts, completed: counts.completed + 1 };
|
||||
}
|
||||
if (isTeamTaskNeedsFixActionable(task)) {
|
||||
return task.status === 'in_progress'
|
||||
? { ...counts, inProgress: counts.inProgress + 1 }
|
||||
: { ...counts, pending: counts.pending + 1 };
|
||||
}
|
||||
if (task.status === 'pending') return { ...counts, pending: counts.pending + 1 };
|
||||
if (isTeamTaskFinishedForDependency(task)) {
|
||||
return { ...counts, completed: counts.completed + 1 };
|
||||
}
|
||||
if (task.status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 };
|
||||
return counts;
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +55,7 @@ export function buildTaskCountsByProject(tasks: GlobalTask[]): Map<string, TaskS
|
|||
if (!task.projectPath) continue;
|
||||
const key = normalizePath(task.projectPath);
|
||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
||||
map.set(key, incrementStatus(counts, task.status));
|
||||
map.set(key, incrementTaskStatus(counts, task));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
|
@ -40,22 +66,29 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map<string, TaskStat
|
|||
for (const task of tasks) {
|
||||
const key = task.teamName;
|
||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
||||
map.set(key, incrementStatus(counts, task.status));
|
||||
map.set(key, incrementTaskStatus(counts, task));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
|
||||
export function buildTaskCountsByOwner(
|
||||
tasks: { owner?: string | null; status: string }[]
|
||||
tasks: {
|
||||
owner?: string | null;
|
||||
status: string;
|
||||
reviewState?: string | null;
|
||||
kanbanColumn?: string | null;
|
||||
deletedAt?: string | null;
|
||||
}[]
|
||||
): Map<string, TaskStatusCounts> {
|
||||
const map = new Map<string, TaskStatusCounts>();
|
||||
for (const task of tasks) {
|
||||
const owner = task.owner?.trim();
|
||||
if (!owner || task.status === 'deleted') continue;
|
||||
if (!owner || isTeamTaskDeleted(task)) continue;
|
||||
if (getTeamTaskWorkflowColumn(task) === 'review') continue;
|
||||
const key = owner.toLowerCase();
|
||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
||||
map.set(key, incrementStatus(counts, task.status));
|
||||
map.set(key, incrementTaskStatus(counts, task));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
|
|
|||
18
src/renderer/utils/teamChangeEvents.ts
Normal file
18
src/renderer/utils/teamChangeEvents.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
const RUNTIME_TASK_EVENT_DETAIL_PREFIX = 'opencode-runtime-task-event:';
|
||||
|
||||
export function isTaskLogActivityChangeEvent(event: TeamChangeEvent): boolean {
|
||||
if (event.type !== 'task-log-change') {
|
||||
return false;
|
||||
}
|
||||
if (event.taskSignalKind === 'log') {
|
||||
return true;
|
||||
}
|
||||
if (event.taskSignalKind === 'change') {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof event.detail === 'string' && event.detail.startsWith(RUNTIME_TASK_EVENT_DETAIL_PREFIX)
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import {
|
|||
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
|
|
@ -110,6 +112,7 @@ export function filterTeamMessages(
|
|||
messages: InboxMessage[],
|
||||
options: {
|
||||
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
|
||||
includeAutomationEvents?: boolean;
|
||||
leadNames?: Iterable<string>;
|
||||
timeWindow?: { start: number; end: number } | null;
|
||||
filter: TeamMessagesFilter;
|
||||
|
|
@ -118,6 +121,7 @@ export function filterTeamMessages(
|
|||
): InboxMessage[] {
|
||||
const {
|
||||
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
|
||||
includeAutomationEvents = false,
|
||||
leadNames: rawLeadNames,
|
||||
timeWindow,
|
||||
filter,
|
||||
|
|
@ -125,7 +129,12 @@ export function filterTeamMessages(
|
|||
} = options;
|
||||
const leadNames = normalizeLeadNames(rawLeadNames);
|
||||
|
||||
let list = messages.filter((m) => m.messageKind !== 'task_comment_notification');
|
||||
let list = messages.filter(
|
||||
(m) =>
|
||||
m.messageKind !== 'task_comment_notification' &&
|
||||
(includeAutomationEvents || !isTaskStallRemediationMessage(m)) &&
|
||||
!isTeamInternalControlMessageEnvelope(m)
|
||||
);
|
||||
if (timeWindow) {
|
||||
list = list.filter((m) => {
|
||||
const ts = new Date(m.timestamp).getTime();
|
||||
|
|
|
|||
9
src/renderer/utils/teamTaskDisplayState.ts
Normal file
9
src/renderer/utils/teamTaskDisplayState.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { isTeamTaskActivelyWorked } from '@shared/utils/teamTaskState';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
export function isDisplayableCurrentTask(
|
||||
task: TeamTaskWithKanban | null | undefined
|
||||
): task is TeamTaskWithKanban {
|
||||
return Boolean(task && isTeamTaskActivelyWorked(task));
|
||||
}
|
||||
|
|
@ -126,6 +126,12 @@ export interface TaskStatusChangedEvent extends TaskHistoryEventBase {
|
|||
to: TeamTaskStatus;
|
||||
}
|
||||
|
||||
export interface TaskOwnerChangedEvent extends TaskHistoryEventBase {
|
||||
type: 'owner_changed';
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface TaskReviewRequestedEvent extends TaskHistoryEventBase {
|
||||
type: 'review_requested';
|
||||
from: TeamReviewState;
|
||||
|
|
@ -157,6 +163,7 @@ export interface TaskReviewStartedEvent extends TaskHistoryEventBase {
|
|||
export type TaskHistoryEvent =
|
||||
| TaskCreatedEvent
|
||||
| TaskStatusChangedEvent
|
||||
| TaskOwnerChangedEvent
|
||||
| TaskReviewRequestedEvent
|
||||
| TaskReviewChangesRequestedEvent
|
||||
| TaskReviewApprovedEvent
|
||||
|
|
@ -427,6 +434,7 @@ export type InboxMessageKind =
|
|||
| 'slash_command'
|
||||
| 'slash_command_result'
|
||||
| 'task_comment_notification'
|
||||
| 'task_stall_remediation'
|
||||
| 'member_work_sync_nudge'
|
||||
| 'agent_error';
|
||||
|
||||
|
|
@ -821,6 +829,7 @@ export interface MemberRuntimeAdvisory {
|
|||
| 'codex_native_timeout'
|
||||
| 'network_error'
|
||||
| 'provider_overloaded'
|
||||
| 'protocol_proof_missing'
|
||||
| 'backend_error'
|
||||
| 'unknown';
|
||||
message?: string;
|
||||
|
|
@ -1005,6 +1014,27 @@ export interface PersistedTeamLaunchMemberSources {
|
|||
duplicateRespawnBlocked?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenCodeAppManagedBootstrapCandidate {
|
||||
schemaVersion: 1;
|
||||
source: 'app_managed_bootstrap';
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
runtimeSessionId: string;
|
||||
messageID: string;
|
||||
contextHash: string;
|
||||
briefingHash: string;
|
||||
injectionVerifiedAt: string;
|
||||
candidateAt: string;
|
||||
model?: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
export type OpenCodeBootstrapEvidenceSource = 'runtime_bootstrap_checkin' | 'app_managed_bootstrap';
|
||||
|
||||
export type OpenCodeBootstrapMode = 'model_tool_checkin' | 'app_managed_context';
|
||||
|
||||
export interface PersistedTeamLaunchMemberState {
|
||||
name: string;
|
||||
providerId?: TeamProviderId;
|
||||
|
|
@ -1032,6 +1062,9 @@ export interface PersistedTeamLaunchMemberState {
|
|||
/** OpenCode runtime run id that produced the current runtimeSessionId/liveness evidence. */
|
||||
runtimeRunId?: string;
|
||||
runtimeSessionId?: string;
|
||||
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||
bootstrapMode?: OpenCodeBootstrapMode;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
|
|
@ -1170,12 +1203,15 @@ export interface TeamChangeEvent {
|
|||
| 'lead-message'
|
||||
| 'tool-activity'
|
||||
| 'member-turn-settled'
|
||||
| 'member-advisory'
|
||||
| 'process'
|
||||
| 'member-spawn';
|
||||
teamName: string;
|
||||
runId?: string;
|
||||
detail?: string;
|
||||
taskId?: string;
|
||||
/** Distinguishes real task log freshness from task-change presence freshness. */
|
||||
taskSignalKind?: 'log' | 'change';
|
||||
}
|
||||
|
||||
export interface ProjectBranchChangeEvent {
|
||||
|
|
|
|||
|
|
@ -14,18 +14,8 @@ export function normalizeReviewState(value: unknown): TeamReviewState {
|
|||
}
|
||||
|
||||
export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
|
||||
// Prefer derivation from historyEvents when available
|
||||
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
|
||||
const derived = getDerivedReviewStateFromHistory({
|
||||
historyEvents: task.historyEvents as TaskHistoryEvent[],
|
||||
});
|
||||
if (derived) {
|
||||
return derived;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackStatus = typeof task.status === 'string' ? task.status : null;
|
||||
const normalizeFallback = (value: unknown): TeamReviewState | null => {
|
||||
const normalizeForStatus = (value: unknown): TeamReviewState | null => {
|
||||
const explicit = normalizeReviewState(value);
|
||||
if (explicit === 'none') return null;
|
||||
|
||||
|
|
@ -36,16 +26,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
|
|||
return explicit === 'needsFix' ? 'needsFix' : 'none';
|
||||
}
|
||||
if (fallbackStatus === 'completed') {
|
||||
return explicit === 'review' || explicit === 'approved' ? explicit : 'none';
|
||||
return explicit === 'review' || explicit === 'approved' || explicit === 'needsFix'
|
||||
? explicit
|
||||
: 'none';
|
||||
}
|
||||
return explicit;
|
||||
};
|
||||
|
||||
const explicit = normalizeFallback(task.reviewState);
|
||||
// Prefer derivation from historyEvents when available
|
||||
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
|
||||
const derived = getDerivedReviewStateFromHistory({
|
||||
historyEvents: task.historyEvents as TaskHistoryEvent[],
|
||||
});
|
||||
if (derived !== null) {
|
||||
return normalizeForStatus(derived) ?? 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const explicit = normalizeForStatus(task.reviewState);
|
||||
if (explicit) return explicit;
|
||||
|
||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
||||
return normalizeFallback(task.kanbanColumn) ?? 'none';
|
||||
return normalizeForStatus(task.kanbanColumn) ?? 'none';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getReviewStateFromTask } from './reviewState';
|
||||
import { getTeamTaskWorkflowColumn } from './teamTaskState';
|
||||
|
||||
import type { TeamReviewState } from '@shared/types';
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ interface TaskChangeStateLike {
|
|||
reviewState?: TeamReviewState | null;
|
||||
historyEvents?: unknown[];
|
||||
kanbanColumn?: 'review' | 'approved' | null;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
||||
|
|
@ -17,8 +19,15 @@ function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
|||
|
||||
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
|
||||
const reviewState = getEffectiveReviewState(task);
|
||||
if (reviewState === 'approved') return 'approved';
|
||||
if (reviewState === 'review') return 'review';
|
||||
const workflowColumn = getTeamTaskWorkflowColumn({
|
||||
status: task.status ?? '',
|
||||
reviewState,
|
||||
kanbanColumn: task.kanbanColumn,
|
||||
deletedAt: task.deletedAt,
|
||||
});
|
||||
if (workflowColumn === 'approved') return 'approved';
|
||||
if (workflowColumn === 'review') return 'review';
|
||||
if (reviewState === 'needsFix') return 'active';
|
||||
return task.status === 'completed' ? 'completed' : 'active';
|
||||
}
|
||||
|
||||
|
|
|
|||
16
src/shared/utils/teamAutomationMessages.ts
Normal file
16
src/shared/utils/teamAutomationMessages.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
type AutomationMessageLike = Pick<InboxMessage, 'from' | 'messageId' | 'messageKind' | 'source'>;
|
||||
|
||||
export function isTaskStallRemediationMessage(message: AutomationMessageLike): boolean {
|
||||
if (message.messageKind === 'task_stall_remediation') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||
return (
|
||||
message.source === 'system_notification' &&
|
||||
message.from === 'system' &&
|
||||
messageId.startsWith('task-stall:')
|
||||
);
|
||||
}
|
||||
87
src/shared/utils/teamInternalControlMessages.ts
Normal file
87
src/shared/utils/teamInternalControlMessages.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
const NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN = '<agent_teams_native_app_managed_bootstrap_check>';
|
||||
const LEAD_INBOX_RELAY_PROMPT_OPEN = 'You have new inbox messages addressed to you (team lead ';
|
||||
const TEAMMATE_MESSAGE_OPEN_RE = /^<teammate-message\s/i;
|
||||
|
||||
const INTERNAL_CONTROL_MESSAGE_SOURCES = new Set([
|
||||
'lead_process',
|
||||
'lead_session',
|
||||
'runtime_delivery',
|
||||
'system_notification',
|
||||
]);
|
||||
const INTERNAL_BOOTSTRAP_AUTHORS = new Set(['team-lead', 'lead', 'orchestrator']);
|
||||
|
||||
export function stripTranscriptSpeakerPrefix(value: string): string {
|
||||
let normalized = value.trim();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const next = normalized.replace(/^(?:Human|User):\s*/i, '').trimStart();
|
||||
if (next === normalized) break;
|
||||
normalized = next;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isNativeAppManagedBootstrapCheckText(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
stripTranscriptSpeakerPrefix(value).startsWith(NATIVE_APP_MANAGED_BOOTSTRAP_CHECK_OPEN)
|
||||
);
|
||||
}
|
||||
|
||||
export function isLeadInboxRelayControlPromptText(value: unknown): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const text = stripTranscriptSpeakerPrefix(value);
|
||||
return (
|
||||
text.startsWith(LEAD_INBOX_RELAY_PROMPT_OPEN) &&
|
||||
text.includes('Process them in order (oldest first).') &&
|
||||
text.includes('\nMessages:')
|
||||
);
|
||||
}
|
||||
|
||||
export function isTeammateProtocolControlText(value: unknown): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return TEAMMATE_MESSAGE_OPEN_RE.test(stripTranscriptSpeakerPrefix(value));
|
||||
}
|
||||
|
||||
export function isTeamInternalControlMessageText(value: unknown): boolean {
|
||||
return (
|
||||
isNativeAppManagedBootstrapCheckText(value) ||
|
||||
isLeadInboxRelayControlPromptText(value) ||
|
||||
isTeammateProtocolControlText(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isTeamInternalControlMessageEnvelope(message: {
|
||||
text?: unknown;
|
||||
source?: unknown;
|
||||
from?: unknown;
|
||||
}): boolean {
|
||||
if (isNativeAppManagedBootstrapCheckText(message.text)) {
|
||||
if (typeof message.source === 'string') {
|
||||
return INTERNAL_CONTROL_MESSAGE_SOURCES.has(message.source);
|
||||
}
|
||||
return (
|
||||
typeof message.from === 'string' &&
|
||||
INTERNAL_BOOTSTRAP_AUTHORS.has(message.from.trim().toLowerCase())
|
||||
);
|
||||
}
|
||||
if (!isTeamInternalControlMessageText(message.text)) {
|
||||
return false;
|
||||
}
|
||||
return typeof message.source === 'string' && INTERNAL_CONTROL_MESSAGE_SOURCES.has(message.source);
|
||||
}
|
||||
|
||||
export function stripExactInternalControlEchoPrefix(
|
||||
value: string,
|
||||
expectedControlText: string
|
||||
): string {
|
||||
const text = stripTranscriptSpeakerPrefix(value);
|
||||
const expected = stripTranscriptSpeakerPrefix(expectedControlText);
|
||||
if (!expected || !text.startsWith(expected)) {
|
||||
return value.trim();
|
||||
}
|
||||
return text.slice(expected.length).trim();
|
||||
}
|
||||
102
src/shared/utils/teamTaskState.ts
Normal file
102
src/shared/utils/teamTaskState.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
export interface TeamTaskStateLike {
|
||||
status: string;
|
||||
reviewState?: string | null;
|
||||
kanbanColumn?: string | null;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
export type TeamTaskWorkflowColumn = 'review' | 'approved';
|
||||
|
||||
export function isTeamTaskApproved(task: TeamTaskStateLike): boolean {
|
||||
if (isTeamTaskDeleted(task) || task.status === 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (task.kanbanColumn === 'approved') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.kanbanColumn === 'review') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return task.reviewState === 'approved';
|
||||
}
|
||||
|
||||
export function isTeamTaskDeleted(task: TeamTaskStateLike): boolean {
|
||||
return task.status === 'deleted' || Boolean(task.deletedAt);
|
||||
}
|
||||
|
||||
export function isTeamTaskActivelyWorked(task: TeamTaskStateLike): boolean {
|
||||
return (
|
||||
task.status === 'in_progress' &&
|
||||
getTeamTaskWorkflowColumn(task) !== 'review' &&
|
||||
!isTeamTaskApproved(task) &&
|
||||
!isTeamTaskDeleted(task)
|
||||
);
|
||||
}
|
||||
|
||||
export function isTeamTaskNeedsFixActionable(task: TeamTaskStateLike): boolean {
|
||||
return (
|
||||
task.reviewState === 'needsFix' &&
|
||||
!isTeamTaskDeleted(task) &&
|
||||
getTeamTaskWorkflowColumn(task) === undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolean {
|
||||
const workflowColumn = getTeamTaskWorkflowColumn(task);
|
||||
if (workflowColumn === 'approved') {
|
||||
return true;
|
||||
}
|
||||
if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) {
|
||||
return false;
|
||||
}
|
||||
return task.status === 'completed';
|
||||
}
|
||||
|
||||
export function isTeamTaskTerminalForActionableWork(task: TeamTaskStateLike): boolean {
|
||||
if (isTeamTaskDeleted(task)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workflowColumn = getTeamTaskWorkflowColumn(task);
|
||||
if (workflowColumn === 'approved') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (workflowColumn === 'review' || isTeamTaskNeedsFixActionable(task)) {
|
||||
return false;
|
||||
}
|
||||
return task.status === 'completed';
|
||||
}
|
||||
|
||||
export function isTeamTaskFinalForCompletionNotification(task: TeamTaskStateLike): boolean {
|
||||
return isTeamTaskTerminalForActionableWork(task);
|
||||
}
|
||||
|
||||
export function getTeamTaskWorkflowColumn(
|
||||
task: TeamTaskStateLike
|
||||
): TeamTaskWorkflowColumn | undefined {
|
||||
if (isTeamTaskDeleted(task) || task.status === 'pending') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (task.kanbanColumn === 'approved') {
|
||||
return 'approved';
|
||||
}
|
||||
|
||||
if (task.kanbanColumn === 'review') {
|
||||
return 'review';
|
||||
}
|
||||
|
||||
if (task.reviewState === 'approved') {
|
||||
return 'approved';
|
||||
}
|
||||
|
||||
if (task.reviewState === 'review') {
|
||||
return 'review';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
4
src/types/agent-teams-controller.d.ts
vendored
4
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -31,7 +31,7 @@ declare module 'agent-teams-controller' {
|
|||
completeTask(taskId: string, actor?: string): unknown;
|
||||
softDeleteTask(taskId: string, actor?: string): unknown;
|
||||
restoreTask(taskId: string, actor?: string): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null, actor?: string): unknown;
|
||||
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
|
||||
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
|
|
@ -43,7 +43,7 @@ declare module 'agent-teams-controller' {
|
|||
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
memberBriefing(
|
||||
memberName: string,
|
||||
options?: { runtimeProvider?: 'native' | 'opencode' }
|
||||
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
|
||||
): Promise<string>;
|
||||
leadBriefing(): Promise<string>;
|
||||
taskBriefing(memberName: string): Promise<string>;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,177 @@ describe('buildActionableWorkAgenda', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('does not keep stale terminal task state in the work agenda', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
generatedAt: '2026-05-06T19:06:07.257Z',
|
||||
members: [{ name: 'jack' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-completed',
|
||||
displayId: '#6d4db591',
|
||||
subject: 'Completed after stale work-sync status',
|
||||
status: 'completed',
|
||||
owner: 'jack',
|
||||
},
|
||||
{
|
||||
id: 'task-deleted',
|
||||
subject: 'Deleted after stale work-sync status',
|
||||
status: 'in_progress',
|
||||
owner: 'jack',
|
||||
deletedAt: '2026-05-06T19:06:07.257Z',
|
||||
},
|
||||
{
|
||||
id: 'task-review-approved',
|
||||
subject: 'Approved review after stale work-sync status',
|
||||
status: 'in_progress',
|
||||
owner: 'jack',
|
||||
reviewState: 'approved',
|
||||
},
|
||||
{
|
||||
id: 'task-kanban-approved',
|
||||
subject: 'Approved kanban after stale work-sync status',
|
||||
status: 'in_progress',
|
||||
owner: 'jack',
|
||||
kanbanColumn: 'approved',
|
||||
},
|
||||
{
|
||||
id: 'task-stale-needsfix-approved',
|
||||
subject: 'Approved task after stale needsFix status',
|
||||
status: 'in_progress',
|
||||
owner: 'jack',
|
||||
reviewState: 'needsFix',
|
||||
kanbanColumn: 'approved',
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('projects reopened in-progress work after a previous completion', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
generatedAt: '2026-05-06T18:56:19.173Z',
|
||||
members: [{ name: 'jack' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-reopened',
|
||||
displayId: '#6d4db591',
|
||||
subject: 'Reopened work',
|
||||
status: 'in_progress',
|
||||
owner: 'jack',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-completed',
|
||||
type: 'status_changed',
|
||||
timestamp: '2026-05-06T18:50:05.662Z',
|
||||
from: 'in_progress',
|
||||
to: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'evt-reopened',
|
||||
type: 'status_changed',
|
||||
timestamp: '2026-05-06T18:56:19.173Z',
|
||||
from: 'completed',
|
||||
to: 'in_progress',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
|
||||
['task-reopened', 'owned_in_progress_task'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not treat approved dependencies as waiting blockers', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
generatedAt: '2026-05-06T19:06:07.257Z',
|
||||
members: [{ name: 'jack' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-approved',
|
||||
subject: 'Approved dependency',
|
||||
status: 'in_progress',
|
||||
owner: 'alice',
|
||||
kanbanColumn: 'approved',
|
||||
},
|
||||
{
|
||||
id: 'task-dependent',
|
||||
subject: 'Depends on approved task',
|
||||
status: 'in_progress',
|
||||
owner: 'jack',
|
||||
blockedBy: ['task-approved'],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
|
||||
['task-dependent', 'owned_in_progress_task'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps dependencies blocked while completed work is still in review', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
generatedAt: '2026-05-06T19:06:07.257Z',
|
||||
members: [{ name: 'jack' }, { name: 'alice' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-review',
|
||||
subject: 'Dependency waiting for review',
|
||||
status: 'completed',
|
||||
owner: 'alice',
|
||||
reviewState: 'review',
|
||||
kanbanColumn: 'review',
|
||||
},
|
||||
{
|
||||
id: 'task-dependent',
|
||||
subject: 'Depends on reviewed task',
|
||||
status: 'in_progress',
|
||||
owner: 'jack',
|
||||
blockedBy: ['task-review'],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not let stale kanban approved hide a reopened pending task', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
generatedAt: '2026-05-06T19:06:07.257Z',
|
||||
members: [{ name: 'jack' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-reopened-pending',
|
||||
subject: 'Reopened pending work',
|
||||
status: 'pending',
|
||||
owner: 'jack',
|
||||
kanbanColumn: 'approved',
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items.map((item) => [item.taskId, item.reason])).toEqual([
|
||||
['task-reopened-pending', 'owned_pending_task'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('assigns active review work to the current-cycle reviewer only', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
|
|
@ -78,6 +249,98 @@ describe('buildActionableWorkAgenda', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps completed tasks actionable for the current reviewer while workflow is review', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-review',
|
||||
subject: 'Review completed work',
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewState: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-review',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items).toHaveLength(1);
|
||||
expect(agenda.items[0]).toMatchObject({
|
||||
taskId: 'task-review',
|
||||
kind: 'review',
|
||||
assignee: 'alice',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not assign owner work while stale in-progress task is in review workflow', () => {
|
||||
const ownerAgenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-review',
|
||||
subject: 'Review in progress status',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
reviewState: 'none',
|
||||
kanbanColumn: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-review',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
const reviewerAgenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-review',
|
||||
subject: 'Review in progress status',
|
||||
status: 'in_progress',
|
||||
owner: 'bob',
|
||||
reviewState: 'none',
|
||||
kanbanColumn: 'review',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-review',
|
||||
type: 'review_requested',
|
||||
timestamp: '2026-04-29T00:00:00.000Z',
|
||||
reviewer: 'alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(ownerAgenda.items).toEqual([]);
|
||||
expect(reviewerAgenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
|
||||
['task-review', 'review', 'current_cycle_review_assigned'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not resurrect a stale reviewer after review was approved', () => {
|
||||
const agenda = buildActionableWorkAgenda({
|
||||
teamName: 'team-a',
|
||||
|
|
@ -257,12 +520,20 @@ describe('buildActionableWorkAgenda', () => {
|
|||
owner: 'bob',
|
||||
reviewState: 'needsFix',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
subject: 'Fix completed review',
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewState: 'needsFix',
|
||||
},
|
||||
],
|
||||
hash,
|
||||
});
|
||||
|
||||
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
|
||||
['task-1', 'work', 'review_changes_requested'],
|
||||
['task-2', 'work', 'review_changes_requested'],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue