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) => {
|
return updateTask(paths, taskRef, (task) => {
|
||||||
if (owner == null || owner === 'clear' || owner === 'none') {
|
const previousOwner = normalizeOwnerValue(task.owner);
|
||||||
delete task.owner;
|
const nextOwner = normalizeOwnerValue(owner);
|
||||||
|
|
||||||
|
if (nextOwner) {
|
||||||
|
task.owner = nextOwner;
|
||||||
} else {
|
} 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;
|
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 { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
|
||||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||||
const nextOwner = isClearOwnerValue(owner)
|
const nextOwner = isClearOwnerValue(owner)
|
||||||
? owner
|
? owner
|
||||||
: assertKnownTaskActor(context, owner, 'task 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 {
|
return {
|
||||||
previousTask: before,
|
previousTask: before,
|
||||||
updatedTask: after,
|
updatedTask: after,
|
||||||
|
|
@ -707,7 +707,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
|
||||||
- Human-facing summaries should use the short display label like #abcd1234 for readability.
|
- 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:
|
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:
|
- 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.
|
- Do this only when you are genuinely taking over the work.
|
||||||
- Reviewing, approving, or leaving comments does NOT require changing ownership.
|
- Reviewing, approving, or leaving comments does NOT require changing ownership.
|
||||||
2. Use MCP tool task_start to mark task started:
|
2. Use MCP tool task_start to mark task started:
|
||||||
|
|
@ -911,14 +911,17 @@ async function memberBriefing(context, memberName, options = {}) {
|
||||||
config.projectPath.trim() :
|
config.projectPath.trim() :
|
||||||
'';
|
'';
|
||||||
|
|
||||||
const activeProcesses = processStore
|
const includeActiveProcesses = options.includeActiveProcesses !== false;
|
||||||
.listProcesses(context.paths)
|
const activeProcesses = includeActiveProcesses ?
|
||||||
.filter(
|
processStore
|
||||||
(entry) =>
|
.listProcesses(context.paths)
|
||||||
entry &&
|
.filter(
|
||||||
entry.alive &&
|
(entry) =>
|
||||||
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
|
entry &&
|
||||||
);
|
entry.alive &&
|
||||||
|
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
|
||||||
|
) :
|
||||||
|
[];
|
||||||
|
|
||||||
const taskQueue = await taskBriefing(context, requestedMemberName);
|
const taskQueue = await taskBriefing(context, requestedMemberName);
|
||||||
const completionNotifyExample = messagingProtocol.buildLeadMessageExample({
|
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', () => {
|
it('wraps review instructions in the canonical agent block format used by the UI', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
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;
|
completeTask(taskId: string, actor?: string): unknown;
|
||||||
softDeleteTask(taskId: string, actor?: string): unknown;
|
softDeleteTask(taskId: string, actor?: string): unknown;
|
||||||
restoreTask(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;
|
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
|
||||||
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
||||||
attachTaskFile(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;
|
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||||
memberBriefing(
|
memberBriefing(
|
||||||
memberName: string,
|
memberName: string,
|
||||||
options?: { runtimeProvider?: 'native' | 'opencode' }
|
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
leadBriefing(): Promise<string>;
|
leadBriefing(): Promise<string>;
|
||||||
taskBriefing(memberName: string): Promise<string>;
|
taskBriefing(memberName: string): Promise<string>;
|
||||||
|
|
|
||||||
|
|
@ -413,18 +413,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_set_owner',
|
name: 'task_set_owner',
|
||||||
description: 'Assign or clear task owner',
|
description: 'Assign, reassign, or clear task owner',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
owner: z.string().nullable(),
|
owner: z.string().nullable(),
|
||||||
|
actor: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, owner }) => {
|
execute: async ({ teamName, claudeDir, taskId, owner, actor }) => {
|
||||||
assertConfiguredTeam(teamName, claudeDir);
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
return await Promise.resolve(
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
|
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner, actor) as Record<
|
||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
>
|
>
|
||||||
|
|
@ -622,8 +623,15 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
memberName: z.string().min(1),
|
memberName: z.string().min(1),
|
||||||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
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);
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
|
|
@ -631,6 +639,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
|
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
|
||||||
...(runtimeProvider ? { runtimeProvider } : {}),
|
...(runtimeProvider ? { runtimeProvider } : {}),
|
||||||
|
...(includeActiveProcesses !== undefined ? { includeActiveProcesses } : {}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -601,15 +601,36 @@ describe('agent-teams-mcp tools', () => {
|
||||||
);
|
);
|
||||||
expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id);
|
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(
|
const owned = parseJsonToolResult(
|
||||||
await getTool('task_set_owner').execute({
|
await getTool('task_set_owner').execute({
|
||||||
claudeDir,
|
claudeDir,
|
||||||
teamName,
|
teamName,
|
||||||
taskId: createdTask.id,
|
taskId: createdTask.id,
|
||||||
owner: 'alice',
|
owner: 'alice',
|
||||||
|
actor: 'lead',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(owned.owner).toBe('alice');
|
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(
|
const commented = parseJsonToolResult(
|
||||||
await getTool('task_add_comment').execute({
|
await getTool('task_add_comment').execute({
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@
|
||||||
"dev:web": "node ./scripts/dev-web.mjs",
|
"dev:web": "node ./scripts/dev-web.mjs",
|
||||||
"dev:kill": "node bin/kill-dev.js",
|
"dev:kill": "node bin/kill-dev.js",
|
||||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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;
|
ctx.globalAlpha = opacity;
|
||||||
|
|
||||||
if (simplify) {
|
if (simplify) {
|
||||||
drawTaskPillLod(ctx, x, y, node, isSelected, isHovered);
|
drawTaskPillLod(ctx, x, y, node, time, isSelected, isHovered);
|
||||||
} else {
|
} else {
|
||||||
drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
|
drawTaskPill(ctx, x, y, node, time, isSelected, isHovered);
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +145,10 @@ function drawTaskPill(
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.hasLiveTaskLogs) {
|
||||||
|
drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time);
|
||||||
|
}
|
||||||
|
|
||||||
// Subject (main title — large)
|
// Subject (main title — large)
|
||||||
if (node.sublabel) {
|
if (node.sublabel) {
|
||||||
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
|
ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`;
|
||||||
|
|
@ -235,6 +239,7 @@ function drawTaskPillLod(
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
node: GraphNode,
|
node: GraphNode,
|
||||||
|
time: number,
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isHovered: boolean
|
isHovered: boolean
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -276,6 +281,45 @@ function drawTaskPillLod(
|
||||||
ctx.fill();
|
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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,8 @@ export interface GraphNode {
|
||||||
totalCommentCount?: number;
|
totalCommentCount?: number;
|
||||||
/** Unread comment count on this task */
|
/** Unread comment count on this task */
|
||||||
unreadCommentCount?: number;
|
unreadCommentCount?: number;
|
||||||
|
/** Recent live log activity is arriving for this task */
|
||||||
|
hasLiveTaskLogs?: boolean;
|
||||||
/** Synthetic overflow stack node instead of hidden task tails */
|
/** Synthetic overflow stack node instead of hidden task tails */
|
||||||
isOverflowStack?: boolean;
|
isOverflowStack?: boolean;
|
||||||
/** Number of hidden tasks behind this overflow stack */
|
/** Number of hidden tasks behind this overflow stack */
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,7 @@
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
|
|
@ -480,6 +481,7 @@
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"supports_prompt_caching": false,
|
"supports_prompt_caching": false,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_tool_choice": true
|
"supports_tool_choice": true
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-opus-4-7": {
|
"global.anthropic.claude-opus-4-7": {
|
||||||
|
|
@ -627,6 +629,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -656,6 +659,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -685,6 +689,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -713,6 +718,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -741,6 +747,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -1018,6 +1025,7 @@
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true
|
"supports_vision": true
|
||||||
|
|
@ -1141,6 +1149,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -1711,6 +1720,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -1718,6 +1728,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -1853,6 +1864,7 @@
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
|
|
@ -1880,6 +1892,7 @@
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
|
|
@ -1901,6 +1914,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": false,
|
"supports_assistant_prefill": false,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -1934,6 +1948,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": false,
|
"supports_assistant_prefill": false,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -1967,6 +1982,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": false,
|
"supports_assistant_prefill": false,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -2001,6 +2017,7 @@
|
||||||
"search_context_size_low": 0.01,
|
"search_context_size_low": 0.01,
|
||||||
"search_context_size_medium": 0.01
|
"search_context_size_medium": 0.01
|
||||||
},
|
},
|
||||||
|
"supports_adaptive_thinking": true,
|
||||||
"supports_assistant_prefill": false,
|
"supports_assistant_prefill": false,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
|
@ -2144,6 +2161,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_tool_choice": true
|
"supports_tool_choice": true
|
||||||
},
|
},
|
||||||
"databricks/databricks-claude-sonnet-4": {
|
"databricks/databricks-claude-sonnet-4": {
|
||||||
|
|
@ -2655,7 +2673,8 @@
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"output_cost_per_token": 0.000025,
|
"output_cost_per_token": 0.000025,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_vision": true
|
"supports_vision": true,
|
||||||
|
"supports_minimal_reasoning_effort": true
|
||||||
},
|
},
|
||||||
"gmi/anthropic/claude-sonnet-4.5": {
|
"gmi/anthropic/claude-sonnet-4.5": {
|
||||||
"input_cost_per_token": 0.000003,
|
"input_cost_per_token": 0.000003,
|
||||||
|
|
@ -3304,6 +3323,7 @@
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 159,
|
"tool_use_system_prompt_tokens": 159,
|
||||||
|
|
@ -3322,6 +3342,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
|
|
@ -3343,6 +3364,7 @@
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -3408,6 +3430,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"supports_xhigh_reasoning_effort": true,
|
"supports_xhigh_reasoning_effort": true,
|
||||||
|
|
@ -3786,6 +3809,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -3814,6 +3838,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -3841,6 +3866,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -4107,6 +4133,7 @@
|
||||||
"output_cost_per_token": 0.000025,
|
"output_cost_per_token": 0.000025,
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -4446,6 +4473,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -4472,6 +4500,7 @@
|
||||||
"supports_assistant_prefill": true,
|
"supports_assistant_prefill": true,
|
||||||
"supports_computer_use": true,
|
"supports_computer_use": true,
|
||||||
"supports_function_calling": true,
|
"supports_function_calling": true,
|
||||||
|
"supports_minimal_reasoning_effort": true,
|
||||||
"supports_pdf_input": true,
|
"supports_pdf_input": true,
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
|
|
@ -4638,6 +4667,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
@ -4778,6 +4808,7 @@
|
||||||
"supports_prompt_caching": true,
|
"supports_prompt_caching": true,
|
||||||
"supports_reasoning": true,
|
"supports_reasoning": true,
|
||||||
"supports_response_schema": true,
|
"supports_response_schema": true,
|
||||||
|
"supports_max_reasoning_effort": true,
|
||||||
"supports_tool_choice": true,
|
"supports_tool_choice": true,
|
||||||
"supports_vision": true,
|
"supports_vision": true,
|
||||||
"tool_use_system_prompt_tokens": 346,
|
"tool_use_system_prompt_tokens": 346,
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "0.0.18",
|
"version": "0.0.21",
|
||||||
"sourceRef": "v0.0.18",
|
"sourceRef": "v0.0.21",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.18.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.21.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"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'
|
? 'has_changes'
|
||||||
: undefined,
|
: undefined,
|
||||||
isBlocked: hiddenTasks.some((task) => task.isBlocked),
|
isBlocked: hiddenTasks.some((task) => task.isBlocked),
|
||||||
|
hasLiveTaskLogs: hiddenTasks.some((task) => task.hasLiveTaskLogs) ? true : undefined,
|
||||||
isOverflowStack: true,
|
isOverflowStack: true,
|
||||||
overflowCount: hiddenTasks.length,
|
overflowCount: hiddenTasks.length,
|
||||||
overflowTaskIds,
|
overflowTaskIds,
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,34 @@
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskDeleted,
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
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 TaskReviewerInput = Pick<TeamTaskWithKanban, 'reviewer' | 'reviewState' | 'kanbanColumn'>;
|
||||||
type TaskBlockInput = Pick<TeamTask, 'blockedBy'>;
|
type TaskBlockInput = Pick<TeamTask, 'blockedBy'>;
|
||||||
type TaskBlockState = Pick<TeamTask, 'status'>;
|
type TaskBlockState = Pick<
|
||||||
|
TeamTaskWithKanban,
|
||||||
|
'status' | 'reviewState' | 'kanbanColumn' | 'deletedAt'
|
||||||
|
>;
|
||||||
|
|
||||||
export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId {
|
export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId {
|
||||||
if (task.reviewState === 'approved') return 'approved';
|
const workflowColumn = getTeamTaskWorkflowColumn(task);
|
||||||
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
|
if (workflowColumn) return workflowColumn;
|
||||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
if (isTeamTaskNeedsFixActionable(task)) return 'review';
|
||||||
return task.kanbanColumn;
|
|
||||||
}
|
|
||||||
if (task.status === 'in_progress') return 'in_progress';
|
if (task.status === 'in_progress') return 'in_progress';
|
||||||
if (task.status === 'completed') return 'done';
|
if (task.status === 'completed') return 'done';
|
||||||
return 'todo';
|
return 'todo';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTaskInReviewCycle(task: TaskColumnInput): boolean {
|
export function isTaskInReviewCycle(task: TaskColumnInput): boolean {
|
||||||
return (
|
return isTeamTaskNeedsFixActionable(task) || getTeamTaskWorkflowColumn(task) === 'review';
|
||||||
task.reviewState === 'review' ||
|
|
||||||
task.reviewState === 'needsFix' ||
|
|
||||||
task.kanbanColumn === 'review'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTaskReviewer(
|
export function resolveTaskReviewer(
|
||||||
|
|
@ -43,6 +50,6 @@ export function isTaskBlocked(
|
||||||
|
|
||||||
return blockedBy.some((taskId) => {
|
return blockedBy.some((taskId) => {
|
||||||
const blocker = taskStateById.get(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';
|
} from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import {
|
||||||
|
isTeamTaskActivelyWorked,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
import { buildOrderedVisibleTeamGraphOwnerIds } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -41,6 +45,7 @@ import {
|
||||||
import {
|
import {
|
||||||
isTaskBlocked,
|
isTaskBlocked,
|
||||||
isTaskInReviewCycle,
|
isTaskInReviewCycle,
|
||||||
|
resolveTaskGraphColumn,
|
||||||
resolveTaskReviewer,
|
resolveTaskReviewer,
|
||||||
} from '../../core/domain/taskGraphSemantics';
|
} from '../../core/domain/taskGraphSemantics';
|
||||||
|
|
||||||
|
|
@ -118,7 +123,8 @@ export class TeamGraphAdapter {
|
||||||
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
memberSpawnSnapshot?: MemberSpawnStatusesSnapshot,
|
||||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
slotAssignments?: Record<string, GraphOwnerSlotAssignment>,
|
||||||
layoutMode: GraphLayoutMode = 'radial',
|
layoutMode: GraphLayoutMode = 'radial',
|
||||||
gridOwnerOrder?: readonly string[]
|
gridOwnerOrder?: readonly string[],
|
||||||
|
activeTaskLogActivity?: Record<string, true>
|
||||||
): GraphDataPort {
|
): GraphDataPort {
|
||||||
if (teamData?.teamName !== teamName) {
|
if (teamData?.teamName !== teamName) {
|
||||||
return TeamGraphAdapter.#emptyResult(teamName);
|
return TeamGraphAdapter.#emptyResult(teamName);
|
||||||
|
|
@ -203,7 +209,8 @@ export class TeamGraphAdapter {
|
||||||
commentReadState,
|
commentReadState,
|
||||||
memberNodeIdByAlias,
|
memberNodeIdByAlias,
|
||||||
leadId,
|
leadId,
|
||||||
leadName
|
leadName,
|
||||||
|
activeTaskLogActivity
|
||||||
);
|
);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||||
|
|
@ -539,8 +546,17 @@ export class TeamGraphAdapter {
|
||||||
spawn,
|
spawn,
|
||||||
pendingApprovalAgents?.has(member.name) ?? false
|
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({
|
const launchPresentation = buildMemberLaunchPresentation({
|
||||||
member,
|
member: presentationMember,
|
||||||
spawnStatus: spawn?.status,
|
spawnStatus: spawn?.status,
|
||||||
spawnLaunchState: spawn?.launchState,
|
spawnLaunchState: spawn?.launchState,
|
||||||
spawnLivenessSource: spawn?.livenessSource,
|
spawnLivenessSource: spawn?.livenessSource,
|
||||||
|
|
@ -577,10 +593,8 @@ export class TeamGraphAdapter {
|
||||||
? (launchPresentation.launchStatusLabel ?? undefined)
|
? (launchPresentation.launchStatusLabel ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
|
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
|
||||||
currentTaskId: member.currentTaskId ?? undefined,
|
currentTaskId: displayableCurrentTask?.id,
|
||||||
currentTaskSubject: member.currentTaskId
|
currentTaskSubject: displayableCurrentTask?.subject,
|
||||||
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
|
||||||
: undefined,
|
|
||||||
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
||||||
exceptionTone: exception?.exceptionTone,
|
exceptionTone: exception?.exceptionTone,
|
||||||
exceptionLabel: exception?.exceptionLabel,
|
exceptionLabel: exception?.exceptionLabel,
|
||||||
|
|
@ -627,14 +641,23 @@ export class TeamGraphAdapter {
|
||||||
commentReadState?: Record<string, unknown>,
|
commentReadState?: Record<string, unknown>,
|
||||||
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||||
leadId?: string,
|
leadId?: string,
|
||||||
leadName?: string
|
leadName?: string,
|
||||||
|
activeTaskLogActivity?: Record<string, true>
|
||||||
): void {
|
): 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 taskDisplayIds = new Map<string, string>();
|
||||||
const memberColorByName = new Map<string, string>();
|
const memberColorByName = new Map<string, string>();
|
||||||
|
|
||||||
for (const t of data.tasks) {
|
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)}`);
|
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
||||||
}
|
}
|
||||||
for (const member of data.members) {
|
for (const member of data.members) {
|
||||||
|
|
@ -657,9 +680,19 @@ export class TeamGraphAdapter {
|
||||||
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||||
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||||
const isReviewCycle = isTaskInReviewCycle(task);
|
const isReviewCycle = isTaskInReviewCycle(task);
|
||||||
|
const graphColumn = resolveTaskGraphColumn(task);
|
||||||
const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
|
const taskStatus =
|
||||||
const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState);
|
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
|
const blockedByDisplayIds = task.blockedBy?.length
|
||||||
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||||
|
|
@ -683,7 +716,8 @@ export class TeamGraphAdapter {
|
||||||
kind: 'task',
|
kind: 'task',
|
||||||
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
|
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
|
||||||
sublabel: task.subject,
|
sublabel: task.subject,
|
||||||
state: TeamGraphAdapter.#mapTaskStatus(task.status),
|
state:
|
||||||
|
graphColumn === 'approved' ? 'complete' : TeamGraphAdapter.#mapTaskStatus(task.status),
|
||||||
taskStatus,
|
taskStatus,
|
||||||
reviewState,
|
reviewState,
|
||||||
reviewerName: isReviewCycle ? reviewerName : null,
|
reviewerName: isReviewCycle ? reviewerName : null,
|
||||||
|
|
@ -698,6 +732,7 @@ export class TeamGraphAdapter {
|
||||||
blocksDisplayIds,
|
blocksDisplayIds,
|
||||||
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
||||||
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
||||||
|
hasLiveTaskLogs: activeTaskLogActivity?.[task.id] === true ? true : undefined,
|
||||||
domainRef: { kind: 'task', teamName, taskId: task.id },
|
domainRef: { kind: 'task', teamName, taskId: task.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export function useTeamGraphAdapter(
|
||||||
gridOwnerOrder,
|
gridOwnerOrder,
|
||||||
slotAssignments,
|
slotAssignments,
|
||||||
graphLayoutSession,
|
graphLayoutSession,
|
||||||
|
activeTaskLogActivity,
|
||||||
ensureTeamGraphSlotAssignments,
|
ensureTeamGraphSlotAssignments,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -92,6 +93,8 @@ export function useTeamGraphAdapter(
|
||||||
gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
|
gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
|
||||||
slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
|
||||||
graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
|
||||||
|
activeTaskLogActivity:
|
||||||
|
isActive && teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
|
||||||
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -189,7 +192,8 @@ export function useTeamGraphAdapter(
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
effectiveSlotAssignments,
|
effectiveSlotAssignments,
|
||||||
graphLayoutMode ?? 'radial',
|
graphLayoutMode ?? 'radial',
|
||||||
gridOwnerOrder
|
gridOwnerOrder,
|
||||||
|
activeTaskLogActivity
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
isActive,
|
isActive,
|
||||||
|
|
@ -208,6 +212,7 @@ export function useTeamGraphAdapter(
|
||||||
effectiveSlotAssignments,
|
effectiveSlotAssignments,
|
||||||
graphLayoutMode,
|
graphLayoutMode,
|
||||||
gridOwnerOrder,
|
gridOwnerOrder,
|
||||||
|
activeTaskLogActivity,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
buildMemberAvatarMap,
|
buildMemberAvatarMap,
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -309,6 +310,17 @@ const MemberPopoverContent = ({
|
||||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||||
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
|
const avatarSrc = node.avatarUrl ?? avatarMap.get(memberName) ?? agentAvatarUrl(memberName, 64);
|
||||||
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
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 =
|
const provisioningPresentation =
|
||||||
teamData && teamName
|
teamData && teamName
|
||||||
? buildTeamProvisioningPresentation({
|
? buildTeamProvisioningPresentation({
|
||||||
|
|
@ -320,7 +332,10 @@ const MemberPopoverContent = ({
|
||||||
: null;
|
: null;
|
||||||
const launchPresentation = member
|
const launchPresentation = member
|
||||||
? buildMemberLaunchPresentation({
|
? buildMemberLaunchPresentation({
|
||||||
member,
|
member:
|
||||||
|
member.currentTaskId && !displayableCurrentTask
|
||||||
|
? { ...member, currentTaskId: null }
|
||||||
|
: member,
|
||||||
spawnStatus: spawnEntry?.status,
|
spawnStatus: spawnEntry?.status,
|
||||||
spawnLaunchState: spawnEntry?.launchState,
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||||
|
|
@ -444,7 +459,7 @@ const MemberPopoverContent = ({
|
||||||
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
|
{/* Context usage stays hidden for now because lead context telemetry is still incomplete. */}
|
||||||
|
|
||||||
{/* Current task indicator — reuses same pattern as MemberCard */}
|
{/* 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]">
|
<div className="mt-2 flex items-center gap-1.5 text-[10px]">
|
||||||
<Loader2
|
<Loader2
|
||||||
className="size-3 shrink-0 animate-spin"
|
className="size-3 shrink-0 animate-spin"
|
||||||
|
|
@ -457,13 +472,13 @@ const MemberPopoverContent = ({
|
||||||
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
|
style={{ border: `1px solid ${node.color ?? '#66ccff'}40` }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenTask?.(node.currentTaskId!);
|
onOpenTask?.(currentTaskIndicatorId);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.currentTaskSubject.length > 30
|
{currentTaskIndicatorSubject.length > 30
|
||||||
? `${node.currentTaskSubject.slice(0, 30)}…`
|
? `${currentTaskIndicatorSubject.slice(0, 30)}…`
|
||||||
: node.currentTaskSubject}
|
: currentTaskIndicatorSubject}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
||||||
|
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
|
import { isTaskBlocked, resolveTaskGraphColumn } from '../../core/domain/taskGraphSemantics';
|
||||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
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)',
|
boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)',
|
||||||
};
|
};
|
||||||
case 'review':
|
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(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)' };
|
: { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' };
|
||||||
case 'approved':
|
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 { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
|
||||||
|
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
||||||
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
|
||||||
import { decideMemberWorkSyncStatus } from '../domain';
|
import { decideMemberWorkSyncStatus } from '../domain';
|
||||||
|
|
||||||
import type { MemberWorkSyncOutboxItem } from '../../contracts';
|
import type { MemberWorkSyncOutboxItem, MemberWorkSyncStatus } from '../../contracts';
|
||||||
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
||||||
|
|
||||||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||||
|
|
@ -151,6 +152,12 @@ export class MemberWorkSyncNudgeDispatcher {
|
||||||
nowIso,
|
nowIso,
|
||||||
});
|
});
|
||||||
await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted');
|
await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted');
|
||||||
|
await this.scheduleDeliveryWake(
|
||||||
|
item,
|
||||||
|
inserted.messageId,
|
||||||
|
inserted.inserted,
|
||||||
|
revalidation.providerId
|
||||||
|
);
|
||||||
return 'delivered';
|
return 'delivered';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await outbox.markFailed({
|
await outbox.markFailed({
|
||||||
|
|
@ -188,7 +195,8 @@ export class MemberWorkSyncNudgeDispatcher {
|
||||||
item: MemberWorkSyncOutboxItem,
|
item: MemberWorkSyncOutboxItem,
|
||||||
nowIso: string
|
nowIso: string
|
||||||
): Promise<
|
): 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
|
const teamActive = this.deps.lifecycle
|
||||||
? await this.deps.lifecycle.isTeamActive(item.teamName)
|
? await this.deps.lifecycle.isTeamActive(item.teamName)
|
||||||
|
|
@ -221,6 +229,24 @@ export class MemberWorkSyncNudgeDispatcher {
|
||||||
nowIso,
|
nowIso,
|
||||||
inactive: source.inactive || !teamActive,
|
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 (
|
if (
|
||||||
decision.state !== 'needs_sync' ||
|
decision.state !== 'needs_sync' ||
|
||||||
agenda.items.length === 0 ||
|
agenda.items.length === 0 ||
|
||||||
|
|
@ -233,7 +259,11 @@ export class MemberWorkSyncNudgeDispatcher {
|
||||||
return { ok: false, reason: 'metrics_unavailable', retryable: true };
|
return { ok: false, reason: 'metrics_unavailable', retryable: true };
|
||||||
}
|
}
|
||||||
const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName);
|
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 };
|
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: 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 { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
|
||||||
|
|
||||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||||
|
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
||||||
|
|
||||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||||
|
|
@ -38,7 +39,8 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
||||||
}
|
}
|
||||||
|
|
||||||
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
|
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' });
|
await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' });
|
||||||
return { planned: false, code: 'phase2_not_ready' };
|
return { planned: false, code: 'phase2_not_ready' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * from './MemberWorkSyncAudit';
|
export * from './MemberWorkSyncAudit';
|
||||||
export * from './MemberWorkSyncDiagnosticsReader';
|
export * from './MemberWorkSyncDiagnosticsReader';
|
||||||
export * from './MemberWorkSyncMetricsReader';
|
export * from './MemberWorkSyncMetricsReader';
|
||||||
|
export * from './MemberWorkSyncNudgeActivationPolicy';
|
||||||
export * from './MemberWorkSyncNudgeDispatcher';
|
export * from './MemberWorkSyncNudgeDispatcher';
|
||||||
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
||||||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ export type MemberWorkSyncAuditEventName =
|
||||||
| 'report_rejected'
|
| 'report_rejected'
|
||||||
| 'nudge_planned'
|
| 'nudge_planned'
|
||||||
| 'nudge_delivered'
|
| 'nudge_delivered'
|
||||||
|
| 'nudge_wake_failed'
|
||||||
| 'nudge_skipped'
|
| 'nudge_skipped'
|
||||||
| 'nudge_retryable'
|
| 'nudge_retryable'
|
||||||
| 'nudge_superseded'
|
| 'nudge_superseded'
|
||||||
|
|
@ -181,6 +182,17 @@ export interface MemberWorkSyncBusySignalPort {
|
||||||
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
|
}): 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 {
|
export interface MemberWorkSyncUseCaseDeps {
|
||||||
clock: MemberWorkSyncClockPort;
|
clock: MemberWorkSyncClockPort;
|
||||||
hash: MemberWorkSyncHashPort;
|
hash: MemberWorkSyncHashPort;
|
||||||
|
|
@ -191,6 +203,7 @@ export interface MemberWorkSyncUseCaseDeps {
|
||||||
inboxNudge?: MemberWorkSyncInboxNudgePort;
|
inboxNudge?: MemberWorkSyncInboxNudgePort;
|
||||||
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
|
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
|
||||||
busySignal?: MemberWorkSyncBusySignalPort;
|
busySignal?: MemberWorkSyncBusySignalPort;
|
||||||
|
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
|
||||||
reportToken?: MemberWorkSyncReportTokenPort;
|
reportToken?: MemberWorkSyncReportTokenPort;
|
||||||
auditJournal?: MemberWorkSyncAuditJournalPort;
|
auditJournal?: MemberWorkSyncAuditJournalPort;
|
||||||
lifecycle?: MemberWorkSyncLifecyclePort;
|
lifecycle?: MemberWorkSyncLifecyclePort;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
isTeamTaskTerminalForActionableWork,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildAgendaFingerprintPayload,
|
buildAgendaFingerprintPayload,
|
||||||
canonicalizeAgendaFingerprintPayload,
|
canonicalizeAgendaFingerprintPayload,
|
||||||
|
|
@ -19,6 +26,7 @@ export interface MemberWorkSyncTaskLike {
|
||||||
status: string;
|
status: string;
|
||||||
owner?: string | null;
|
owner?: string | null;
|
||||||
reviewState?: string | null;
|
reviewState?: string | null;
|
||||||
|
kanbanColumn?: string | null;
|
||||||
needsClarification?: 'lead' | 'user' | null;
|
needsClarification?: 'lead' | 'user' | null;
|
||||||
blockedBy?: string[];
|
blockedBy?: string[];
|
||||||
blocks?: string[];
|
blocks?: string[];
|
||||||
|
|
@ -45,10 +53,6 @@ export interface BuildActionableWorkAgendaInput {
|
||||||
hash: (canonicalPayload: string) => string;
|
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> {
|
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
|
||||||
return new Set(
|
return new Set(
|
||||||
members
|
members
|
||||||
|
|
@ -114,7 +118,9 @@ export function buildActionableWorkAgenda(
|
||||||
|
|
||||||
if (activeMemberNames.has(memberName)) {
|
if (activeMemberNames.has(memberName)) {
|
||||||
for (const task of input.tasks) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +134,7 @@ export function buildActionableWorkAgenda(
|
||||||
const dependency = tasksByReference.get(dependencyId) ?? null;
|
const dependency = tasksByReference.get(dependencyId) ?? null;
|
||||||
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
|
||||||
brokenDependencyIds.push(dependencyId);
|
brokenDependencyIds.push(dependencyId);
|
||||||
} else if (dependency.status !== 'completed') {
|
} else if (!isTeamTaskFinishedForDependency(dependency)) {
|
||||||
waitingDependencyIds.push(dependencyId);
|
waitingDependencyIds.push(dependencyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,11 +180,13 @@ export function buildActionableWorkAgenda(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewOwner = resolveCurrentReviewOwner({
|
const reviewOwner = isReviewWorkflow
|
||||||
reviewState: task.reviewState,
|
? resolveCurrentReviewOwner({
|
||||||
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
|
reviewState: workflowColumn,
|
||||||
historyEvents: task.historyEvents,
|
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
|
||||||
});
|
historyEvents: task.historyEvents,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
|
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|
@ -199,6 +207,10 @@ export function buildActionableWorkAgenda(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isReviewWorkflow) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sameMemberName(owner, memberName)) {
|
if (!sameMemberName(owner, memberName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -214,18 +226,17 @@ export function buildActionableWorkAgenda(
|
||||||
if (
|
if (
|
||||||
task.status === 'pending' ||
|
task.status === 'pending' ||
|
||||||
task.status === 'in_progress' ||
|
task.status === 'in_progress' ||
|
||||||
task.reviewState === 'needsFix'
|
isTeamTaskNeedsFixActionable(task)
|
||||||
) {
|
) {
|
||||||
items.push({
|
items.push({
|
||||||
...base,
|
...base,
|
||||||
kind: 'work',
|
kind: 'work',
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
reason:
|
reason: isTeamTaskNeedsFixActionable(task)
|
||||||
task.reviewState === 'needsFix'
|
? 'review_changes_requested'
|
||||||
? 'review_changes_requested'
|
: task.status === 'pending'
|
||||||
: task.status === 'pending'
|
? 'owned_pending_task'
|
||||||
? 'owned_pending_task'
|
: 'owned_in_progress_task',
|
||||||
: 'owned_in_progress_task',
|
|
||||||
evidence: {
|
evidence: {
|
||||||
status: task.status,
|
status: task.status,
|
||||||
owner: memberName,
|
owner: memberName,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ export interface ReviewHistoryEventLike {
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
reviewer?: string;
|
reviewer?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentReviewOwner {
|
export interface CurrentReviewOwner {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskTerminalForActionableWork,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
|
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
|
||||||
|
|
||||||
|
|
@ -20,10 +24,6 @@ export interface MemberWorkSyncTaskImpactResolverResult {
|
||||||
diagnostics: string[];
|
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 {
|
function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
|
||||||
return task.status === 'deleted' || Boolean(task.deletedAt);
|
return task.status === 'deleted' || Boolean(task.deletedAt);
|
||||||
}
|
}
|
||||||
|
|
@ -127,13 +127,6 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
|
|
||||||
addMember(task.owner);
|
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)) {
|
if (!normalizeMemberName(task.owner)) {
|
||||||
addLead();
|
addLead();
|
||||||
addDiagnostic('task_owner_missing');
|
addDiagnostic('task_owner_missing');
|
||||||
|
|
@ -142,7 +135,23 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
addDiagnostic('task_owner_inactive');
|
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();
|
addLead();
|
||||||
addDiagnostic('task_reviewer_missing');
|
addDiagnostic('task_reviewer_missing');
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +175,14 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const candidate of tasks) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,13 @@ export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
memberName: input.memberName,
|
memberName: input.memberName,
|
||||||
generatedAt: this.deps.clock.now().toISOString(),
|
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),
|
members: members.map(toMemberLike),
|
||||||
kanbanReviewersByTaskId: Object.fromEntries(
|
kanbanReviewersByTaskId: Object.fromEntries(
|
||||||
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])
|
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 { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown';
|
||||||
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
||||||
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
|
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
|
||||||
|
import { CompositeMemberWorkSyncBusySignal } from '../infrastructure/CompositeMemberWorkSyncBusySignal';
|
||||||
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
|
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
|
||||||
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
|
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
|
||||||
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
||||||
|
|
@ -46,7 +47,11 @@ import type {
|
||||||
MemberWorkSyncStatusRequest,
|
MemberWorkSyncStatusRequest,
|
||||||
MemberWorkSyncTeamMetrics,
|
MemberWorkSyncTeamMetrics,
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
import type { MemberWorkSyncLoggerPort } from '../../core/application';
|
import type {
|
||||||
|
MemberWorkSyncBusySignalPort,
|
||||||
|
MemberWorkSyncLoggerPort,
|
||||||
|
MemberWorkSyncNudgeDeliveryWakePort,
|
||||||
|
} from '../../core/application';
|
||||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||||
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||||
|
|
@ -93,6 +98,8 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
listLifecycleActiveTeamNames?: () => Promise<string[]>;
|
||||||
queueQuietWindowMs?: number;
|
queueQuietWindowMs?: number;
|
||||||
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
|
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
|
||||||
|
extraBusySignals?: MemberWorkSyncBusySignalPort[];
|
||||||
|
nudgeDeliveryWake?: MemberWorkSyncNudgeDeliveryWakePort;
|
||||||
logger?: MemberWorkSyncLoggerPort;
|
logger?: MemberWorkSyncLoggerPort;
|
||||||
}): MemberWorkSyncFeatureFacade {
|
}): MemberWorkSyncFeatureFacade {
|
||||||
const clock = new SystemClockAdapter();
|
const clock = new SystemClockAdapter();
|
||||||
|
|
@ -138,7 +145,12 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
});
|
});
|
||||||
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
|
||||||
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
|
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 inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
|
||||||
const useCaseDeps = {
|
const useCaseDeps = {
|
||||||
clock,
|
clock,
|
||||||
|
|
@ -150,6 +162,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
inboxNudge,
|
inboxNudge,
|
||||||
watchdogCooldown,
|
watchdogCooldown,
|
||||||
busySignal,
|
busySignal,
|
||||||
|
...(deps.nudgeDeliveryWake ? { nudgeDeliveryWake: deps.nudgeDeliveryWake } : {}),
|
||||||
reportToken,
|
reportToken,
|
||||||
auditJournal,
|
auditJournal,
|
||||||
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
||||||
|
|
@ -233,7 +246,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
getMetrics: (request) => metricsReader.execute(request),
|
getMetrics: (request) => metricsReader.execute(request),
|
||||||
report: (request) => reporter.execute(request),
|
report: (request) => reporter.execute(request),
|
||||||
noteTeamChange: (event) => {
|
noteTeamChange: (event) => {
|
||||||
busySignal.noteTeamChange(event);
|
toolActivityBusySignal.noteTeamChange(event);
|
||||||
router.noteTeamChange(event);
|
router.noteTeamChange(event);
|
||||||
},
|
},
|
||||||
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),
|
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,
|
MemberLaunchState,
|
||||||
MemberSpawnLivenessSource,
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
|
OpenCodeAppManagedBootstrapCandidate,
|
||||||
|
OpenCodeBootstrapEvidenceSource,
|
||||||
|
OpenCodeBootstrapMode,
|
||||||
PersistedTeamLaunchMemberSources,
|
PersistedTeamLaunchMemberSources,
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
|
|
@ -43,6 +46,9 @@ export interface MixedSecondaryLaneMemberStateInput {
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
runtimeSessionId?: string;
|
runtimeSessionId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||||
|
bootstrapMode?: OpenCodeBootstrapMode;
|
||||||
|
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
pidSource?: TeamAgentRuntimePidSource;
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
runtimeDiagnostic?: string;
|
runtimeDiagnostic?: string;
|
||||||
|
|
@ -348,6 +354,9 @@ function createSecondaryLaneMemberState(
|
||||||
? Math.trunc(evidence.runtimePid)
|
? Math.trunc(evidence.runtimePid)
|
||||||
: undefined,
|
: undefined,
|
||||||
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
|
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
|
||||||
|
bootstrapEvidenceSource: evidence?.bootstrapEvidenceSource,
|
||||||
|
bootstrapMode: evidence?.bootstrapMode,
|
||||||
|
appManagedBootstrapCandidate: evidence?.appManagedBootstrapCandidate,
|
||||||
livenessKind: evidence?.livenessKind,
|
livenessKind: evidence?.livenessKind,
|
||||||
pidSource: evidence?.pidSource,
|
pidSource: evidence?.pidSource,
|
||||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ import {
|
||||||
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
@ -470,6 +471,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
||||||
const msg = newMessages[i];
|
const msg = newMessages[i];
|
||||||
// Skip messages sent from our own UI
|
// Skip messages sent from our own UI
|
||||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
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.)
|
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||||
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
||||||
|
|
||||||
|
|
@ -1140,6 +1144,9 @@ async function initializeServices(): Promise<void> {
|
||||||
teamDataService = new TeamDataService();
|
teamDataService = new TeamDataService();
|
||||||
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
|
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
|
||||||
teamProvisioningService = new TeamProvisioningService();
|
teamProvisioningService = new TeamProvisioningService();
|
||||||
|
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
|
||||||
|
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
|
||||||
|
});
|
||||||
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
|
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
|
||||||
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
|
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
|
||||||
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
|
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
|
||||||
|
|
@ -1358,6 +1365,24 @@ async function initializeServices(): Promise<void> {
|
||||||
)
|
)
|
||||||
.map((team) => team.teamName);
|
.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'),
|
logger: createLogger('Feature:MemberWorkSync'),
|
||||||
});
|
});
|
||||||
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
|
||||||
|
|
|
||||||
|
|
@ -826,6 +826,9 @@ export class NotificationManager extends EventEmitter {
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(() => writeNotificationsFileAtomically(notificationsPath, data))
|
.then(() => writeNotificationsFileAtomically(notificationsPath, data))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.error('Error saving notifications:', error);
|
logger.error('Error saving notifications:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1031,7 +1034,7 @@ export class NotificationManager extends EventEmitter {
|
||||||
): void {
|
): void {
|
||||||
const NotificationClass = getNotificationClass();
|
const NotificationClass = getNotificationClass();
|
||||||
if (!NotificationClass || !this.isNativeNotificationSupported()) {
|
if (!NotificationClass || !this.isNativeNotificationSupported()) {
|
||||||
logger.warn('[team-toast] native notifications not supported — skipping');
|
logger.debug('[team-toast] native notifications not supported - skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
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 { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
|
|
@ -62,6 +62,7 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
import { getTeamTaskWorkflowColumn, selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
||||||
|
|
||||||
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes';
|
||||||
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
|
||||||
|
|
@ -550,13 +551,7 @@ export class TeamDataService {
|
||||||
const launchIdentity = teamMeta?.launchIdentity;
|
const launchIdentity = teamMeta?.launchIdentity;
|
||||||
const leadName = 'team-lead';
|
const leadName = 'team-lead';
|
||||||
const ownedTasks = tasks.filter((task) => task.owner === leadName);
|
const ownedTasks = tasks.filter((task) => task.owner === leadName);
|
||||||
const currentTask =
|
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
|
||||||
ownedTasks.find(
|
|
||||||
(task) =>
|
|
||||||
task.status === 'in_progress' &&
|
|
||||||
task.reviewState !== 'approved' &&
|
|
||||||
task.kanbanColumn !== 'approved'
|
|
||||||
) ?? null;
|
|
||||||
|
|
||||||
members.unshift({
|
members.unshift({
|
||||||
name: leadName,
|
name: leadName,
|
||||||
|
|
@ -600,12 +595,35 @@ export class TeamDataService {
|
||||||
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
||||||
kanbanTaskState?: KanbanState['tasks'][string]
|
kanbanTaskState?: KanbanState['tasks'][string]
|
||||||
): 'none' | 'review' | 'needsFix' | 'approved' {
|
): '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,
|
historyEvents: task.historyEvents,
|
||||||
reviewState: task.reviewState,
|
reviewState: task.reviewState,
|
||||||
status: task.status,
|
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(
|
private attachKanbanCompatibility(
|
||||||
|
|
@ -614,14 +632,27 @@ export class TeamDataService {
|
||||||
): TeamTaskWithKanban {
|
): TeamTaskWithKanban {
|
||||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||||
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
||||||
|
const kanbanColumn = this.resolveTaskKanbanColumn(task, kanbanTaskState, reviewState);
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
reviewState,
|
reviewState,
|
||||||
kanbanColumn: getKanbanColumnFromReviewState(reviewState),
|
...(kanbanColumn ? { kanbanColumn } : {}),
|
||||||
reviewer,
|
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.
|
* Extract reviewer name from the current review cycle history.
|
||||||
* For legacy boards that stored reviewer only in kanban state, preserve that
|
* 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 info = teamInfoMap.get(task.teamName)!;
|
||||||
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
|
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
|
||||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
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).
|
// 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.
|
// 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> {
|
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();
|
this.invalidateGlobalTaskProjectionCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
MemberLaunchState,
|
MemberLaunchState,
|
||||||
MemberSpawnLivenessSource,
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
|
OpenCodeAppManagedBootstrapCandidate,
|
||||||
PersistedTeamLaunchMemberSources,
|
PersistedTeamLaunchMemberSources,
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
|
|
@ -176,6 +177,60 @@ function normalizeOptionalString(value: unknown): string | undefined {
|
||||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : 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 {
|
function decodeJsonStringLiteral(value: string): string {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(`"${value}"`) as string;
|
return JSON.parse(`"${value}"`) as string;
|
||||||
|
|
@ -601,6 +656,19 @@ function normalizePersistedMemberState(
|
||||||
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
||||||
runtimeRunId: normalizeOptionalString(parsed.runtimeRunId),
|
runtimeRunId: normalizeOptionalString(parsed.runtimeRunId),
|
||||||
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
|
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,
|
livenessKind,
|
||||||
pidSource: normalizePidSource(parsed.pidSource),
|
pidSource: normalizePidSource(parsed.pidSource),
|
||||||
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
|
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,22 @@ type DecodedFreshnessTaskId =
|
||||||
| { kind: 'opaque-safe-segment' }
|
| { kind: 'opaque-safe-segment' }
|
||||||
| { kind: 'invalid' };
|
| { kind: 'invalid' };
|
||||||
|
|
||||||
|
type TaskFreshnessSignalKind = NonNullable<TeamChangeEvent['taskSignalKind']>;
|
||||||
|
|
||||||
function isOpaqueSafeTaskIdSegment(segment: string): boolean {
|
function isOpaqueSafeTaskIdSegment(segment: string): boolean {
|
||||||
return /^task-id-[0-9a-f]{32}$/.test(segment);
|
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(
|
export function shouldIgnoreLogSourceWatcherPath(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
watchedPath: string,
|
watchedPath: string,
|
||||||
|
|
@ -368,14 +380,20 @@ export class TeamLogSourceTracker {
|
||||||
return;
|
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)}`);
|
logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`);
|
||||||
|
return [path.normalize(context.projectDir)];
|
||||||
});
|
});
|
||||||
|
|
||||||
const { targets, scopedSessionIds } = await this.buildScopedWatchTargets(
|
const { targets, scopedSessionIds } = await this.buildScopedWatchTargets(
|
||||||
context.projectDir,
|
context.projectDir,
|
||||||
context.watchSessionIds,
|
context.watchSessionIds,
|
||||||
this.getPendingUnknownSessionIds(state)
|
this.getPendingUnknownSessionIds(state),
|
||||||
|
taskFreshnessWatchRootDirs
|
||||||
);
|
);
|
||||||
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
if (!this.isTrackingCurrent(teamName, expectedVersion)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -411,6 +429,18 @@ export class TeamLogSourceTracker {
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const eventTaskFreshnessRootDirs = this.getTaskFreshnessRootDirs(current.activeContext);
|
||||||
|
pushUniqueNormalizedPath(eventTaskFreshnessRootDirs, current.projectDir);
|
||||||
|
if (
|
||||||
|
this.handleTaskFreshnessSignalChangeForRoots(
|
||||||
|
teamName,
|
||||||
|
changedPath,
|
||||||
|
eventTaskFreshnessRootDirs
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const action = classifyLogSourceWatcherEvent({
|
const action = classifyLogSourceWatcherEvent({
|
||||||
projectDir: current.projectDir,
|
projectDir: current.projectDir,
|
||||||
changedPath,
|
changedPath,
|
||||||
|
|
@ -420,21 +450,6 @@ export class TeamLogSourceTracker {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (action.kind === 'task-freshness') {
|
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;
|
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([
|
await Promise.all([
|
||||||
fs.mkdir(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), { recursive: true }),
|
fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), {
|
||||||
fs.mkdir(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { recursive: true }),
|
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(
|
private async buildScopedWatchTargets(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
confirmedSessionIds: readonly string[],
|
confirmedSessionIds: readonly string[],
|
||||||
pendingRootSessionIds: readonly string[]
|
pendingRootSessionIds: readonly string[],
|
||||||
|
taskFreshnessRootDirs: readonly string[] = [projectDir]
|
||||||
): Promise<{ targets: string[]; scopedSessionIds: Set<string> }> {
|
): Promise<{ targets: string[]; scopedSessionIds: Set<string> }> {
|
||||||
const targets = new Set<string>();
|
const targets = new Set<string>();
|
||||||
const scopedSessionIds = new Set<string>();
|
const scopedSessionIds = new Set<string>();
|
||||||
|
|
||||||
targets.add(projectDir);
|
targets.add(projectDir);
|
||||||
targets.add(path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME));
|
for (const freshnessRootDir of taskFreshnessRootDirs) {
|
||||||
targets.add(path.join(projectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME));
|
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) {
|
for (const rawSessionId of confirmedSessionIds) {
|
||||||
const sessionId = normalizeLogSourceSessionId(rawSessionId);
|
const sessionId = normalizeLogSourceSessionId(rawSessionId);
|
||||||
|
|
@ -664,11 +729,10 @@ export class TeamLogSourceTracker {
|
||||||
|
|
||||||
private handleTaskFreshnessSignalChange(
|
private handleTaskFreshnessSignalChange(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
projectDir: string,
|
|
||||||
changedPath: string,
|
changedPath: string,
|
||||||
signalDirName: string
|
signalDir: string,
|
||||||
|
taskSignalKind: TaskFreshnessSignalKind
|
||||||
): boolean {
|
): boolean {
|
||||||
const signalDir = path.join(projectDir, signalDirName);
|
|
||||||
const relativePath = path.relative(signalDir, changedPath);
|
const relativePath = path.relative(signalDir, changedPath);
|
||||||
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||||
return path.normalize(changedPath) === path.normalize(signalDir);
|
return path.normalize(changedPath) === path.normalize(signalDir);
|
||||||
|
|
@ -687,7 +751,7 @@ export class TeamLogSourceTracker {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (decoded.kind === 'opaque-safe-segment') {
|
if (decoded.kind === 'opaque-safe-segment') {
|
||||||
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath);
|
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath, taskSignalKind);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -695,6 +759,7 @@ export class TeamLogSourceTracker {
|
||||||
type: 'task-log-change',
|
type: 'task-log-change',
|
||||||
teamName,
|
teamName,
|
||||||
taskId: decoded.taskId,
|
taskId: decoded.taskId,
|
||||||
|
taskSignalKind,
|
||||||
});
|
});
|
||||||
return true;
|
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 {
|
try {
|
||||||
const raw = await fs.readFile(filePath, 'utf8');
|
const raw = await fs.readFile(filePath, 'utf8');
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
|
@ -733,6 +802,7 @@ export class TeamLogSourceTracker {
|
||||||
type: 'task-log-change',
|
type: 'task-log-change',
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
|
taskSignalKind,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -742,6 +812,36 @@ export class TeamLogSourceTracker {
|
||||||
this.emitLogSourceChange(teamName);
|
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> {
|
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||||
const state = this.getOrCreateState(teamName);
|
const state = this.getOrCreateState(teamName);
|
||||||
if (this.getActiveConsumerCount(state) === 0) {
|
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. */
|
/** TTL for discoverProjectSessions cache — avoids re-reading config/dirs within rapid successive calls. */
|
||||||
const DISCOVERY_CACHE_TTL = 30_000;
|
const DISCOVERY_CACHE_TTL = 30_000;
|
||||||
|
const MAX_TASK_FRESHNESS_ROOT_DIRS = 64;
|
||||||
|
|
||||||
/** Signal sources for subagent member attribution, ordered by reliability. */
|
/** Signal sources for subagent member attribution, ordered by reliability. */
|
||||||
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
|
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
|
||||||
|
|
@ -116,6 +117,7 @@ export interface MemberLogFileRef {
|
||||||
export interface TeamLogSourceLiveContext {
|
export interface TeamLogSourceLiveContext {
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
|
taskFreshnessRootDirs?: string[];
|
||||||
leadSessionId?: string;
|
leadSessionId?: string;
|
||||||
sessionIds: string[];
|
sessionIds: string[];
|
||||||
watchSessionIds: string[];
|
watchSessionIds: string[];
|
||||||
|
|
@ -143,6 +145,30 @@ async function mapLimit<T, R>(
|
||||||
return results;
|
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 {
|
export class TeamMemberLogsFinder {
|
||||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||||
private readonly attributionCache = new Map<
|
private readonly attributionCache = new Map<
|
||||||
|
|
@ -286,13 +312,13 @@ export class TeamMemberLogsFinder {
|
||||||
readBootstrapLaunchSnapshot(teamName).catch(() => null),
|
readBootstrapLaunchSnapshot(teamName).catch(() => null),
|
||||||
]);
|
]);
|
||||||
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
const preferredSnapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
||||||
const extraProjectPathCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
|
const runtimeMemberCwdCandidates = Object.values(preferredSnapshot?.members ?? {}).map(
|
||||||
(member) => member.cwd
|
(member) => member.cwd
|
||||||
);
|
);
|
||||||
|
|
||||||
const base = await this.projectResolver.getLiveBaseContext(teamName, {
|
const base = await this.projectResolver.getLiveBaseContext(teamName, {
|
||||||
forceRefresh: options?.forceRefresh,
|
forceRefresh: options?.forceRefresh,
|
||||||
extraProjectPathCandidates,
|
extraProjectPathCandidates: runtimeMemberCwdCandidates,
|
||||||
});
|
});
|
||||||
if (!base) {
|
if (!base) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -308,6 +334,11 @@ export class TeamMemberLogsFinder {
|
||||||
return {
|
return {
|
||||||
projectDir: base.projectDir,
|
projectDir: base.projectDir,
|
||||||
projectPath: base.config.projectPath,
|
projectPath: base.config.projectPath,
|
||||||
|
taskFreshnessRootDirs: collectTaskFreshnessRootDirs([
|
||||||
|
base.config.projectPath,
|
||||||
|
...(base.config.members ?? []).map((member) => member.cwd),
|
||||||
|
...runtimeMemberCwdCandidates,
|
||||||
|
]),
|
||||||
leadSessionId: base.config.leadSessionId ?? preferredSnapshot?.leadSessionId,
|
leadSessionId: base.config.leadSessionId ?? preferredSnapshot?.leadSessionId,
|
||||||
sessionIds: watchSessionIds,
|
sessionIds: watchSessionIds,
|
||||||
watchSessionIds,
|
watchSessionIds,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
|
import { selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
|
|
@ -282,13 +284,7 @@ export class TeamMemberResolver {
|
||||||
const members: TeamMemberSnapshot[] = [];
|
const members: TeamMemberSnapshot[] = [];
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||||
const currentTask =
|
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
|
||||||
ownedTasks.find(
|
|
||||||
(task) =>
|
|
||||||
task.status === 'in_progress' &&
|
|
||||||
task.reviewState !== 'approved' &&
|
|
||||||
task.kanbanColumn !== 'approved'
|
|
||||||
) ?? null;
|
|
||||||
const configMember = configMemberMap.get(name);
|
const configMember = configMemberMap.get(name);
|
||||||
const metaMember = metaMemberMap.get(name);
|
const metaMember = metaMemberMap.get(name);
|
||||||
const launchMember = launchMemberMap.get(name);
|
const launchMember = launchMemberMap.get(name);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
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';
|
import type { MemberLogSummary, MemberRuntimeAdvisory, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -29,11 +40,15 @@ const CACHE_TTL_MS = 30_000;
|
||||||
const TAIL_BYTES = 64 * 1024;
|
const TAIL_BYTES = 64 * 1024;
|
||||||
const BATCH_WARN_MS = 1_000;
|
const BATCH_WARN_MS = 1_000;
|
||||||
const ADVISORY_FETCH_CONCURRENCY = 2;
|
const ADVISORY_FETCH_CONCURRENCY = 2;
|
||||||
|
const OPENCODE_DELIVERY_ERROR_LOOKBACK_MS = 30 * 60 * 1000;
|
||||||
const QUOTA_EXHAUSTED_TOKENS = [
|
const QUOTA_EXHAUSTED_TOKENS = [
|
||||||
'exhausted your capacity',
|
'exhausted your capacity',
|
||||||
'capacity exceeded',
|
'capacity exceeded',
|
||||||
'quota exceeded',
|
'quota exceeded',
|
||||||
'quota exhausted',
|
'quota exhausted',
|
||||||
|
'insufficient credits',
|
||||||
|
'key limit exceeded',
|
||||||
|
'total limit',
|
||||||
];
|
];
|
||||||
const RATE_LIMITED_TOKENS = [
|
const RATE_LIMITED_TOKENS = [
|
||||||
'rate limit',
|
'rate limit',
|
||||||
|
|
@ -70,7 +85,18 @@ const PROVIDER_OVERLOADED_TOKENS = [
|
||||||
'service unavailable',
|
'service unavailable',
|
||||||
'503',
|
'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');
|
const logger = createLogger('Service:TeamMemberRuntimeAdvisory');
|
||||||
|
|
||||||
interface CachedRuntimeAdvisory {
|
interface CachedRuntimeAdvisory {
|
||||||
|
|
@ -111,9 +137,49 @@ function classifyRetryReason(message: string | undefined): MemberRuntimeAdvisory
|
||||||
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
|
if (includesAnyToken(normalized, PROVIDER_OVERLOADED_TOKENS)) {
|
||||||
return 'provider_overloaded';
|
return 'provider_overloaded';
|
||||||
}
|
}
|
||||||
|
if (includesAnyToken(normalized, PROTOCOL_PROOF_MISSING_TOKENS)) {
|
||||||
|
return 'protocol_proof_missing';
|
||||||
|
}
|
||||||
return 'backend_error';
|
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>(
|
async function mapLimit<T, R>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
limit: number,
|
limit: number,
|
||||||
|
|
@ -137,8 +203,10 @@ async function mapLimit<T, R>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamMemberRuntimeAdvisoryService {
|
export class TeamMemberRuntimeAdvisoryService {
|
||||||
|
private readonly inboxReader = new TeamInboxReader();
|
||||||
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
|
private readonly memberCache = new Map<string, CachedRuntimeAdvisory>();
|
||||||
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
|
private readonly teamBatchCacheByTeam = new Map<string, CachedTeamBatchAdvisories>();
|
||||||
|
private readonly cacheGenerationByTeam = new Map<string, number>();
|
||||||
private readonly inFlightBatchRequests = new Map<
|
private readonly inFlightBatchRequests = new Map<
|
||||||
string,
|
string,
|
||||||
Promise<Map<string, MemberRuntimeAdvisory>>
|
Promise<Map<string, MemberRuntimeAdvisory>>
|
||||||
|
|
@ -148,6 +216,23 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
private readonly logsFinder: RuntimeAdvisoryLogsFinder = new TeamMemberLogsFinder()
|
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(
|
async getMemberAdvisories(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
|
members: readonly Pick<ResolvedTeamMember, 'name' | 'removedAt'>[]
|
||||||
|
|
@ -187,17 +272,21 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string
|
memberName: string
|
||||||
): Promise<MemberRuntimeAdvisory | null> {
|
): Promise<MemberRuntimeAdvisory | null> {
|
||||||
|
const teamKey = this.normalizeToken(teamName);
|
||||||
const cacheKey = this.getMemberCacheKey(teamName, memberName);
|
const cacheKey = this.getMemberCacheKey(teamName, memberName);
|
||||||
const cached = this.memberCache.get(cacheKey);
|
const cached = this.memberCache.get(cacheKey);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return cached.value ? this.cloneAdvisory(cached.value) : null;
|
return cached.value ? this.cloneAdvisory(cached.value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0;
|
||||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
||||||
this.memberCache.set(cacheKey, {
|
if ((this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart) {
|
||||||
value: advisory,
|
this.memberCache.set(cacheKey, {
|
||||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
value: advisory,
|
||||||
});
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
return advisory ? this.cloneAdvisory(advisory) : null;
|
return advisory ? this.cloneAdvisory(advisory) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,6 +298,7 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
): Promise<Map<string, MemberRuntimeAdvisory>> {
|
): Promise<Map<string, MemberRuntimeAdvisory>> {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const generationAtStart = this.cacheGenerationByTeam.get(teamKey) ?? 0;
|
||||||
const result = new Map<string, MemberRuntimeAdvisory>();
|
const result = new Map<string, MemberRuntimeAdvisory>();
|
||||||
const membersToFetch: string[] = [];
|
const membersToFetch: string[] = [];
|
||||||
let memberCacheHits = 0;
|
let memberCacheHits = 0;
|
||||||
|
|
@ -233,23 +323,29 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
if (membersToFetch.length > 0) {
|
if (membersToFetch.length > 0) {
|
||||||
const fetched = await this.findRecentMemberAdvisories(teamName, membersToFetch);
|
const fetched = await this.findRecentMemberAdvisories(teamName, membersToFetch);
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
|
const cacheStillCurrent =
|
||||||
|
(this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart;
|
||||||
for (const [memberName, advisory] of fetched) {
|
for (const [memberName, advisory] of fetched) {
|
||||||
const normalizedMemberName = this.normalizeToken(memberName);
|
const normalizedMemberName = this.normalizeToken(memberName);
|
||||||
this.memberCache.set(`${teamKey}::${normalizedMemberName}`, {
|
if (cacheStillCurrent) {
|
||||||
value: advisory,
|
this.memberCache.set(`${teamKey}::${normalizedMemberName}`, {
|
||||||
expiresAt: fetchedAt + CACHE_TTL_MS,
|
value: advisory,
|
||||||
});
|
expiresAt: fetchedAt + CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (advisory) {
|
if (advisory) {
|
||||||
result.set(normalizedMemberName, this.cloneAdvisory(advisory));
|
result.set(normalizedMemberName, this.cloneAdvisory(advisory));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.teamBatchCacheByTeam.set(teamKey, {
|
if ((this.cacheGenerationByTeam.get(teamKey) ?? 0) === generationAtStart) {
|
||||||
membersSignature,
|
this.teamBatchCacheByTeam.set(teamKey, {
|
||||||
value: this.cloneNormalizedAdvisories(result),
|
membersSignature,
|
||||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
value: this.cloneNormalizedAdvisories(result),
|
||||||
});
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const totalMs = performance.now() - startedAt;
|
const totalMs = performance.now() - startedAt;
|
||||||
if (totalMs >= BATCH_WARN_MS) {
|
if (totalMs >= BATCH_WARN_MS) {
|
||||||
|
|
@ -305,6 +401,11 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string
|
memberName: string
|
||||||
): Promise<MemberRuntimeAdvisory | null> {
|
): Promise<MemberRuntimeAdvisory | null> {
|
||||||
|
const openCodeAdvisory = await this.findRecentOpenCodeDeliveryAdvisory(teamName, memberName);
|
||||||
|
if (openCodeAdvisory) {
|
||||||
|
return openCodeAdvisory;
|
||||||
|
}
|
||||||
|
|
||||||
const summaries = await this.logsFinder.findMemberLogs(
|
const summaries = await this.logsFinder.findMemberLogs(
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -319,9 +420,33 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNames: readonly string[]
|
memberNames: readonly string[]
|
||||||
): Promise<readonly (readonly [string, MemberRuntimeAdvisory | null])[]> {
|
): 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) {
|
if (this.logsFinder.findRecentMemberLogFileRefsByMember) {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
logger.warn('batch member runtime advisory log lookup failed; falling back', {
|
logger.warn('batch member runtime advisory log lookup failed; falling back', {
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -330,10 +455,226 @@ export class TeamMemberRuntimeAdvisoryService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapLimit(memberNames, ADVISORY_FETCH_CONCURRENCY, async (memberName) => {
|
const logAdvisories = await mapLimit(
|
||||||
const advisory = await this.findRecentMemberAdvisory(teamName, memberName);
|
remainingMemberNames,
|
||||||
return [memberName, advisory] as const;
|
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(
|
private async findRecentMemberAdvisoriesFromBatchRefs(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
|
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||||
|
|
@ -84,7 +85,7 @@ function resolveLeadName(config: TeamConfig): string {
|
||||||
return lead?.name?.trim() || 'team-lead';
|
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;
|
const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt;
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||||
return new Date(raw).toISOString();
|
return new Date(raw).toISOString();
|
||||||
|
|
@ -98,46 +99,55 @@ function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfi
|
||||||
return new Date(0).toISOString();
|
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 role = member.role?.trim() || member.agentType?.trim() || 'team member';
|
||||||
const displayName = config.description?.trim() || config.name;
|
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()
|
const modelLine = member.model?.trim()
|
||||||
? `\nModel override for this teammate: ${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}
|
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.
|
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:
|
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.
|
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.`;
|
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 members = Array.isArray(config.members) ? config.members : [];
|
||||||
const leadName = resolveLeadName(config);
|
const leadName = resolveLeadName(config);
|
||||||
|
const normalizedLeadName = leadName.trim().toLowerCase();
|
||||||
return members
|
return members
|
||||||
.filter(
|
.filter(
|
||||||
(member) =>
|
(member) =>
|
||||||
member &&
|
member &&
|
||||||
member.name?.trim() &&
|
member.name?.trim() &&
|
||||||
member.providerId === 'opencode' &&
|
member.name.trim().toLowerCase() !== normalizedLeadName &&
|
||||||
member.removedAt == null &&
|
member.removedAt == null
|
||||||
(member as { isActive?: unknown }).isActive !== false
|
|
||||||
)
|
)
|
||||||
.map((member) => ({
|
.map((member) => ({
|
||||||
from: leadName,
|
from: leadName,
|
||||||
to: member.name,
|
to: member.name,
|
||||||
text: buildOpenCodeBootstrapDisplayPrompt(config, member),
|
text: buildSyntheticBootstrapDisplayPrompt(config, member),
|
||||||
timestamp: resolveOpenCodeBootstrapTimestamp(config, member),
|
timestamp: resolveSyntheticBootstrapTimestamp(config, member),
|
||||||
read: true,
|
read: true,
|
||||||
source: 'system_notification' as const,
|
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 {
|
function annotateSlashCommandResponses(messages: InboxMessage[]): void {
|
||||||
let pendingSlash = null as InboxMessage['slashCommand'] | null;
|
let pendingSlash = null as InboxMessage['slashCommand'] | null;
|
||||||
|
|
||||||
|
|
@ -498,8 +508,10 @@ export class TeamMessageFeedService {
|
||||||
const sourceMs = Date.now() - sourceStartedAt;
|
const sourceMs = Date.now() - sourceStartedAt;
|
||||||
|
|
||||||
const normalizeStartedAt = Date.now();
|
const normalizeStartedAt = Date.now();
|
||||||
const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config);
|
const syntheticMessages = buildSyntheticBootstrapMessages(config);
|
||||||
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages];
|
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter(
|
||||||
|
isVisibleTeamMessage
|
||||||
|
);
|
||||||
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
messages = dedupeLeadProcessCopies(messages, leadTexts);
|
||||||
messages = ensureEffectiveMessageIds(messages);
|
messages = ensureEffectiveMessageIds(messages);
|
||||||
messages = dedupeByMessageId(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;
|
const task = JSON.parse(raw) as TeamTask;
|
||||||
if (owner) {
|
const previousOwner =
|
||||||
task.owner = owner;
|
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 {
|
} else {
|
||||||
delete task.owner;
|
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));
|
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 { 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_BRIDGE_SCHEMA_VERSION = 1 as const;
|
||||||
export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_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 =
|
export type OpenCodeBridgeCommandName =
|
||||||
| 'opencode.handshake'
|
| 'opencode.handshake'
|
||||||
|
|
@ -65,6 +72,9 @@ export interface OpenCodeLaunchTeamCommandBody {
|
||||||
export interface OpenCodeTeamMemberLaunchCommandData {
|
export interface OpenCodeTeamMemberLaunchCommandData {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
launchState: OpenCodeTeamMemberLaunchBridgeState;
|
launchState: OpenCodeTeamMemberLaunchBridgeState;
|
||||||
|
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||||
|
bootstrapMode?: OpenCodeBootstrapMode;
|
||||||
|
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
model: string;
|
model: string;
|
||||||
|
|
@ -166,6 +176,7 @@ export interface OpenCodeSendMessageCommandBody {
|
||||||
| 'slash_command'
|
| 'slash_command'
|
||||||
| 'slash_command_result'
|
| 'slash_command_result'
|
||||||
| 'task_comment_notification'
|
| 'task_comment_notification'
|
||||||
|
| 'task_stall_remediation'
|
||||||
| 'member_work_sync_nudge'
|
| 'member_work_sync_nudge'
|
||||||
| 'agent_error';
|
| 'agent_error';
|
||||||
taskRefs?: { taskId: string; displayId: string; teamName: string }[];
|
taskRefs?: { taskId: string; displayId: string; teamName: string }[];
|
||||||
|
|
@ -373,6 +384,7 @@ export interface OpenCodeBridgePeerIdentity {
|
||||||
currentVersion: number;
|
currentVersion: number;
|
||||||
supportedCommands: OpenCodeBridgeCommandName[];
|
supportedCommands: OpenCodeBridgeCommandName[];
|
||||||
opencodeTaskLedgerEvidenceContractVersion?: number;
|
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||||
|
opencodeAppManagedBootstrapContractVersion?: number;
|
||||||
};
|
};
|
||||||
runtime: {
|
runtime: {
|
||||||
providerId: 'opencode';
|
providerId: 'opencode';
|
||||||
|
|
@ -591,6 +603,26 @@ export function validateOpenCodeBridgeHandshake(input: {
|
||||||
return { ok: false, reason: `Bridge server does not support command ${input.requiredCommand}` };
|
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 (
|
if (
|
||||||
input.expectedCapabilitySnapshotId &&
|
input.expectedCapabilitySnapshotId &&
|
||||||
input.handshake.server.runtime.capabilitySnapshotId !== 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.supportedCommands.every(isOpenCodeBridgeCommandName) ||
|
||||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined &&
|
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined &&
|
||||||
(!Number.isInteger(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion) ||
|
(!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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import type {
|
||||||
OpenCodeBridgeHandshake,
|
OpenCodeBridgeHandshake,
|
||||||
OpenCodeBridgePeerIdentity,
|
OpenCodeBridgePeerIdentity,
|
||||||
} from './OpenCodeBridgeCommandContract';
|
} from './OpenCodeBridgeCommandContract';
|
||||||
|
import {
|
||||||
|
OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||||
|
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||||
|
} from './OpenCodeBridgeCommandContract';
|
||||||
import type {
|
import type {
|
||||||
OpenCodeBridgeCommandExecutor,
|
OpenCodeBridgeCommandExecutor,
|
||||||
OpenCodeBridgeHandshakePort,
|
OpenCodeBridgeHandshakePort,
|
||||||
|
|
@ -96,6 +100,8 @@ export function createOpenCodeBridgeClientIdentity(input: {
|
||||||
'opencode.recoverDeliveryJournal',
|
'opencode.recoverDeliveryJournal',
|
||||||
'opencode.backfillTaskLedger',
|
'opencode.backfillTaskLedger',
|
||||||
],
|
],
|
||||||
|
opencodeTaskLedgerEvidenceContractVersion: OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||||
|
opencodeAppManagedBootstrapContractVersion: OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION,
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
providerId: 'opencode',
|
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,
|
validateRuntimeStoreManifest,
|
||||||
} from './RuntimeStoreManifest';
|
} from './RuntimeStoreManifest';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
OpenCodeAppManagedBootstrapCandidate,
|
||||||
|
OpenCodeBootstrapEvidenceSource,
|
||||||
|
} from '@shared/types/team';
|
||||||
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
|
import type { RuntimeStoreManifestEvidence } from '../bridge/OpenCodeBridgeCommandContract';
|
||||||
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
|
import type { RuntimeStoreManifestReader } from '../bridge/OpenCodeStateChangingBridgeCommandService';
|
||||||
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
|
import type { RuntimeStoreManifestEntryState } from './RuntimeStoreManifest';
|
||||||
|
|
@ -65,7 +69,8 @@ export interface OpenCodeCommittedBootstrapSessionRecord {
|
||||||
laneId: string;
|
laneId: string;
|
||||||
runId: string | null;
|
runId: string | null;
|
||||||
observedAt: string | null;
|
observedAt: string | null;
|
||||||
source: 'runtime_bootstrap_checkin';
|
source: OpenCodeBootstrapEvidenceSource;
|
||||||
|
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeCommittedBootstrapSessionEvidence {
|
export interface OpenCodeCommittedBootstrapSessionEvidence {
|
||||||
|
|
@ -301,10 +306,20 @@ function normalizeOpenCodeBootstrapSessionRecord(
|
||||||
const memberName = normalizeNonEmptyStoreString(record.memberName);
|
const memberName = normalizeNonEmptyStoreString(record.memberName);
|
||||||
const laneId = normalizeNonEmptyStoreString(record.laneId);
|
const laneId = normalizeNonEmptyStoreString(record.laneId);
|
||||||
const source = normalizeNonEmptyStoreString(record.source);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const observedAt = normalizeOptionalStoreIso(record.observedAt);
|
const observedAt = normalizeOptionalStoreIso(record.observedAt);
|
||||||
|
const appManagedBootstrapCandidate =
|
||||||
|
source === 'app_managed_bootstrap'
|
||||||
|
? normalizeAppManagedBootstrapCandidate(record.appManagedBootstrapCandidate)
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -312,7 +327,62 @@ function normalizeOpenCodeBootstrapSessionRecord(
|
||||||
laneId,
|
laneId,
|
||||||
runId: normalizeNonEmptyStoreString(record.runId),
|
runId: normalizeNonEmptyStoreString(record.runId),
|
||||||
observedAt,
|
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) {
|
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 {
|
return {
|
||||||
state: 'healthy',
|
state: 'healthy',
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@ import type {
|
||||||
TeamRuntimeStopInput,
|
TeamRuntimeStopInput,
|
||||||
TeamRuntimeStopResult,
|
TeamRuntimeStopResult,
|
||||||
} from './TeamRuntimeAdapter';
|
} from './TeamRuntimeAdapter';
|
||||||
import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
|
import type {
|
||||||
|
AgentActionMode,
|
||||||
|
InboxMessageKind,
|
||||||
|
OpenCodeAppManagedBootstrapCandidate,
|
||||||
|
TaskRef,
|
||||||
|
} from '@shared/types/team';
|
||||||
|
|
||||||
export interface OpenCodeTeamRuntimeBridgePort {
|
export interface OpenCodeTeamRuntimeBridgePort {
|
||||||
checkOpenCodeTeamLaunchReadiness(input: {
|
checkOpenCodeTeamLaunchReadiness(input: {
|
||||||
|
|
@ -169,6 +174,15 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
const runtimeSnapshot = skipReadinessPreflight
|
const runtimeSnapshot = skipReadinessPreflight
|
||||||
? null
|
? null
|
||||||
: (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? 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);
|
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||||
const data = await this.bridge.launchOpenCodeTeam({
|
const data = await this.bridge.launchOpenCodeTeam({
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
|
|
@ -457,18 +471,24 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
||||||
checkpointNames.has(name)
|
checkpointNames.has(name)
|
||||||
);
|
);
|
||||||
const bridgeReady = data.teamLaunchState === 'ready';
|
const bridgeReady = data.teamLaunchState === 'ready';
|
||||||
|
const isExpectedMemberConfirmed = (memberName: string): boolean => {
|
||||||
|
const bridgeMember = data.members[memberName];
|
||||||
|
return bridgeMember?.launchState === 'confirmed_alive';
|
||||||
|
};
|
||||||
const missingExpectedMembers = input.expectedMembers
|
const missingExpectedMembers = input.expectedMembers
|
||||||
.map((member) => member.name)
|
.map((member) => member.name)
|
||||||
.filter((memberName) => data.members[memberName] == null);
|
.filter((memberName) => data.members[memberName] == null);
|
||||||
const unconfirmedExpectedMembers = input.expectedMembers
|
const unconfirmedExpectedMembers = input.expectedMembers
|
||||||
.map((member) => member.name)
|
.map((member) => member.name)
|
||||||
.filter((memberName) => data.members[memberName]?.launchState !== 'confirmed_alive');
|
.filter((memberName) => !isExpectedMemberConfirmed(memberName));
|
||||||
const anyExpectedMemberFailed = input.expectedMembers.some(
|
const anyExpectedMemberFailed = input.expectedMembers.some(
|
||||||
(member) => data.members[member.name]?.launchState === 'failed'
|
(member) => data.members[member.name]?.launchState === 'failed'
|
||||||
);
|
);
|
||||||
const allExpectedMembersConfirmed =
|
const allExpectedMembersConfirmed =
|
||||||
input.expectedMembers.length > 0 && unconfirmedExpectedMembers.length === 0;
|
input.expectedMembers.length > 0 && unconfirmedExpectedMembers.length === 0;
|
||||||
const success = bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed;
|
const success =
|
||||||
|
(bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed) ||
|
||||||
|
(data.teamLaunchState === 'launching' && allExpectedMembersConfirmed);
|
||||||
const checkpointDiagnostic = success
|
const checkpointDiagnostic = success
|
||||||
? []
|
? []
|
||||||
: bridgeReady && !readyCheckpointsPresent
|
: bridgeReady && !readyCheckpointsPresent
|
||||||
|
|
@ -522,6 +542,12 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
||||||
bridgeMember?.pendingPermissionRequestIds,
|
bridgeMember?.pendingPermissionRequestIds,
|
||||||
bridgeMember != null,
|
bridgeMember != null,
|
||||||
memberDiagnostics,
|
memberDiagnostics,
|
||||||
|
input.runId,
|
||||||
|
input.laneId?.trim() || 'primary',
|
||||||
|
input.teamName,
|
||||||
|
bridgeMember?.bootstrapEvidenceSource,
|
||||||
|
bridgeMember?.bootstrapMode,
|
||||||
|
bridgeMember?.appManagedBootstrapCandidate,
|
||||||
selectOpenCodeMemberFailureReason({
|
selectOpenCodeMemberFailureReason({
|
||||||
memberDiagnostics: bridgeMember?.diagnostics ?? [],
|
memberDiagnostics: bridgeMember?.diagnostics ?? [],
|
||||||
bridgeDiagnostics: data.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(
|
function mapBridgeMemberToRuntimeEvidence(
|
||||||
memberName: string,
|
memberName: string,
|
||||||
launchState: OpenCodeTeamMemberLaunchBridgeState,
|
launchState: OpenCodeTeamMemberLaunchBridgeState,
|
||||||
|
|
@ -564,8 +645,30 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
pendingPermissionRequestIds: string[] | undefined,
|
pendingPermissionRequestIds: string[] | undefined,
|
||||||
runtimeMaterialized: boolean,
|
runtimeMaterialized: boolean,
|
||||||
diagnostics: string[],
|
diagnostics: string[],
|
||||||
|
runId: string,
|
||||||
|
laneId: string,
|
||||||
|
teamName: string,
|
||||||
|
bootstrapEvidenceSource: TeamRuntimeMemberLaunchEvidence['bootstrapEvidenceSource'] | undefined,
|
||||||
|
bootstrapMode: TeamRuntimeMemberLaunchEvidence['bootstrapMode'] | undefined,
|
||||||
|
appManagedBootstrapCandidate: OpenCodeAppManagedBootstrapCandidate | undefined,
|
||||||
selectedHardFailureReason: string
|
selectedHardFailureReason: string
|
||||||
): TeamRuntimeMemberLaunchEvidence {
|
): 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 confirmed = launchState === 'confirmed_alive';
|
||||||
const failed = launchState === 'failed';
|
const failed = launchState === 'failed';
|
||||||
const hasRuntimePid =
|
const hasRuntimePid =
|
||||||
|
|
@ -580,20 +683,24 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
: launchState === 'permission_blocked'
|
: launchState === 'permission_blocked'
|
||||||
? 'permission_blocked'
|
? 'permission_blocked'
|
||||||
: 'registered_only';
|
: 'registered_only';
|
||||||
const runtimeDiagnostic = pendingRuntimeObserved
|
const runtimeDiagnostic = appManagedCandidatePresent
|
||||||
? hasRuntimePid
|
? 'OpenCode app-managed bootstrap context was injected and verified by the bridge; waiting for app-owned durable evidence commit.'
|
||||||
? 'OpenCode runtime pid reported by bridge without local process verification'
|
: pendingRuntimeObserved
|
||||||
: 'OpenCode session exists without verified runtime pid'
|
? hasRuntimePid
|
||||||
: launchState === 'permission_blocked'
|
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||||
? 'OpenCode runtime is waiting for permission approval'
|
: 'OpenCode session exists without verified runtime pid'
|
||||||
: runtimeMaterialized
|
: launchState === 'permission_blocked'
|
||||||
? 'OpenCode bridge did not report a runtime session or pid for this member'
|
? '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;
|
: undefined;
|
||||||
const runtimeDiagnosticSeverity = failed
|
|
||||||
? 'error'
|
|
||||||
: pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized
|
|
||||||
? 'warning'
|
|
||||||
: undefined;
|
|
||||||
return {
|
return {
|
||||||
memberName,
|
memberName,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -618,6 +725,13 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
? [...new Set(pendingPermissionRequestIds)]
|
? [...new Set(pendingPermissionRequestIds)]
|
||||||
: undefined,
|
: undefined,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
...(appManagedCandidatePresent
|
||||||
|
? { bootstrapEvidenceSource: 'app_managed_bootstrap' as const }
|
||||||
|
: {}),
|
||||||
|
...(appManagedCandidatePresent ? { bootstrapMode: 'app_managed_context' as const } : {}),
|
||||||
|
...(normalizedAppManagedCandidate
|
||||||
|
? { appManagedBootstrapCandidate: normalizedAppManagedCandidate }
|
||||||
|
: {}),
|
||||||
...(hasRuntimePid ? { runtimePid } : {}),
|
...(hasRuntimePid ? { runtimePid } : {}),
|
||||||
livenessKind,
|
livenessKind,
|
||||||
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
|
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
|
||||||
|
|
@ -725,24 +839,24 @@ function buildMemberBootstrapPrompt(
|
||||||
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
|
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
|
||||||
const workflow = member.workflow?.trim();
|
const workflow = member.workflow?.trim();
|
||||||
return [
|
return [
|
||||||
|
'<agent_teams_app_managed_bootstrap_briefing>',
|
||||||
|
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
|
||||||
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
|
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
|
||||||
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
|
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
|
||||||
workflow ? `Workflow:\n${workflow}` : 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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'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.`,
|
`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.',
|
'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)
|
.filter((line): line is string => line !== null)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
@ -792,6 +906,10 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
input.taskRefs
|
input.taskRefs
|
||||||
?.map((ref) => ref.taskId?.trim())
|
?.map((ref) => ref.taskId?.trim())
|
||||||
.filter((taskId): taskId is string => Boolean(taskId)) ?? [];
|
.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
|
const responseInstructions = isWorkSyncNudge
|
||||||
? [
|
? [
|
||||||
'This delivered app message is a member-work-sync nudge.',
|
'This delivered app message is a member-work-sync nudge.',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import type {
|
import type {
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
MemberLaunchState,
|
MemberLaunchState,
|
||||||
|
OpenCodeAppManagedBootstrapCandidate,
|
||||||
|
OpenCodeBootstrapEvidenceSource,
|
||||||
|
OpenCodeBootstrapMode,
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
TeamAgentRuntimeBackendType,
|
TeamAgentRuntimeBackendType,
|
||||||
|
|
@ -79,6 +82,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
||||||
hardFailureReason?: string;
|
hardFailureReason?: string;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||||
|
bootstrapMode?: OpenCodeBootstrapMode;
|
||||||
|
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||||
backendType?: TeamAgentRuntimeBackendType;
|
backendType?: TeamAgentRuntimeBackendType;
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,10 @@ export async function resolveDesktopTeammateModeDecision(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmuxAvailable = await isTmuxAvailable();
|
await isTmuxAvailable();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
injectedTeammateMode: tmuxAvailable ? 'tmux' : null,
|
injectedTeammateMode: null,
|
||||||
forceProcessTeammates: true,
|
forceProcessTeammates: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ export class TeamTaskStallNotifier {
|
||||||
taskRefs: [args.alert.taskRef],
|
taskRefs: [args.alert.taskRef],
|
||||||
actionMode: 'do',
|
actionMode: 'do',
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
|
messageKind: 'task_stall_remediation',
|
||||||
};
|
};
|
||||||
await this.inboxWriter.sendMessage(args.teamName, request);
|
await this.inboxWriter.sendMessage(args.teamName, request);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'
|
||||||
import { TeamKanbanManager } from '../TeamKanbanManager';
|
import { TeamKanbanManager } from '../TeamKanbanManager';
|
||||||
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from '../TeamMembersMetaStore';
|
||||||
import { TeamTaskReader } from '../TeamTaskReader';
|
import { TeamTaskReader } from '../TeamTaskReader';
|
||||||
|
import { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked } from '../teamTaskActiveState';
|
||||||
|
|
||||||
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer';
|
||||||
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
|
import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource';
|
||||||
|
|
@ -87,12 +88,47 @@ export class TeamTaskStallSnapshotSource {
|
||||||
this.kanbanManager.getState(teamName),
|
this.kanbanManager.getState(teamName),
|
||||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
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 allTasksById = new Map(allTasks.map((task) => [task.id, task] as const));
|
||||||
const inProgressTasks = activeTasks.filter(
|
const inProgressTasks = workflowActiveTasks.filter((task) => {
|
||||||
(task) => task.status === 'in_progress' && task.reviewState !== 'review'
|
const kanbanColumn = kanbanState.tasks[task.id]?.column;
|
||||||
);
|
const workflowColumn = getTeamTaskWorkflowColumn({
|
||||||
const reviewOpenTasks = activeTasks.filter((task) => task.reviewState === 'review');
|
...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 resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState);
|
||||||
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
|
const activityReadsEnabled = isBoardTaskActivityReadEnabled();
|
||||||
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
|
const exactReadsEnabled = isBoardTaskExactLogsReadEnabled();
|
||||||
|
|
@ -157,7 +193,7 @@ export class TeamTaskStallSnapshotSource {
|
||||||
transcriptFiles: transcriptContext.transcriptFiles,
|
transcriptFiles: transcriptContext.transcriptFiles,
|
||||||
activityReadsEnabled,
|
activityReadsEnabled,
|
||||||
exactReadsEnabled,
|
exactReadsEnabled,
|
||||||
activeTasks,
|
activeTasks: workflowActiveTasks,
|
||||||
deletedTasks,
|
deletedTasks,
|
||||||
allTasksById,
|
allTasksById,
|
||||||
inProgressTasks,
|
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 === 'in_progress' || status === 'deleted') return 'none';
|
||||||
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||||
if (status === 'completed') {
|
if (status === 'completed') {
|
||||||
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
|
return reviewState === 'review' || reviewState === 'approved' || reviewState === 'needsFix'
|
||||||
|
? reviewState
|
||||||
|
: 'none';
|
||||||
}
|
}
|
||||||
return reviewState;
|
return reviewState;
|
||||||
}
|
}
|
||||||
|
|
@ -1444,9 +1446,11 @@ async function readTasksDirForTeam(
|
||||||
parsed.status === 'deleted'
|
parsed.status === 'deleted'
|
||||||
? (parsed.status as string)
|
? (parsed.status as string)
|
||||||
: 'pending';
|
: 'pending';
|
||||||
|
const derivedReviewState = deriveReviewStateFromEvents(historyEvents);
|
||||||
const reviewState =
|
const reviewState =
|
||||||
deriveReviewStateFromEvents(historyEvents) ??
|
derivedReviewState !== null
|
||||||
normalizeFallbackReviewState(parsed.reviewState, status);
|
? normalizeFallbackReviewState(derivedReviewState, status)
|
||||||
|
: normalizeFallbackReviewState(parsed.reviewState, status);
|
||||||
|
|
||||||
const task = {
|
const task = {
|
||||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
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" />
|
<FlaskConical className="mt-0.5 size-4 shrink-0 text-amber-600" />
|
||||||
<div className="min-w-0">
|
<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">
|
<p className="mt-1 text-xs leading-relaxed text-amber-800">
|
||||||
Some desktop features are not available in the browser yet. Project actions, integrations,
|
The browser version is still in development. Project actions, integrations, and live
|
||||||
and live status data may be limited or not work as expected.
|
status updates may be limited here. Use the desktop app to access all features reliably.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/membe
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
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 { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -105,7 +108,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
}
|
}
|
||||||
}, [isRenaming, displaySubject]);
|
}, [isRenaming, displaySubject]);
|
||||||
|
|
||||||
const reviewColumn = getTaskKanbanColumn(task);
|
const reviewColumn = getTeamTaskWorkflowColumn(task);
|
||||||
const cfg =
|
const cfg =
|
||||||
reviewColumn === 'approved'
|
reviewColumn === 'approved'
|
||||||
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
||||||
|
|
@ -212,7 +215,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{displaySubject}
|
{displaySubject}
|
||||||
{task.reviewState === 'needsFix' && (
|
{isTeamTaskNeedsFixActionable(task) && (
|
||||||
<span
|
<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}`}
|
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 { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
export type TaskStatusFilterId =
|
export type TaskStatusFilterId =
|
||||||
| 'todo'
|
| 'todo'
|
||||||
|
|
@ -50,10 +53,10 @@ export function taskMatchesStatus(
|
||||||
if (statusIds.size === 0) return false;
|
if (statusIds.size === 0) return false;
|
||||||
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
|
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
|
||||||
|
|
||||||
const kanbanColumn = getTaskKanbanColumn(task);
|
const kanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||||
const inNeedsFix = task.reviewState === 'needsFix';
|
const inNeedsFix = isTeamTaskNeedsFixActionable(task);
|
||||||
const inTodo = task.status === 'pending' && !kanbanColumn && !inNeedsFix;
|
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 inDone = task.status === 'completed' && !kanbanColumn && !inNeedsFix;
|
||||||
const inReview = kanbanColumn === 'review';
|
const inReview = kanbanColumn === 'review';
|
||||||
const inApproved = kanbanColumn === 'approved';
|
const inApproved = kanbanColumn === 'approved';
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import { useStore } from '@renderer/store';
|
||||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
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 { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -29,7 +32,7 @@ const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEffectiveColumn(task: TeamTaskWithKanban): string {
|
function getEffectiveColumn(task: TeamTaskWithKanban): string {
|
||||||
const reviewColumn = getTaskKanbanColumn(task);
|
const reviewColumn = getTeamTaskWorkflowColumn(task);
|
||||||
if (reviewColumn) return reviewColumn;
|
if (reviewColumn) return reviewColumn;
|
||||||
if (task.status === 'pending') return 'todo';
|
if (task.status === 'pending') return 'todo';
|
||||||
if (task.status === 'completed') return 'done';
|
if (task.status === 'completed') return 'done';
|
||||||
|
|
@ -159,7 +162,7 @@ export const TaskTooltip = memo(function TaskTooltip({
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{task.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(task) ? (
|
||||||
<span
|
<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}`}
|
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,
|
restoreTask,
|
||||||
fetchDeletedTasks,
|
fetchDeletedTasks,
|
||||||
deletedTasks,
|
deletedTasks,
|
||||||
|
activeTaskLogActivity,
|
||||||
launchParams,
|
launchParams,
|
||||||
messagesPanelMode,
|
messagesPanelMode,
|
||||||
messagesPanelWidth,
|
messagesPanelWidth,
|
||||||
|
|
@ -1299,6 +1300,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
restoreTask: s.restoreTask,
|
restoreTask: s.restoreTask,
|
||||||
fetchDeletedTasks: s.fetchDeletedTasks,
|
fetchDeletedTasks: s.fetchDeletedTasks,
|
||||||
deletedTasks: s.deletedTasks,
|
deletedTasks: s.deletedTasks,
|
||||||
|
activeTaskLogActivity: teamName ? s.activeTaskLogActivityByTeam[teamName] : undefined,
|
||||||
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
|
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
|
||||||
messagesPanelMode: s.messagesPanelMode,
|
messagesPanelMode: s.messagesPanelMode,
|
||||||
messagesPanelWidth: s.messagesPanelWidth,
|
messagesPanelWidth: s.messagesPanelWidth,
|
||||||
|
|
@ -2554,6 +2556,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
||||||
sessions={teamSessions}
|
sessions={teamSessions}
|
||||||
leadSessionId={data.config.leadSessionId}
|
leadSessionId={data.config.leadSessionId}
|
||||||
members={activeMembers}
|
members={activeMembers}
|
||||||
|
activeTaskLogActivity={activeTaskLogActivity}
|
||||||
forceShowAllTasks={isKanbanSearchActive}
|
forceShowAllTasks={isKanbanSearchActive}
|
||||||
onFilterChange={setKanbanFilter}
|
onFilterChange={setKanbanFilter}
|
||||||
onSortChange={setKanbanSort}
|
onSortChange={setKanbanSort}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ import {
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -27,7 +29,7 @@ interface ActiveTasksBlockProps {
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
task: TeamTaskWithKanban | undefined;
|
task: TeamTaskWithKanban;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
kind: 'working' | 'reviewing';
|
kind: 'working' | 'reviewing';
|
||||||
}
|
}
|
||||||
|
|
@ -53,8 +55,8 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
if (!m.currentTaskId) continue;
|
if (!m.currentTaskId) continue;
|
||||||
const task = taskMap.get(m.currentTaskId);
|
const task = taskMap.get(m.currentTaskId);
|
||||||
// Defense-in-depth: hide banner for approved/completed tasks even if currentTaskId is stale
|
// Defense-in-depth: hide stale currentTaskId until backend refresh clears it.
|
||||||
if (task && (task.reviewState === 'approved' || task.status === 'completed')) continue;
|
if (!isDisplayableCurrentTask(task)) continue;
|
||||||
workingMemberNames.add(m.name);
|
workingMemberNames.add(m.name);
|
||||||
entries.push({ member: m, task, taskId: m.currentTaskId, kind: 'working' });
|
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) {
|
for (const m of members) {
|
||||||
if (workingMemberNames.has(m.name)) continue;
|
if (workingMemberNames.has(m.name)) continue;
|
||||||
const reviewTask = tasks.find(
|
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) {
|
if (reviewTask) {
|
||||||
entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' });
|
entries.push({ member: m, task: reviewTask, taskId: reviewTask.id, kind: 'reviewing' });
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ import {
|
||||||
getKnownSlashCommand,
|
getKnownSlashCommand,
|
||||||
parseStandaloneSlashCommand,
|
parseStandaloneSlashCommand,
|
||||||
} from '@shared/utils/slashCommands';
|
} from '@shared/utils/slashCommands';
|
||||||
|
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
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 = ({
|
const BootstrapSystemRow = ({
|
||||||
teamName,
|
teamName,
|
||||||
eventKind,
|
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) {
|
if (bootstrapDisplay) {
|
||||||
return (
|
return (
|
||||||
<BootstrapSystemRow
|
<BootstrapSystemRow
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||||
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
|
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
|
||||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||||
|
import { isTeamInternalControlMessageText } from '@shared/utils/teamInternalControlMessages';
|
||||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
|
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;
|
if (msg.messageKind === 'slash_command_result') return false;
|
||||||
// Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden
|
// Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden
|
||||||
if (isThoughtProtocolNoise(msg.text)) return false;
|
if (isThoughtProtocolNoise(msg.text)) return false;
|
||||||
|
if (isTeamInternalControlMessageText(msg.text)) return false;
|
||||||
if (msg.source === 'lead_session') return true;
|
if (msg.source === 'lead_session') return true;
|
||||||
if (msg.source === 'lead_process') return true;
|
if (msg.source === 'lead_process') return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -90,7 +92,7 @@ export function isLeadThought(msg: InboxMessage): boolean {
|
||||||
function isLeadSessionNoise(msg: InboxMessage): boolean {
|
function isLeadSessionNoise(msg: InboxMessage): boolean {
|
||||||
if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false;
|
if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false;
|
||||||
if (typeof msg.to === 'string' && msg.to.trim().length > 0) 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 =
|
export type TimelineItem =
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
extractTaskRefsFromText,
|
extractTaskRefsFromText,
|
||||||
stripEncodedTaskReferenceMetadata,
|
stripEncodedTaskReferenceMetadata,
|
||||||
} from '@renderer/utils/taskReferenceUtils';
|
} from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
|
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
|
// Only show non-internal, non-deleted tasks as candidates for blocking
|
||||||
const availableTasks = tasks.filter(
|
const availableTasks = tasks.filter(
|
||||||
(t) => t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved'
|
(t) => t.status !== 'deleted' && getTeamTaskWorkflowColumn(t) !== 'approved'
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleBlockedBy = (taskId: string): void => {
|
const toggleBlockedBy = (taskId: string): void => {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} 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';
|
import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -107,6 +107,52 @@ const EventContent = ({
|
||||||
<StatusBadge status={event.to} />
|
<StatusBadge status={event.to} />
|
||||||
</span>
|
</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':
|
case 'review_requested':
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
|
|
@ -181,6 +227,8 @@ function dotColor(event: TaskHistoryEvent): string {
|
||||||
return dotColorForStatus(event.status);
|
return dotColorForStatus(event.status);
|
||||||
case 'status_changed':
|
case 'status_changed':
|
||||||
return dotColorForStatus(event.to);
|
return dotColorForStatus(event.to);
|
||||||
|
case 'owner_changed':
|
||||||
|
return 'bg-cyan-400';
|
||||||
case 'review_requested':
|
case 'review_requested':
|
||||||
return 'bg-purple-400';
|
return 'bg-purple-400';
|
||||||
case 'review_started':
|
case 'review_started':
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,11 @@ import {
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import {
|
import {
|
||||||
deriveTaskDisplayId,
|
deriveTaskDisplayId,
|
||||||
formatTaskDisplayLabel,
|
formatTaskDisplayLabel,
|
||||||
|
|
@ -598,12 +602,10 @@ export const TaskDetailDialog = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const kanbanColumn =
|
const kanbanColumn = getTeamTaskWorkflowColumn({
|
||||||
kanbanTaskState?.column ??
|
...currentTask,
|
||||||
getTaskKanbanColumn({
|
...(kanbanTaskState?.column ? { kanbanColumn: kanbanTaskState.column } : {}),
|
||||||
reviewState: currentTask.reviewState,
|
});
|
||||||
kanbanColumn: currentTask.kanbanColumn,
|
|
||||||
});
|
|
||||||
const status = currentTask.status;
|
const status = currentTask.status;
|
||||||
const statusStyle =
|
const statusStyle =
|
||||||
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
|
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">
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||||
{formatTaskDisplayLabel(currentTask)}
|
{formatTaskDisplayLabel(currentTask)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') &&
|
{(kanbanColumn === 'approved' || kanbanColumn === 'review') &&
|
||||||
currentTask.reviewer &&
|
currentTask.reviewer &&
|
||||||
currentTask.reviewer !== 'user' ? (
|
currentTask.reviewer !== 'user' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const reviewerColor = colorMap.get(currentTask.reviewer);
|
const reviewerColor = colorMap.get(currentTask.reviewer);
|
||||||
const colors =
|
const colors =
|
||||||
currentTask.reviewState === 'review'
|
kanbanColumn === 'review'
|
||||||
? getTeamColorSet('blue')
|
? getTeamColorSet('blue')
|
||||||
: getTeamColorSet(reviewerColor ?? '');
|
: getTeamColorSet(reviewerColor ?? '');
|
||||||
const reviewerBadgeStyle = {
|
const reviewerBadgeStyle = {
|
||||||
|
|
@ -677,7 +679,7 @@ export const TaskDetailDialog = ({
|
||||||
};
|
};
|
||||||
const lastReviewEvent = currentTask.historyEvents
|
const lastReviewEvent = currentTask.historyEvents
|
||||||
?.filter((e) =>
|
?.filter((e) =>
|
||||||
currentTask.reviewState === 'approved'
|
kanbanColumn === 'approved'
|
||||||
? e.type === 'review_approved'
|
? e.type === 'review_approved'
|
||||||
: e.type === 'review_requested' || e.type === 'review_started'
|
: e.type === 'review_requested' || e.type === 'review_started'
|
||||||
)
|
)
|
||||||
|
|
@ -731,7 +733,7 @@ export const TaskDetailDialog = ({
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{currentTask.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(currentTask) ? (
|
||||||
<span
|
<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}`}
|
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>
|
</span>
|
||||||
{blockedByIds.map((id) => {
|
{blockedByIds.map((id) => {
|
||||||
const depTask = taskMap.get(id);
|
const depTask = taskMap.get(id);
|
||||||
const isCompleted = depTask?.status === 'completed';
|
const isCompleted = depTask
|
||||||
|
? isTeamTaskFinishedForDependency(depTask)
|
||||||
|
: false;
|
||||||
const label = depTask
|
const label = depTask
|
||||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||||
: `#${deriveTaskDisplayId(id)}`;
|
: `#${deriveTaskDisplayId(id)}`;
|
||||||
|
|
@ -977,7 +981,9 @@ export const TaskDetailDialog = ({
|
||||||
</span>
|
</span>
|
||||||
{blocksIds.map((id) => {
|
{blocksIds.map((id) => {
|
||||||
const depTask = taskMap.get(id);
|
const depTask = taskMap.get(id);
|
||||||
const isCompleted = depTask?.status === 'completed';
|
const isCompleted = depTask
|
||||||
|
? isTeamTaskFinishedForDependency(depTask)
|
||||||
|
: false;
|
||||||
const label = depTask
|
const label = depTask
|
||||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||||
: `#${deriveTaskDisplayId(id)}`;
|
: `#${deriveTaskDisplayId(id)}`;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { isTeamTaskNeedsFixActionable } from '@shared/utils/teamTaskState';
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -78,6 +79,7 @@ interface KanbanBoardProps {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
leadSessionId?: string;
|
leadSessionId?: string;
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
|
activeTaskLogActivity?: Record<string, true>;
|
||||||
/** Shows all cards when another UI flow, such as search, must not hide matches. */
|
/** Shows all cards when another UI flow, such as search, must not hide matches. */
|
||||||
forceShowAllTasks?: boolean;
|
forceShowAllTasks?: boolean;
|
||||||
onFilterChange: (filter: KanbanFilterState) => void;
|
onFilterChange: (filter: KanbanFilterState) => void;
|
||||||
|
|
@ -160,7 +162,7 @@ function estimateGridSkeletonCardHeight(
|
||||||
if (task.subject.length > 54) height += 10;
|
if (task.subject.length > 54) height += 10;
|
||||||
if (task.subject.length > 92) height += 8;
|
if (task.subject.length > 92) height += 8;
|
||||||
if (task.needsClarification) height += 16;
|
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.blockedBy?.length ?? 0) > 0) height += 18;
|
||||||
if ((task.blocks?.length ?? 0) > 0) height += 18;
|
if ((task.blocks?.length ?? 0) > 0) height += 18;
|
||||||
|
|
||||||
|
|
@ -244,6 +246,7 @@ interface SortableKanbanTaskCardProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
taskMap: Map<string, TeamTask>;
|
taskMap: Map<string, TeamTask>;
|
||||||
memberColorMap: Map<string, string>;
|
memberColorMap: Map<string, string>;
|
||||||
|
hasLiveTaskLogs?: boolean;
|
||||||
onRequestReview: (taskId: string) => void;
|
onRequestReview: (taskId: string) => void;
|
||||||
onApprove: (taskId: string) => void;
|
onApprove: (taskId: string) => void;
|
||||||
onRequestChanges: (taskId: string) => void;
|
onRequestChanges: (taskId: string) => void;
|
||||||
|
|
@ -265,6 +268,7 @@ const SortableKanbanTaskCard = ({
|
||||||
compact,
|
compact,
|
||||||
taskMap,
|
taskMap,
|
||||||
memberColorMap,
|
memberColorMap,
|
||||||
|
hasLiveTaskLogs,
|
||||||
onRequestReview,
|
onRequestReview,
|
||||||
onApprove,
|
onApprove,
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
|
|
@ -300,6 +304,7 @@ const SortableKanbanTaskCard = ({
|
||||||
compact={compact}
|
compact={compact}
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
memberColorMap={memberColorMap}
|
memberColorMap={memberColorMap}
|
||||||
|
hasLiveTaskLogs={hasLiveTaskLogs}
|
||||||
onRequestReview={onRequestReview}
|
onRequestReview={onRequestReview}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onRequestChanges={onRequestChanges}
|
onRequestChanges={onRequestChanges}
|
||||||
|
|
@ -325,6 +330,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
sessions,
|
sessions,
|
||||||
leadSessionId,
|
leadSessionId,
|
||||||
members,
|
members,
|
||||||
|
activeTaskLogActivity,
|
||||||
forceShowAllTasks = false,
|
forceShowAllTasks = false,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
|
@ -578,6 +584,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
compact={compact}
|
compact={compact}
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
memberColorMap={memberColorMap}
|
memberColorMap={memberColorMap}
|
||||||
|
hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])}
|
||||||
onRequestReview={onRequestReview}
|
onRequestReview={onRequestReview}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onRequestChanges={onRequestChanges}
|
onRequestChanges={onRequestChanges}
|
||||||
|
|
@ -610,6 +617,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
compact={compact}
|
compact={compact}
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
memberColorMap={memberColorMap}
|
memberColorMap={memberColorMap}
|
||||||
|
hasLiveTaskLogs={Boolean(activeTaskLogActivity?.[task.id])}
|
||||||
onRequestReview={onRequestReview}
|
onRequestReview={onRequestReview}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onRequestChanges={onRequestChanges}
|
onRequestChanges={onRequestChanges}
|
||||||
|
|
@ -630,6 +638,7 @@ export const KanbanBoard = memo(function KanbanBoard({
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
enableTaskSorting,
|
enableTaskSorting,
|
||||||
|
activeTaskLogActivity,
|
||||||
handleScrollToTask,
|
handleScrollToTask,
|
||||||
hasReviewers,
|
hasReviewers,
|
||||||
kanbanState,
|
kanbanState,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,41 @@ const baseTask: TeamTaskWithKanban = {
|
||||||
|
|
||||||
const noop = (): void => undefined;
|
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', () => {
|
describe('KanbanTaskCard change badge', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
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 { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -12,6 +13,10 @@ import {
|
||||||
buildTaskChangeRequestOptions,
|
buildTaskChangeRequestOptions,
|
||||||
canDisplayTaskChangesForOptions,
|
canDisplayTaskChangesForOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
|
import {
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
ArrowLeftFromLine,
|
ArrowLeftFromLine,
|
||||||
|
|
@ -38,6 +43,7 @@ interface KanbanTaskCardProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
taskMap: Map<string, TeamTask>;
|
taskMap: Map<string, TeamTask>;
|
||||||
memberColorMap: Map<string, string>;
|
memberColorMap: Map<string, string>;
|
||||||
|
hasLiveTaskLogs?: boolean;
|
||||||
onRequestReview: (taskId: string) => void;
|
onRequestReview: (taskId: string) => void;
|
||||||
onApprove: (taskId: string) => void;
|
onApprove: (taskId: string) => void;
|
||||||
onRequestChanges: (taskId: string) => void;
|
onRequestChanges: (taskId: string) => void;
|
||||||
|
|
@ -63,7 +69,7 @@ const DependencyBadge = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
}: DependencyBadgeProps): React.JSX.Element => {
|
}: DependencyBadgeProps): React.JSX.Element => {
|
||||||
const depTask = taskMap.get(taskId);
|
const depTask = taskMap.get(taskId);
|
||||||
const isCompleted = depTask?.status === 'completed';
|
const isCompleted = depTask ? isTeamTaskFinishedForDependency(depTask) : false;
|
||||||
const label = depTask
|
const label = depTask
|
||||||
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
|
||||||
: `#${deriveTaskDisplayId(taskId)}`;
|
: `#${deriveTaskDisplayId(taskId)}`;
|
||||||
|
|
@ -227,6 +233,7 @@ export const KanbanTaskCard = memo(
|
||||||
compact,
|
compact,
|
||||||
taskMap,
|
taskMap,
|
||||||
memberColorMap,
|
memberColorMap,
|
||||||
|
hasLiveTaskLogs = false,
|
||||||
onRequestReview,
|
onRequestReview,
|
||||||
onApprove,
|
onApprove,
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
|
|
@ -245,6 +252,7 @@ export const KanbanTaskCard = memo(
|
||||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||||
const hasBlockedBy = blockedByIds.length > 0;
|
const hasBlockedBy = blockedByIds.length > 0;
|
||||||
const hasBlocks = blocksIds.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 cardSurfaceClass = isLight ? 'bg-white' : 'bg-[var(--color-surface-raised)]';
|
||||||
|
|
||||||
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
|
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
|
||||||
|
|
@ -288,8 +296,8 @@ export const KanbanTaskCard = memo(
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-task-id={task.id}
|
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)] ${
|
className={`kanban-task-card relative cursor-pointer rounded-md border px-1.5 py-3 hover:border-[var(--color-border-emphasis)] ${
|
||||||
hasBlockedBy
|
shouldHighlightBlocked
|
||||||
? `border-yellow-500/30 ${cardSurfaceClass}`
|
? `border-yellow-500/30 ${cardSurfaceClass}`
|
||||||
: `border-[var(--color-border)] ${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)]">
|
<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)]">
|
||||||
{formatTaskDisplayLabel(task)}
|
<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>
|
</span>
|
||||||
{task.owner ? (
|
{task.owner ? (
|
||||||
<span className="absolute right-[6px] top-[2px]">
|
<span className="absolute right-[6px] top-[2px]">
|
||||||
|
|
@ -325,7 +338,7 @@ export const KanbanTaskCard = memo(
|
||||||
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{task.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(task) ? (
|
||||||
<span
|
<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}`}
|
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.compact === next.compact &&
|
||||||
prev.taskMap === next.taskMap &&
|
prev.taskMap === next.taskMap &&
|
||||||
prev.memberColorMap === next.memberColorMap &&
|
prev.memberColorMap === next.memberColorMap &&
|
||||||
|
prev.hasLiveTaskLogs === next.hasLiveTaskLogs &&
|
||||||
prev.onRequestReview === next.onRequestReview &&
|
prev.onRequestReview === next.onRequestReview &&
|
||||||
prev.onApprove === next.onApprove &&
|
prev.onApprove === next.onApprove &&
|
||||||
prev.onRequestChanges === next.onRequestChanges &&
|
prev.onRequestChanges === next.onRequestChanges &&
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,15 @@ export const MemberCard = memo(function MemberCard({
|
||||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||||
);
|
);
|
||||||
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]);
|
||||||
|
const presentationMember =
|
||||||
|
member.currentTaskId && !currentTask
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
currentTaskId: null,
|
||||||
|
}
|
||||||
|
: member;
|
||||||
const launchPresentation = buildMemberLaunchPresentation({
|
const launchPresentation = buildMemberLaunchPresentation({
|
||||||
member,
|
member: presentationMember,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ import {
|
||||||
getRuntimeMemorySourceLabel,
|
getRuntimeMemorySourceLabel,
|
||||||
resolveMemberRuntimeSummary,
|
resolveMemberRuntimeSummary,
|
||||||
} from '@renderer/utils/memberRuntimeSummary';
|
} from '@renderer/utils/memberRuntimeSummary';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
FileText,
|
FileText,
|
||||||
|
|
@ -155,12 +157,22 @@ export const MemberDetailDialog = ({
|
||||||
}, [member, memberMessages, members, tasks, teamName]);
|
}, [member, memberMessages, members, tasks, teamName]);
|
||||||
|
|
||||||
const inProgressTasks = useMemo(
|
const inProgressTasks = useMemo(
|
||||||
() => memberTasks.filter((t) => t.status === 'in_progress').length,
|
() => memberTasks.filter(isDisplayableCurrentTask).length,
|
||||||
[memberTasks]
|
[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(
|
const completedTasks = useMemo(
|
||||||
() => memberTasks.filter((t) => t.status === 'completed').length,
|
() => memberTasks.filter(isTeamTaskFinishedForDependency).length,
|
||||||
[memberTasks]
|
[memberTasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -255,7 +267,11 @@ export const MemberDetailDialog = ({
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<DialogHeader className="shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<MemberDetailHeader
|
<MemberDetailHeader
|
||||||
member={member}
|
member={
|
||||||
|
member.currentTaskId && !displayableCurrentTask
|
||||||
|
? { ...member, currentTaskId: null }
|
||||||
|
: member
|
||||||
|
}
|
||||||
runtimeSummary={runtimeSummary}
|
runtimeSummary={runtimeSummary}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import {
|
import {
|
||||||
buildMemberLaunchDiagnosticsPayload,
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
getMemberLaunchDiagnosticsErrorMessage,
|
getMemberLaunchDiagnosticsErrorMessage,
|
||||||
|
|
@ -31,6 +32,7 @@ import {
|
||||||
hasMemberLaunchDiagnosticsError,
|
hasMemberLaunchDiagnosticsError,
|
||||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -126,8 +128,19 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
|
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;
|
||||||
const colors = getTeamColorSet(color ?? member.color ?? '');
|
const colors = getTeamColorSet(color ?? member.color ?? '');
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
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({
|
const launchPresentation = buildMemberLaunchPresentation({
|
||||||
member,
|
member: presentationMember,
|
||||||
spawnStatus: spawnEntry?.status,
|
spawnStatus: spawnEntry?.status,
|
||||||
spawnLaunchState: spawnEntry?.launchState,
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||||
|
|
@ -171,15 +184,12 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
||||||
const showCopyDiagnostics =
|
const showCopyDiagnostics =
|
||||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
|
||||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
|
||||||
: null;
|
|
||||||
const reviewTask: TeamTaskWithKanban | null = tasks
|
const reviewTask: TeamTaskWithKanban | null = tasks
|
||||||
? (tasks.find(
|
? (tasks.find(
|
||||||
(task) =>
|
(task) =>
|
||||||
task.reviewer === member.name &&
|
task.reviewer === member.name &&
|
||||||
task.id !== member.currentTaskId &&
|
task.id !== currentTask?.id &&
|
||||||
(task.reviewState === 'review' || task.kanbanColumn === 'review')
|
getTeamTaskWorkflowColumn(task) === 'review'
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { getTeamTaskWorkflowColumn } from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
|
|
@ -425,7 +427,7 @@ export const MemberList = memo(function MemberList({
|
||||||
const result = new Map<string, TeamTaskWithKanban>();
|
const result = new Map<string, TeamTaskWithKanban>();
|
||||||
if (!taskMap) return result;
|
if (!taskMap) return result;
|
||||||
for (const task of taskMap.values()) {
|
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);
|
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 ref={containerRef} className="flex flex-col gap-1">
|
||||||
<div className={gridClass}>
|
<div className={gridClass}>
|
||||||
{activeMembers.map((member) => {
|
{activeMembers.map((member) => {
|
||||||
const currentTask =
|
const currentTaskCandidate =
|
||||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
|
const currentTask = isDisplayableCurrentTask(currentTaskCandidate)
|
||||||
|
? currentTaskCandidate
|
||||||
|
: null;
|
||||||
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
const reviewCandidate = reviewTaskByMember.get(member.name) ?? null;
|
||||||
const reviewTask =
|
const reviewTask =
|
||||||
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
|
reviewCandidate && reviewCandidate.id !== currentTask?.id ? reviewCandidate : null;
|
||||||
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
const runtimeEntry = memberRuntimeEntries?.get(member.name);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
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="max-h-[320px] overflow-y-auto">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{visibleTasks.map((task) => {
|
{visibleTasks.map((task) => {
|
||||||
const col = getTaskKanbanColumn(task);
|
const col = getTeamTaskWorkflowColumn(task);
|
||||||
const style =
|
const style =
|
||||||
col && KANBAN_COLUMN_DISPLAY[col]
|
col && KANBAN_COLUMN_DISPLAY[col]
|
||||||
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
|
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
|
||||||
|
|
@ -71,7 +74,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{task.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(task) ? (
|
||||||
<span
|
<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}`}
|
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 { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
|
|
@ -587,6 +588,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
|
|
||||||
const activityTimelineMessages = useMemo(() => {
|
const activityTimelineMessages = useMemo(() => {
|
||||||
return filterTeamMessages(effectiveMessages, {
|
return filterTeamMessages(effectiveMessages, {
|
||||||
|
includeAutomationEvents: true,
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||||
leadNames,
|
leadNames,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
|
|
@ -600,6 +602,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
effectiveMessages.filter(
|
effectiveMessages.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.messageKind !== 'task_comment_notification' &&
|
m.messageKind !== 'task_comment_notification' &&
|
||||||
|
!isTaskStallRemediationMessage(m) &&
|
||||||
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
|
!shouldExcludeInboxTextFromReplyCandidates(typeof m.text === 'string' ? m.text : '')
|
||||||
),
|
),
|
||||||
[effectiveMessages]
|
[effectiveMessages]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
|
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
|
import { ActiveTasksBlock } from '../activity/ActiveTasksBlock';
|
||||||
|
|
@ -55,8 +56,7 @@ export const StatusBlock = ({
|
||||||
return members.some((m) => {
|
return members.some((m) => {
|
||||||
if (!m.currentTaskId) return false;
|
if (!m.currentTaskId) return false;
|
||||||
const task = tMap.get(m.currentTaskId);
|
const task = tMap.get(m.currentTaskId);
|
||||||
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
|
return isDisplayableCurrentTask(task);
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
}, [members, tasks]);
|
}, [members, tasks]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { asEnhancedChunkArray } from '@renderer/types/data';
|
import { asEnhancedChunkArray } from '@renderer/types/data';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
|
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -375,7 +376,7 @@ export const TaskLogStreamSection = ({
|
||||||
}
|
}
|
||||||
const shouldReload =
|
const shouldReload =
|
||||||
event.type === 'log-source-change' ||
|
event.type === 'log-source-change' ||
|
||||||
(event.type === 'task-log-change' && event.taskId === taskId);
|
(isTaskLogActivityChangeEvent(event) && event.taskId === taskId);
|
||||||
if (!shouldReload) {
|
if (!shouldReload) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
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 { ExecutionSessionsSection } from './ExecutionSessionsSection';
|
||||||
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
|
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
|
||||||
|
|
@ -64,9 +66,10 @@ export const TaskLogsPanel = ({
|
||||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const countRequestSeqRef = useRef(0);
|
const countRequestSeqRef = useRef(0);
|
||||||
const taskLogTrackingEnabled =
|
const hasTaskLogStream = availableTabs.includes('stream');
|
||||||
hasOpenedContent && task.status === 'in_progress' && availableTabs.includes('stream');
|
const taskIsActivelyWorked = isDisplayableCurrentTask(task);
|
||||||
const taskLogSummaryEnabled = hasOpenedContent && availableTabs.includes('stream');
|
const taskLogActivityTrackingEnabled = taskIsActivelyWorked && hasTaskLogStream;
|
||||||
|
const taskLogSummaryEnabled = hasOpenedContent && hasTaskLogStream;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
|
|
@ -133,7 +136,7 @@ export const TaskLogsPanel = ({
|
||||||
}, [task.id, taskLogSummaryEnabled, teamName]);
|
}, [task.id, taskLogSummaryEnabled, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
|
if (!taskLogActivityTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,10 +146,10 @@ export const TaskLogsPanel = ({
|
||||||
() => undefined
|
() => undefined
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [taskLogTrackingEnabled, teamName]);
|
}, [taskLogActivityTrackingEnabled, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskLogTrackingEnabled) {
|
if (!taskLogActivityTrackingEnabled) {
|
||||||
if (pulseTimerRef.current) {
|
if (pulseTimerRef.current) {
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = null;
|
pulseTimerRef.current = null;
|
||||||
|
|
@ -160,7 +163,7 @@ export const TaskLogsPanel = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleCountReload = (): void => {
|
const scheduleCountReload = (): void => {
|
||||||
if (!api.teams.getTaskLogStreamSummary) {
|
if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||||
|
|
@ -187,7 +190,7 @@ export const TaskLogsPanel = ({
|
||||||
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
||||||
if (
|
if (
|
||||||
event.teamName !== teamName ||
|
event.teamName !== teamName ||
|
||||||
event.type !== 'task-log-change' ||
|
!isTaskLogActivityChangeEvent(event) ||
|
||||||
event.taskId !== task.id
|
event.taskId !== task.id
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -230,7 +233,7 @@ export const TaskLogsPanel = ({
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [task.id, taskLogTrackingEnabled, teamName]);
|
}, [task.id, taskLogActivityTrackingEnabled, taskLogSummaryEnabled, teamName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|
@ -260,7 +263,7 @@ export const TaskLogsPanel = ({
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={task.id}
|
taskId={task.id}
|
||||||
taskStatus={task.status}
|
taskStatus={task.status}
|
||||||
liveEnabled={isOpen && task.status === 'in_progress'}
|
liveEnabled={isOpen && taskIsActivelyWorked}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import {
|
||||||
REVIEW_STATE_DISPLAY,
|
REVIEW_STATE_DISPLAY,
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} 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 { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -17,7 +20,7 @@ interface TaskRowProps {
|
||||||
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
|
export const TaskRow = memo(function TaskRow({ task }: TaskRowProps): React.JSX.Element {
|
||||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||||
const kanbanColumn = getTaskKanbanColumn(task);
|
const kanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-t border-[var(--color-border)]">
|
<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
|
? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
|
||||||
: (TASK_STATUS_LABELS[task.status] ?? task.status)}
|
: (TASK_STATUS_LABELS[task.status] ?? task.status)}
|
||||||
</span>
|
</span>
|
||||||
{task.reviewState === 'needsFix' ? (
|
{isTeamTaskNeedsFixActionable(task) ? (
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${REVIEW_STATE_DISPLAY.needsFix.bg} ${REVIEW_STATE_DISPLAY.needsFix.text}`}
|
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 */
|
/* Subtle borders */
|
||||||
--color-border-emphasis: rgba(148, 163, 184, 0.12);
|
--color-border-emphasis: rgba(148, 163, 184, 0.12);
|
||||||
/* Emphasis borders */
|
/* 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: #f1f5f9;
|
||||||
--color-text-secondary: #94a3b8;
|
--color-text-secondary: #94a3b8;
|
||||||
--color-text-muted: #64748b;
|
--color-text-muted: #64748b;
|
||||||
|
|
@ -269,6 +272,19 @@
|
||||||
overflow: visible;
|
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 {
|
.kanban-grid-item-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -466,6 +482,9 @@
|
||||||
/* Warm subtle border */
|
/* Warm subtle border */
|
||||||
--color-border-emphasis: #a8a5a0;
|
--color-border-emphasis: #a8a5a0;
|
||||||
/* Warm emphasis border */
|
/* 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;
|
--color-text: #1c1b19;
|
||||||
/* Warm near-black text */
|
/* Warm near-black text */
|
||||||
--color-text-secondary: #4d4b46;
|
--color-text-secondary: #4d4b46;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
buildTaskChangeRequestOptions,
|
buildTaskChangeRequestOptions,
|
||||||
canDisplayTaskChangesForOptions,
|
canDisplayTaskChangesForOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
|
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
||||||
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
import { isVersionOlder, normalizeVersion } from '@shared/utils/version';
|
||||||
import { create } from 'zustand';
|
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_POLL_MS = 10_000;
|
||||||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_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']> =
|
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
|
||||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
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-message',
|
||||||
'lead-context',
|
'lead-context',
|
||||||
'lead-activity',
|
'lead-activity',
|
||||||
|
'member-advisory',
|
||||||
'process',
|
'process',
|
||||||
'member-spawn',
|
'member-spawn',
|
||||||
]);
|
]);
|
||||||
|
|
@ -268,9 +272,11 @@ export function initializeNotificationListeners(): () => void {
|
||||||
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let teamPresenceRefreshTimers = 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 memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let toolActivityTimers = 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<
|
let processLiteStructuralReconcileTimers = new Map<
|
||||||
string,
|
string,
|
||||||
{ firstScheduledAt: number; timer: ReturnType<typeof setTimeout> }
|
{ firstScheduledAt: number; timer: ReturnType<typeof setTimeout> }
|
||||||
|
|
@ -545,6 +551,71 @@ export function initializeNotificationListeners(): () => void {
|
||||||
toolActivityTimers.delete(key);
|
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 = (
|
const clearRuntimeToolStateForTeam = (
|
||||||
prev: AppState,
|
prev: AppState,
|
||||||
teamName: string
|
teamName: string
|
||||||
|
|
@ -666,7 +737,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateTasks = teamData.tasks.filter((task) => {
|
const candidateTasks = teamData.tasks.filter((task) => {
|
||||||
if (task.status !== 'in_progress') {
|
if (!isDisplayableCurrentTask(task)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
|
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
|
||||||
|
|
@ -700,7 +771,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
|
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
|
||||||
if (currentTask?.status !== 'in_progress') {
|
if (!isDisplayableCurrentTask(currentTask)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -858,6 +929,10 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return getVisibleTeamNamesInAnyPane();
|
return getVisibleTeamNamesInAnyPane();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTrackedTaskLogActivityTeams = (): Set<string> => {
|
||||||
|
return getVisibleTeamNamesInAnyPane();
|
||||||
|
};
|
||||||
|
|
||||||
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
|
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
|
||||||
teamLastRelevantActivityAt.set(teamName, timestamp);
|
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
|
// Listen for task-list file changes to refresh currently viewed session metadata
|
||||||
if (api.onTodoChange) {
|
if (api.onTodoChange) {
|
||||||
const cleanup = api.onTodoChange((event) => {
|
const cleanup = api.onTodoChange((event) => {
|
||||||
|
|
@ -1420,6 +1535,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
|
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
|
||||||
delete nextState.leadContextByTeam[event.teamName];
|
delete nextState.leadContextByTeam[event.teamName];
|
||||||
Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName));
|
Object.assign(nextState, clearRuntimeToolStateForTeam(prev, event.teamName));
|
||||||
|
nextState.activeTaskLogActivityByTeam = { ...prev.activeTaskLogActivityByTeam };
|
||||||
|
delete nextState.activeTaskLogActivityByTeam[event.teamName];
|
||||||
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
|
nextState.currentRuntimeRunIdByTeam = { ...prev.currentRuntimeRunIdByTeam };
|
||||||
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
|
delete nextState.currentRuntimeRunIdByTeam[event.teamName];
|
||||||
nextState.ignoredRuntimeRunIds = event.runId
|
nextState.ignoredRuntimeRunIds = event.runId
|
||||||
|
|
@ -1429,6 +1546,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
: prev.ignoredRuntimeRunIds;
|
: prev.ignoredRuntimeRunIds;
|
||||||
clearToolActivityTimersForTeam(event.teamName);
|
clearToolActivityTimersForTeam(event.teamName);
|
||||||
|
clearTaskLogActivityTimersForTeam(event.teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextState as typeof prev;
|
return nextState as typeof prev;
|
||||||
|
|
@ -1583,6 +1701,59 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
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.
|
// Member spawn status change: fetch updated spawn statuses for the team.
|
||||||
if (event.type === 'member-spawn') {
|
if (event.type === 'member-spawn') {
|
||||||
if (isStaleRuntimeEvent) {
|
if (isStaleRuntimeEvent) {
|
||||||
|
|
@ -1610,6 +1781,75 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
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.type === 'log-source-change') {
|
||||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1791,12 +2031,16 @@ export function initializeNotificationListeners(): () => void {
|
||||||
teamMessageRefreshTimers = new Map();
|
teamMessageRefreshTimers = new Map();
|
||||||
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
|
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
|
||||||
teamPresenceRefreshTimers = new Map();
|
teamPresenceRefreshTimers = new Map();
|
||||||
|
for (const t of memberAdvisorySafetyRefreshTimers.values()) clearTimeout(t);
|
||||||
|
memberAdvisorySafetyRefreshTimers = new Map();
|
||||||
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
||||||
memberSpawnRefreshTimers = new Map();
|
memberSpawnRefreshTimers = new Map();
|
||||||
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
|
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
|
||||||
teamAgentRuntimeRefreshTimers = new Map();
|
teamAgentRuntimeRefreshTimers = new Map();
|
||||||
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
||||||
toolActivityTimers = new Map();
|
toolActivityTimers = new Map();
|
||||||
|
for (const t of taskLogActivityTimers.values()) clearTimeout(t);
|
||||||
|
taskLogActivityTimers = new Map();
|
||||||
for (const state of processLiteStructuralReconcileTimers.values()) {
|
for (const state of processLiteStructuralReconcileTimers.values()) {
|
||||||
clearTimeout(state.timer);
|
clearTimeout(state.timer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,15 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext
|
||||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskFinalForCompletionNotification,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics';
|
||||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
@ -328,6 +331,7 @@ function collectTeamScopedStateRemovals(
|
||||||
| 'provisioningStartedAtFloorByTeam'
|
| 'provisioningStartedAtFloorByTeam'
|
||||||
| 'leadActivityByTeam'
|
| 'leadActivityByTeam'
|
||||||
| 'leadContextByTeam'
|
| 'leadContextByTeam'
|
||||||
|
| 'activeTaskLogActivityByTeam'
|
||||||
| 'activeToolsByTeam'
|
| 'activeToolsByTeam'
|
||||||
| 'finishedVisibleByTeam'
|
| 'finishedVisibleByTeam'
|
||||||
| 'toolHistoryByTeam'
|
| 'toolHistoryByTeam'
|
||||||
|
|
@ -353,6 +357,7 @@ function collectTeamScopedStateRemovals(
|
||||||
);
|
);
|
||||||
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
|
const nextLeadActivity = omitTeamKey(state.leadActivityByTeam, teamName);
|
||||||
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
|
const nextLeadContext = omitTeamKey(state.leadContextByTeam, teamName);
|
||||||
|
const nextActiveTaskLogActivity = omitTeamKey(state.activeTaskLogActivityByTeam, teamName);
|
||||||
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
|
const nextActiveTools = omitTeamKey(state.activeToolsByTeam, teamName);
|
||||||
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
|
const nextFinishedVisible = omitTeamKey(state.finishedVisibleByTeam, teamName);
|
||||||
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
|
const nextToolHistory = omitTeamKey(state.toolHistoryByTeam, teamName);
|
||||||
|
|
@ -378,6 +383,9 @@ function collectTeamScopedStateRemovals(
|
||||||
: {}),
|
: {}),
|
||||||
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
|
...(nextLeadActivity ? { leadActivityByTeam: nextLeadActivity } : {}),
|
||||||
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
|
...(nextLeadContext ? { leadContextByTeam: nextLeadContext } : {}),
|
||||||
|
...(nextActiveTaskLogActivity
|
||||||
|
? { activeTaskLogActivityByTeam: nextActiveTaskLogActivity }
|
||||||
|
: {}),
|
||||||
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
|
...(nextActiveTools ? { activeToolsByTeam: nextActiveTools } : {}),
|
||||||
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
|
...(nextFinishedVisible ? { finishedVisibleByTeam: nextFinishedVisible } : {}),
|
||||||
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
|
...(nextToolHistory ? { toolHistoryByTeam: nextToolHistory } : {}),
|
||||||
|
|
@ -1382,11 +1390,12 @@ function detectStatusChangeNotifications(
|
||||||
if (!oldTask) continue;
|
if (!oldTask) continue;
|
||||||
|
|
||||||
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
|
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
|
||||||
const taskKanbanColumn = getTaskKanbanColumn(task);
|
const taskKanbanColumn = getTeamTaskWorkflowColumn(task);
|
||||||
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
|
const oldTaskKanbanColumn = getTeamTaskWorkflowColumn(oldTask);
|
||||||
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
|
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
|
||||||
const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
|
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;
|
const statusChanged = oldTask.status !== task.status;
|
||||||
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
|
if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
|
||||||
|
|
@ -1681,7 +1690,7 @@ function detectAllTasksCompletedNotification(
|
||||||
|
|
||||||
for (const [teamName, tasks] of teamTasks) {
|
for (const [teamName, tasks] of teamTasks) {
|
||||||
if (tasks.length === 0) continue;
|
if (tasks.length === 0) continue;
|
||||||
const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted');
|
const allCompleted = tasks.every(isTeamTaskFinalForCompletionNotification);
|
||||||
if (!allCompleted) {
|
if (!allCompleted) {
|
||||||
// Reset so we can notify again if tasks become all-completed later
|
// Reset so we can notify again if tasks become all-completed later
|
||||||
notifiedAllCompletedTeams.delete(teamName);
|
notifiedAllCompletedTeams.delete(teamName);
|
||||||
|
|
@ -1692,8 +1701,7 @@ function detectAllTasksCompletedNotification(
|
||||||
// Check that at least one task was NOT completed before (real transition)
|
// Check that at least one task was NOT completed before (real transition)
|
||||||
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
|
const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
|
||||||
const wasAlreadyAllCompleted =
|
const wasAlreadyAllCompleted =
|
||||||
oldTeamTasks.length > 0 &&
|
oldTeamTasks.length > 0 && oldTeamTasks.every(isTeamTaskFinalForCompletionNotification);
|
||||||
oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted');
|
|
||||||
if (wasAlreadyAllCompleted) {
|
if (wasAlreadyAllCompleted) {
|
||||||
notifiedAllCompletedTeams.add(teamName);
|
notifiedAllCompletedTeams.add(teamName);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -2385,6 +2393,7 @@ export interface TeamSlice {
|
||||||
provisioningStartedAtFloorByTeam: Record<string, string>;
|
provisioningStartedAtFloorByTeam: Record<string, string>;
|
||||||
leadActivityByTeam: Record<string, LeadActivityState>;
|
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||||
leadContextByTeam: Record<string, LeadContextUsage>;
|
leadContextByTeam: Record<string, LeadContextUsage>;
|
||||||
|
activeTaskLogActivityByTeam: Record<string, Record<string, true>>;
|
||||||
activeToolsByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
|
activeToolsByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
|
||||||
finishedVisibleByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
|
finishedVisibleByTeam: Record<string, Record<string, Record<string, ActiveToolCall>>>;
|
||||||
toolHistoryByTeam: Record<string, Record<string, ActiveToolCall[]>>;
|
toolHistoryByTeam: Record<string, Record<string, ActiveToolCall[]>>;
|
||||||
|
|
@ -2727,6 +2736,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
provisioningStartedAtFloorByTeam: {},
|
provisioningStartedAtFloorByTeam: {},
|
||||||
leadActivityByTeam: {},
|
leadActivityByTeam: {},
|
||||||
leadContextByTeam: {},
|
leadContextByTeam: {},
|
||||||
|
activeTaskLogActivityByTeam: {},
|
||||||
activeToolsByTeam: {},
|
activeToolsByTeam: {},
|
||||||
finishedVisibleByTeam: {},
|
finishedVisibleByTeam: {},
|
||||||
toolHistoryByTeam: {},
|
toolHistoryByTeam: {},
|
||||||
|
|
@ -3038,13 +3048,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||||
if (task.reviewState === 'needsFix') {
|
if (isTeamTaskNeedsFixActionable(task)) {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:needsFix`);
|
||||||
}
|
}
|
||||||
if (getTaskKanbanColumn(task) === 'approved') {
|
if (getTeamTaskWorkflowColumn(task) === 'approved') {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||||
}
|
}
|
||||||
if (getTaskKanbanColumn(task) === 'review') {
|
if (getTeamTaskWorkflowColumn(task) === 'review') {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
|
||||||
}
|
}
|
||||||
// Seed comment keys to prevent false notifications
|
// Seed comment keys to prevent false notifications
|
||||||
|
|
@ -3062,7 +3072,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
teamTasksMap.set(task.teamName, list);
|
teamTasksMap.set(task.teamName, list);
|
||||||
}
|
}
|
||||||
for (const [teamName, teamTasks] of teamTasksMap) {
|
for (const [teamName, teamTasks] of teamTasksMap) {
|
||||||
if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
|
if (teamTasks.every(isTeamTaskFinalForCompletionNotification)) {
|
||||||
notifiedAllCompletedTeams.add(teamName);
|
notifiedAllCompletedTeams.add(teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import {
|
||||||
getTeamModelLabel,
|
getTeamModelLabel,
|
||||||
getTeamProviderLabel,
|
getTeamProviderLabel,
|
||||||
} from '@renderer/utils/teamModelCatalog';
|
} from '@renderer/utils/teamModelCatalog';
|
||||||
|
import {
|
||||||
|
isNativeAppManagedBootstrapCheckText,
|
||||||
|
isTeamInternalControlMessageEnvelope,
|
||||||
|
} from '@shared/utils/teamInternalControlMessages';
|
||||||
|
|
||||||
import type { InboxMessage, TeamProviderId } from '@shared/types';
|
import type { InboxMessage, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -125,6 +129,29 @@ export interface BootstrapAcknowledgementDisplay {
|
||||||
body: string;
|
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(
|
export function getBootstrapPromptDisplay(
|
||||||
message: Pick<InboxMessage, 'text' | 'to'>
|
message: Pick<InboxMessage, 'text' | 'to'>
|
||||||
): BootstrapPromptDisplay | null {
|
): 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 (
|
return (
|
||||||
|
getInternalControlMessageDisplay(message)?.body ??
|
||||||
getBootstrapPromptDisplay(message)?.body ??
|
getBootstrapPromptDisplay(message)?.body ??
|
||||||
getBootstrapAcknowledgementDisplay(message as Pick<InboxMessage, 'text' | 'from'>)?.body ??
|
getBootstrapAcknowledgementDisplay(message as Pick<InboxMessage, 'text' | 'from'>)?.body ??
|
||||||
message.text ??
|
message.text ??
|
||||||
|
|
@ -219,9 +249,11 @@ export function getSanitizedInboxMessageText(message: Pick<InboxMessage, 'text'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSanitizedInboxMessageSummary(
|
export function getSanitizedInboxMessageSummary(
|
||||||
message: Pick<InboxMessage, 'text' | 'to' | 'from' | 'summary'>
|
message: Pick<InboxMessage, 'text' | 'to' | 'from' | 'summary'> &
|
||||||
|
Partial<Pick<InboxMessage, 'source'>>
|
||||||
): string {
|
): string {
|
||||||
return (
|
return (
|
||||||
|
getInternalControlMessageDisplay(message)?.summary ??
|
||||||
getBootstrapPromptDisplay(message)?.summary ??
|
getBootstrapPromptDisplay(message)?.summary ??
|
||||||
getBootstrapAcknowledgementDisplay(message)?.summary ??
|
getBootstrapAcknowledgementDisplay(message)?.summary ??
|
||||||
message.summary ??
|
message.summary ??
|
||||||
|
|
|
||||||
|
|
@ -321,10 +321,59 @@ function getRuntimeAdvisoryProviderLabel(providerId: TeamProviderId | undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendRuntimeAdvisoryRawMessage(base: string, message: string | undefined): string {
|
function appendRuntimeAdvisoryRawMessage(base: string, message: string | undefined): string {
|
||||||
const trimmed = message?.trim();
|
const trimmed = formatRuntimeAdvisoryDisplayMessage(message);
|
||||||
return trimmed ? `${base}\n\n${trimmed}` : base;
|
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(
|
function formatRuntimeAdvisoryBaseLabel(
|
||||||
advisory: MemberRuntimeAdvisory,
|
advisory: MemberRuntimeAdvisory,
|
||||||
providerId: TeamProviderId | undefined
|
providerId: TeamProviderId | undefined
|
||||||
|
|
@ -344,8 +393,16 @@ function formatRuntimeAdvisoryBaseLabel(
|
||||||
return 'Network error';
|
return 'Network error';
|
||||||
case 'provider_overloaded':
|
case 'provider_overloaded':
|
||||||
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
|
return providerLabel ? `${providerLabel} overload` : 'Provider overload';
|
||||||
|
case 'protocol_proof_missing':
|
||||||
|
return providerId === 'opencode' ? 'OpenCode proof missing' : 'Protocol proof missing';
|
||||||
case 'backend_error':
|
case 'backend_error':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
|
if (
|
||||||
|
providerId === 'opencode' &&
|
||||||
|
isOpenCodeRuntimeDeliveryAdvisoryMessage(advisory.message)
|
||||||
|
) {
|
||||||
|
return 'OpenCode delivery error';
|
||||||
|
}
|
||||||
return providerLabel ? `${providerLabel} API error` : 'API error';
|
return providerLabel ? `${providerLabel} API error` : 'API error';
|
||||||
default:
|
default:
|
||||||
return 'API error';
|
return 'API error';
|
||||||
|
|
@ -365,6 +422,8 @@ function formatRuntimeAdvisoryBaseLabel(
|
||||||
return 'Network retry';
|
return 'Network retry';
|
||||||
case 'provider_overloaded':
|
case 'provider_overloaded':
|
||||||
return providerLabel ? `${providerLabel} overload retry` : 'Provider overload retry';
|
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 'backend_error':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
return 'Provider retry';
|
return 'Provider retry';
|
||||||
|
|
@ -407,8 +466,24 @@ function formatRuntimeAdvisoryTitle(
|
||||||
'Provider is temporarily overloaded.',
|
'Provider is temporarily overloaded.',
|
||||||
advisory.message
|
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 'backend_error':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
|
if (
|
||||||
|
providerId === 'opencode' &&
|
||||||
|
isOpenCodeRuntimeDeliveryAdvisoryMessage(advisory.message)
|
||||||
|
) {
|
||||||
|
return appendRuntimeAdvisoryRawMessage(
|
||||||
|
'OpenCode runtime delivery error.',
|
||||||
|
advisory.message
|
||||||
|
);
|
||||||
|
}
|
||||||
return appendRuntimeAdvisoryRawMessage(
|
return appendRuntimeAdvisoryRawMessage(
|
||||||
`${providerLabel ?? 'Provider'} API error.`,
|
`${providerLabel ?? 'Provider'} API error.`,
|
||||||
advisory.message
|
advisory.message
|
||||||
|
|
@ -449,6 +524,13 @@ function formatRuntimeAdvisoryTitle(
|
||||||
'Provider is temporarily overloaded. SDK is retrying automatically.',
|
'Provider is temporarily overloaded. SDK is retrying automatically.',
|
||||||
advisory.message
|
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 'backend_error':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
return appendRuntimeAdvisoryRawMessage(
|
return appendRuntimeAdvisoryRawMessage(
|
||||||
|
|
@ -505,6 +587,9 @@ export function getMemberRuntimeAdvisoryTone(
|
||||||
if (!advisory) {
|
if (!advisory) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (advisory.reasonCode === 'protocol_proof_missing') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
return advisory.kind === 'api_error' ? 'error' : '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') {
|
if (normalized === 'prompt_delivered_no_assistant_message') {
|
||||||
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
|
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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
import {
|
||||||
|
getTeamTaskWorkflowColumn,
|
||||||
|
isTeamTaskDeleted,
|
||||||
|
isTeamTaskFinishedForDependency,
|
||||||
|
isTeamTaskNeedsFixActionable,
|
||||||
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import type { GlobalTask } from '@shared/types';
|
import type { GlobalTask } from '@shared/types';
|
||||||
|
|
||||||
export function normalizePath(p: string): string {
|
export function normalizePath(p: string): string {
|
||||||
|
|
@ -15,10 +22,29 @@ export interface TaskStatusCounts {
|
||||||
completed: number;
|
completed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function incrementStatus(counts: TaskStatusCounts, status: string): TaskStatusCounts {
|
function incrementTaskStatus(
|
||||||
if (status === 'pending') return { ...counts, pending: counts.pending + 1 };
|
counts: TaskStatusCounts,
|
||||||
if (status === 'in_progress') return { ...counts, inProgress: counts.inProgress + 1 };
|
task: {
|
||||||
if (status === 'completed') return { ...counts, completed: counts.completed + 1 };
|
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;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +55,7 @@ export function buildTaskCountsByProject(tasks: GlobalTask[]): Map<string, TaskS
|
||||||
if (!task.projectPath) continue;
|
if (!task.projectPath) continue;
|
||||||
const key = normalizePath(task.projectPath);
|
const key = normalizePath(task.projectPath);
|
||||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
@ -40,22 +66,29 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map<string, TaskStat
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const key = task.teamName;
|
const key = task.teamName;
|
||||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
|
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
|
||||||
export function buildTaskCountsByOwner(
|
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> {
|
): Map<string, TaskStatusCounts> {
|
||||||
const map = new Map<string, TaskStatusCounts>();
|
const map = new Map<string, TaskStatusCounts>();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const owner = task.owner?.trim();
|
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 key = owner.toLowerCase();
|
||||||
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
|
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;
|
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';
|
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||||
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
|
import { shouldKeepIdleMessageInActivityWhenNoiseHidden } from '@renderer/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
|
import { isTaskStallRemediationMessage } from '@shared/utils/teamAutomationMessages';
|
||||||
|
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -110,6 +112,7 @@ export function filterTeamMessages(
|
||||||
messages: InboxMessage[],
|
messages: InboxMessage[],
|
||||||
options: {
|
options: {
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
|
includePassiveIdlePeerSummariesWhenNoiseHidden?: boolean;
|
||||||
|
includeAutomationEvents?: boolean;
|
||||||
leadNames?: Iterable<string>;
|
leadNames?: Iterable<string>;
|
||||||
timeWindow?: { start: number; end: number } | null;
|
timeWindow?: { start: number; end: number } | null;
|
||||||
filter: TeamMessagesFilter;
|
filter: TeamMessagesFilter;
|
||||||
|
|
@ -118,6 +121,7 @@ export function filterTeamMessages(
|
||||||
): InboxMessage[] {
|
): InboxMessage[] {
|
||||||
const {
|
const {
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
|
includePassiveIdlePeerSummariesWhenNoiseHidden = false,
|
||||||
|
includeAutomationEvents = false,
|
||||||
leadNames: rawLeadNames,
|
leadNames: rawLeadNames,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter,
|
filter,
|
||||||
|
|
@ -125,7 +129,12 @@ export function filterTeamMessages(
|
||||||
} = options;
|
} = options;
|
||||||
const leadNames = normalizeLeadNames(rawLeadNames);
|
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) {
|
if (timeWindow) {
|
||||||
list = list.filter((m) => {
|
list = list.filter((m) => {
|
||||||
const ts = new Date(m.timestamp).getTime();
|
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;
|
to: TeamTaskStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskOwnerChangedEvent extends TaskHistoryEventBase {
|
||||||
|
type: 'owner_changed';
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskReviewRequestedEvent extends TaskHistoryEventBase {
|
export interface TaskReviewRequestedEvent extends TaskHistoryEventBase {
|
||||||
type: 'review_requested';
|
type: 'review_requested';
|
||||||
from: TeamReviewState;
|
from: TeamReviewState;
|
||||||
|
|
@ -157,6 +163,7 @@ export interface TaskReviewStartedEvent extends TaskHistoryEventBase {
|
||||||
export type TaskHistoryEvent =
|
export type TaskHistoryEvent =
|
||||||
| TaskCreatedEvent
|
| TaskCreatedEvent
|
||||||
| TaskStatusChangedEvent
|
| TaskStatusChangedEvent
|
||||||
|
| TaskOwnerChangedEvent
|
||||||
| TaskReviewRequestedEvent
|
| TaskReviewRequestedEvent
|
||||||
| TaskReviewChangesRequestedEvent
|
| TaskReviewChangesRequestedEvent
|
||||||
| TaskReviewApprovedEvent
|
| TaskReviewApprovedEvent
|
||||||
|
|
@ -427,6 +434,7 @@ export type InboxMessageKind =
|
||||||
| 'slash_command'
|
| 'slash_command'
|
||||||
| 'slash_command_result'
|
| 'slash_command_result'
|
||||||
| 'task_comment_notification'
|
| 'task_comment_notification'
|
||||||
|
| 'task_stall_remediation'
|
||||||
| 'member_work_sync_nudge'
|
| 'member_work_sync_nudge'
|
||||||
| 'agent_error';
|
| 'agent_error';
|
||||||
|
|
||||||
|
|
@ -821,6 +829,7 @@ export interface MemberRuntimeAdvisory {
|
||||||
| 'codex_native_timeout'
|
| 'codex_native_timeout'
|
||||||
| 'network_error'
|
| 'network_error'
|
||||||
| 'provider_overloaded'
|
| 'provider_overloaded'
|
||||||
|
| 'protocol_proof_missing'
|
||||||
| 'backend_error'
|
| 'backend_error'
|
||||||
| 'unknown';
|
| 'unknown';
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|
@ -1005,6 +1014,27 @@ export interface PersistedTeamLaunchMemberSources {
|
||||||
duplicateRespawnBlocked?: boolean;
|
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 {
|
export interface PersistedTeamLaunchMemberState {
|
||||||
name: string;
|
name: string;
|
||||||
providerId?: TeamProviderId;
|
providerId?: TeamProviderId;
|
||||||
|
|
@ -1032,6 +1062,9 @@ export interface PersistedTeamLaunchMemberState {
|
||||||
/** OpenCode runtime run id that produced the current runtimeSessionId/liveness evidence. */
|
/** OpenCode runtime run id that produced the current runtimeSessionId/liveness evidence. */
|
||||||
runtimeRunId?: string;
|
runtimeRunId?: string;
|
||||||
runtimeSessionId?: string;
|
runtimeSessionId?: string;
|
||||||
|
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
|
||||||
|
bootstrapMode?: OpenCodeBootstrapMode;
|
||||||
|
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
pidSource?: TeamAgentRuntimePidSource;
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
runtimeDiagnostic?: string;
|
runtimeDiagnostic?: string;
|
||||||
|
|
@ -1170,12 +1203,15 @@ export interface TeamChangeEvent {
|
||||||
| 'lead-message'
|
| 'lead-message'
|
||||||
| 'tool-activity'
|
| 'tool-activity'
|
||||||
| 'member-turn-settled'
|
| 'member-turn-settled'
|
||||||
|
| 'member-advisory'
|
||||||
| 'process'
|
| 'process'
|
||||||
| 'member-spawn';
|
| 'member-spawn';
|
||||||
teamName: string;
|
teamName: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
|
/** Distinguishes real task log freshness from task-change presence freshness. */
|
||||||
|
taskSignalKind?: 'log' | 'change';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectBranchChangeEvent {
|
export interface ProjectBranchChangeEvent {
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,8 @@ export function normalizeReviewState(value: unknown): TeamReviewState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReviewStateFromTask(task: ReviewStateLike): 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 fallbackStatus = typeof task.status === 'string' ? task.status : null;
|
||||||
const normalizeFallback = (value: unknown): TeamReviewState | null => {
|
const normalizeForStatus = (value: unknown): TeamReviewState | null => {
|
||||||
const explicit = normalizeReviewState(value);
|
const explicit = normalizeReviewState(value);
|
||||||
if (explicit === 'none') return null;
|
if (explicit === 'none') return null;
|
||||||
|
|
||||||
|
|
@ -36,16 +26,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
|
||||||
return explicit === 'needsFix' ? 'needsFix' : 'none';
|
return explicit === 'needsFix' ? 'needsFix' : 'none';
|
||||||
}
|
}
|
||||||
if (fallbackStatus === 'completed') {
|
if (fallbackStatus === 'completed') {
|
||||||
return explicit === 'review' || explicit === 'approved' ? explicit : 'none';
|
return explicit === 'review' || explicit === 'approved' || explicit === 'needsFix'
|
||||||
|
? explicit
|
||||||
|
: 'none';
|
||||||
}
|
}
|
||||||
return explicit;
|
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 (explicit) return explicit;
|
||||||
|
|
||||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
||||||
return normalizeFallback(task.kanbanColumn) ?? 'none';
|
return normalizeForStatus(task.kanbanColumn) ?? 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'none';
|
return 'none';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getReviewStateFromTask } from './reviewState';
|
import { getReviewStateFromTask } from './reviewState';
|
||||||
|
import { getTeamTaskWorkflowColumn } from './teamTaskState';
|
||||||
|
|
||||||
import type { TeamReviewState } from '@shared/types';
|
import type { TeamReviewState } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ interface TaskChangeStateLike {
|
||||||
reviewState?: TeamReviewState | null;
|
reviewState?: TeamReviewState | null;
|
||||||
historyEvents?: unknown[];
|
historyEvents?: unknown[];
|
||||||
kanbanColumn?: 'review' | 'approved' | null;
|
kanbanColumn?: 'review' | 'approved' | null;
|
||||||
|
deletedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
||||||
|
|
@ -17,8 +19,15 @@ function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
||||||
|
|
||||||
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
|
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
|
||||||
const reviewState = getEffectiveReviewState(task);
|
const reviewState = getEffectiveReviewState(task);
|
||||||
if (reviewState === 'approved') return 'approved';
|
const workflowColumn = getTeamTaskWorkflowColumn({
|
||||||
if (reviewState === 'review') return 'review';
|
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';
|
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;
|
completeTask(taskId: string, actor?: string): unknown;
|
||||||
softDeleteTask(taskId: string, actor?: string): unknown;
|
softDeleteTask(taskId: string, actor?: string): unknown;
|
||||||
restoreTask(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;
|
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
|
||||||
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
||||||
attachTaskFile(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;
|
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||||
memberBriefing(
|
memberBriefing(
|
||||||
memberName: string,
|
memberName: string,
|
||||||
options?: { runtimeProvider?: 'native' | 'opencode' }
|
options?: { runtimeProvider?: 'native' | 'opencode'; includeActiveProcesses?: boolean }
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
leadBriefing(): Promise<string>;
|
leadBriefing(): Promise<string>;
|
||||||
taskBriefing(memberName: string): 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', () => {
|
it('assigns active review work to the current-cycle reviewer only', () => {
|
||||||
const agenda = buildActionableWorkAgenda({
|
const agenda = buildActionableWorkAgenda({
|
||||||
teamName: 'team-a',
|
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', () => {
|
it('does not resurrect a stale reviewer after review was approved', () => {
|
||||||
const agenda = buildActionableWorkAgenda({
|
const agenda = buildActionableWorkAgenda({
|
||||||
teamName: 'team-a',
|
teamName: 'team-a',
|
||||||
|
|
@ -257,12 +520,20 @@ describe('buildActionableWorkAgenda', () => {
|
||||||
owner: 'bob',
|
owner: 'bob',
|
||||||
reviewState: 'needsFix',
|
reviewState: 'needsFix',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
subject: 'Fix completed review',
|
||||||
|
status: 'completed',
|
||||||
|
owner: 'bob',
|
||||||
|
reviewState: 'needsFix',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
hash,
|
hash,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
|
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
|
||||||
['task-1', 'work', 'review_changes_requested'],
|
['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