From 364c5c6471ff61eda1cb7582d88659d7f16945ca Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 5 May 2026 15:41:47 +0300 Subject: [PATCH 1/7] perf(team): gate hidden team screen effects --- .../components/layout/PaneContent.tsx | 6 ++- .../components/team/TeamDetailView.tsx | 49 ++++++++++++------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index e65e0a9e..b4d96187 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -52,7 +52,11 @@ export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JS {tab.type === 'teams' && } {tab.type === 'team' && ( - + )} {tab.type === 'session' && ( diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 2c04dd15..4804eb03 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -157,6 +157,7 @@ import type { ContextUsageLike } from '@shared/utils/contextMetrics'; interface TeamDetailViewProps { teamName: string; + isActive?: boolean; isPaneFocused?: boolean; } @@ -377,10 +378,12 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ teamName, isTeamProvisioning, isTeamAlive, + isThisTabActive, }: { teamName: string; isTeamProvisioning: boolean; isTeamAlive?: boolean; + isThisTabActive: boolean; }): null { const { leadActivity, memberSpawnStatuses, memberSpawnSnapshot, fetchMemberSpawnStatuses } = useStore( @@ -393,6 +396,8 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ ); useEffect(() => { + if (!isThisTabActive) return; + const hasUnresolvedSpawn = hasUnresolvedMemberSpawnStatus( memberSpawnStatuses, memberSpawnSnapshot @@ -420,6 +425,7 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ fetchMemberSpawnStatuses, isTeamAlive, isTeamProvisioning, + isThisTabActive, leadActivity, memberSpawnSnapshot, memberSpawnStatuses, @@ -888,6 +894,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( export const TeamDetailView = memo(function TeamDetailView({ teamName, + isActive = true, isPaneFocused = false, }: TeamDetailViewProps): React.JSX.Element { const { isLight } = useTheme(); @@ -1279,8 +1286,7 @@ export const TeamDetailView = memo(function TeamDetailView({ ); const tabId = useTabIdOptional(); - const activeTabId = useStore((s) => s.activeTabId); - const isThisTabActive = tabId ? activeTabId === tabId : false; + const isThisTabActive = isActive; const wasInteractiveRef = useRef(false); // Messages panel resize @@ -1324,33 +1330,32 @@ export const TeamDetailView = memo(function TeamDetailView({ useEffect(() => { const wasProvisioning = wasProvisioningRef.current; wasProvisioningRef.current = isTeamProvisioning; + if (!isThisTabActive) return; if (!wasProvisioning && isTeamProvisioning) { provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } - }, [isTeamProvisioning]); + }, [isTeamProvisioning, isThisTabActive]); const [kanbanSearch, setKanbanSearch] = useState(''); // Open editor overlay when a file reveal is requested (e.g. from chip click) const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); useEffect(() => { + if (!isThisTabActive) return; if (pendingRevealFile && data?.config.projectPath) { setEditorOpen(true); } - }, [pendingRevealFile, data?.config.projectPath]); + }, [isThisTabActive, pendingRevealFile, data?.config.projectPath]); useEffect(() => { - if (!teamName) { + if (!isThisTabActive || !teamName) { return; } void selectTeam(teamName); void fetchDeletedTasks(teamName); - }, [teamName, selectTeam, fetchDeletedTasks]); + }, [isThisTabActive, teamName, selectTeam, fetchDeletedTasks]); - // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. - // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins - // and other tabs get stuck with mismatched data (permanent skeleton). - // Re-trigger selectTeam when this tab becomes active and store data is stale. + // Re-trigger selectTeam when this visible tab becomes active and store data is stale. const storedTeamName = data?.teamName; useEffect(() => { if (!isThisTabActive || !teamName || loading) return; @@ -1387,7 +1392,7 @@ export const TeamDetailView = memo(function TeamDetailView({ // Fetch active teams when launch dialog opens (for conflict warning) useEffect(() => { - if (!launchDialogOpen) return; + if (!isThisTabActive || !launchDialogOpen) return; let cancelled = false; const teamsSnapshot = useStore.getState().teams; void (async () => { @@ -1410,7 +1415,7 @@ export const TeamDetailView = memo(function TeamDetailView({ return () => { cancelled = true; }; - }, [launchDialogOpen]); + }, [isThisTabActive, launchDialogOpen]); useEffect(() => { if (kanbanFilterQuery) { @@ -1456,7 +1461,7 @@ export const TeamDetailView = memo(function TeamDetailView({ ]); useEffect(() => { - if (!projectId) return; + if (!isThisTabActive || !projectId) return; let cancelled = false; setSessionsLoading(true); @@ -1485,7 +1490,7 @@ export const TeamDetailView = memo(function TeamDetailView({ return () => { cancelled = true; }; - }, [data?.config.leadSessionId, projectId, sessionHistoryKey]); + }, [data?.config.leadSessionId, isThisTabActive, projectId, sessionHistoryKey]); // Live git branch tracking for the lead project and member worktrees const teamProjectPath = data?.config.projectPath?.trim() ?? null; @@ -1510,7 +1515,11 @@ export const TeamDetailView = memo(function TeamDetailView({ return Array.from(uniquePaths.values()); }, [members, leadProjectPath]); - useBranchSync(branchSyncPaths, { live: true }); + const activeBranchSyncPaths = useMemo( + () => (isThisTabActive ? branchSyncPaths : []), + [branchSyncPaths, isThisTabActive] + ); + useBranchSync(activeBranchSyncPaths, { live: isThisTabActive }); const trackedBranches = useStore( useShallow((s) => Object.fromEntries( @@ -1830,6 +1839,7 @@ export const TeamDetailView = memo(function TeamDetailView({ // Pick up pending review request from GlobalTaskDetailDialog useEffect(() => { + if (!isThisTabActive) return; if (!pendingReviewRequest) return; setReviewDialogState({ open: true, @@ -1842,11 +1852,12 @@ export const TeamDetailView = memo(function TeamDetailView({ selectReviewFile(pendingReviewRequest.filePath); } setPendingReviewRequest(null); - }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); + }, [isThisTabActive, pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); const pendingTeamSectionFocus = useStore((s) => s.pendingTeamSectionFocus); const clearTeamSectionFocus = useStore((s) => s.clearTeamSectionFocus); useEffect(() => { + if (!isThisTabActive) return; if (pendingTeamSectionFocus?.teamName !== teamName) return; const sectionId = @@ -1868,11 +1879,12 @@ export const TeamDetailView = memo(function TeamDetailView({ if (!section) return; section.dispatchEvent(new CustomEvent('team-section-navigate')); clearTeamSectionFocus(); - }, [pendingTeamSectionFocus, clearTeamSectionFocus, teamName, data]); + }, [pendingTeamSectionFocus, clearTeamSectionFocus, isThisTabActive, teamName, data]); // Pick up pending member profile request from MemberHoverCard const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); useEffect(() => { + if (!isThisTabActive) return; if (!pendingMemberProfile || !data) return; if (pendingMemberProfile.teamName && pendingMemberProfile.teamName !== teamName) return; @@ -1889,7 +1901,7 @@ export const TeamDetailView = memo(function TeamDetailView({ }); } useStore.getState().closeMemberProfile(); - }, [pendingMemberProfile, membersWithLiveBranches, teamName, data]); + }, [isThisTabActive, pendingMemberProfile, membersWithLiveBranches, teamName, data]); const handleDeleteTask = useCallback( (taskId: string) => { @@ -2058,6 +2070,7 @@ export const TeamDetailView = memo(function TeamDetailView({ teamName={teamName} isTeamProvisioning={isTeamProvisioning} isTeamAlive={data?.isAlive} + isThisTabActive={isThisTabActive} /> ); const teamAgentRuntimeWatcher = ( From 67826eb2750124821f3a48786764027a80e8d4e5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 5 May 2026 16:25:54 +0300 Subject: [PATCH 2/7] perf(team): pause hidden graph projection --- .../renderer/hooks/useTeamGraphAdapter.ts | 142 ++++++++++++------ .../agent-graph/renderer/ui/TeamGraphTab.tsx | 2 +- 2 files changed, 98 insertions(+), 46 deletions(-) diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 963934f3..f3ddcb70 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -21,9 +21,38 @@ import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; import type { GraphDataPort } from '@claude-teams/agent-graph'; +import type { InboxMessage, ResolvedTeamMember, ToolApprovalRequest } from '@shared/types/team'; -export function useTeamGraphAdapter(teamName: string): GraphDataPort { +interface UseTeamGraphAdapterOptions { + active?: boolean; +} + +const EMPTY_MEMBERS: ResolvedTeamMember[] = []; +const EMPTY_MESSAGES: InboxMessage[] = []; +const EMPTY_PENDING_APPROVALS: ToolApprovalRequest[] = []; +const EMPTY_PENDING_APPROVAL_AGENTS = new Set(); +const EMPTY_COMMENT_READ_STATE: Record = {}; + +function getEmptyCommentReadState(): Record { + return EMPTY_COMMENT_READ_STATE; +} + +function subscribeNoop(): () => void { + return () => undefined; +} + +function emptyGraphData(teamName: string): GraphDataPort { + return { nodes: [], edges: [], particles: [], teamName, isAlive: false }; +} + +export function useTeamGraphAdapter( + teamName: string, + options?: UseTeamGraphAdapterOptions +): GraphDataPort { + const isActive = options?.active ?? true; const adapterRef = useRef(TeamGraphAdapter.create()); + const inactiveGraphData = useMemo(() => emptyGraphData(teamName), [teamName]); + const lastActiveGraphDataRef = useRef(inactiveGraphData); const { teamSnapshot, @@ -45,27 +74,32 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ - teamSnapshot: selectTeamDataForName(s, teamName), - members: selectResolvedMembersForTeamName(s, teamName), - messages: selectTeamMessages(s, teamName), - spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, - leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined, - leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, - pendingApprovals: s.pendingApprovals, - activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, - finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined, - toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, - provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, - memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, - graphLayoutMode: teamName ? s.graphLayoutModeByTeam[teamName] : undefined, - gridOwnerOrder: teamName ? s.gridOwnerOrderByTeam[teamName] : undefined, - slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined, - graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined, + teamSnapshot: isActive ? selectTeamDataForName(s, teamName) : null, + members: isActive ? selectResolvedMembersForTeamName(s, teamName) : EMPTY_MEMBERS, + messages: isActive ? selectTeamMessages(s, teamName) : EMPTY_MESSAGES, + spawnStatuses: isActive && teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, + leadActivity: isActive && teamName ? s.leadActivityByTeam[teamName] : undefined, + leadContext: isActive && teamName ? s.leadContextByTeam[teamName] : undefined, + pendingApprovals: isActive ? s.pendingApprovals : EMPTY_PENDING_APPROVALS, + activeTools: isActive && teamName ? s.activeToolsByTeam[teamName] : undefined, + finishedVisible: isActive && teamName ? s.finishedVisibleByTeam[teamName] : undefined, + toolHistory: isActive && teamName ? s.toolHistoryByTeam[teamName] : undefined, + provisioningProgress: + isActive && teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, + memberSpawnSnapshot: + isActive && teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, + graphLayoutMode: isActive && teamName ? s.graphLayoutModeByTeam[teamName] : undefined, + gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined, + slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined, + graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined, ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments, })) ); const pendingApprovalAgents = useMemo(() => { + if (!isActive) { + return EMPTY_PENDING_APPROVAL_AGENTS; + } const agents = new Set(); for (const a of pendingApprovals) { if (a.teamName === teamName) { @@ -73,7 +107,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { } } return agents; - }, [pendingApprovals, teamName]); + }, [isActive, pendingApprovals, teamName]); const teamData = useMemo(() => { if (!teamSnapshot) { @@ -86,7 +120,10 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { }; }, [members, messages, teamSnapshot]); - const commentReadState = useSyncExternalStore(subscribe, getSnapshot); + const commentReadState = useSyncExternalStore( + isActive ? subscribe : subscribeNoop, + isActive ? getSnapshot : getEmptyCommentReadState + ); const effectiveSlotAssignments = useMemo(() => { if (!teamData) { @@ -127,32 +164,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { }, [graphLayoutSession, slotAssignments, teamData]); useLayoutEffect(() => { - if (!teamName || !teamData) { + if (!isActive || !teamName || !teamData) { return; } ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []); - }, [ensureTeamGraphSlotAssignments, teamData, teamName]); + }, [ensureTeamGraphSlotAssignments, isActive, teamData, teamName]); - return useMemo( - () => - adapterRef.current.adapt( - teamData, - teamName, - spawnStatuses, - leadActivity, - leadContext, - pendingApprovalAgents, - activeTools, - finishedVisible, - toolHistory, - commentReadState, - provisioningProgress, - memberSpawnSnapshot, - effectiveSlotAssignments, - graphLayoutMode ?? 'radial', - gridOwnerOrder - ), - [ + const activeGraphData = useMemo(() => { + if (!isActive) { + return null; + } + return adapterRef.current.adapt( teamData, teamName, spawnStatuses, @@ -166,8 +188,38 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { provisioningProgress, memberSpawnSnapshot, effectiveSlotAssignments, - graphLayoutMode, - gridOwnerOrder, - ] - ); + graphLayoutMode ?? 'radial', + gridOwnerOrder + ); + }, [ + isActive, + teamData, + teamName, + spawnStatuses, + leadActivity, + leadContext, + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory, + commentReadState, + provisioningProgress, + memberSpawnSnapshot, + effectiveSlotAssignments, + graphLayoutMode, + gridOwnerOrder, + ]); + + useLayoutEffect(() => { + if (activeGraphData) { + lastActiveGraphDataRef.current = activeGraphData; + } + }, [activeGraphData]); + + if (!isActive) { + const lastActiveGraphData = lastActiveGraphDataRef.current; + return lastActiveGraphData.teamName === teamName ? lastActiveGraphData : inactiveGraphData; + } + + return activeGraphData ?? inactiveGraphData; } diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 6c1050a1..14e21ec7 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -49,7 +49,7 @@ export const TeamGraphTab = ({ isActive = true, isPaneFocused = false, }: TeamGraphTabProps): React.JSX.Element => { - const graphData = useTeamGraphAdapter(teamName); + const graphData = useTeamGraphAdapter(teamName, { active: isActive }); const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } = useTeamGraphSurfaceActions(teamName); const [fullscreen, setFullscreen] = useState(false); From ab50c433836f93d5c899886565033348c476e7ef Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 5 May 2026 16:26:06 +0300 Subject: [PATCH 3/7] perf(team): limit rendered kanban cards --- .../components/team/TeamDetailView.tsx | 12 +- .../components/team/kanban/KanbanBoard.tsx | 140 +++++++++++++++++- 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 4804eb03..e6d930bf 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1635,11 +1635,12 @@ export const TeamDetailView = memo(function TeamDetailView({ const activeMembers = useStableActiveMembers(membersWithLiveBranches); + const kanbanSearchQuery = kanbanSearch.trim(); + const isKanbanSearchActive = kanbanSearchQuery.length > 0; const kanbanDisplayTasks = useMemo(() => { - const query = kanbanSearch.trim(); - if (!query) return filteredTasks; - return filterKanbanTasks(filteredTasks, query); - }, [filteredTasks, kanbanSearch]); + if (!kanbanSearchQuery) return filteredTasks; + return filterKanbanTasks(filteredTasks, kanbanSearchQuery); + }, [filteredTasks, kanbanSearchQuery]); const activeTeammateCount = useMemo( () => activeMembers.filter((m) => !isLeadMember(m)).length, @@ -2498,7 +2499,7 @@ export const TeamDetailView = memo(function TeamDetailView({ icon={} badge={filteredTasks.length} defaultOpen - forceOpen={kanbanSearch.trim().length > 0} + forceOpen={isKanbanSearchActive} action={ + ) : null; + if (enableTaskSorting) { - const itemIds = columnTasks.map((t) => t.id); + const itemIds = visibleTasks.map((t) => t.id); return ( <> - {columnTasks.map((task) => ( + {visibleTasks.map((task) => ( ))} + {showMoreButton} {addButton} ); } return ( <> - {columnTasks.map((task) => ( + {visibleTasks.map((task) => ( ))} + {showMoreButton} {addButton} ); }, [ enableTaskSorting, + handleScrollToTask, hasReviewers, kanbanState, memberColorMap, @@ -527,10 +642,11 @@ export const KanbanBoard = memo(function KanbanBoard({ onMoveBackToDone, onRequestChanges, onRequestReview, - onScrollToTask, onStartTask, onTaskClick, onViewChanges, + renderableColumnTasks, + revealNextTasks, taskMap, teamName, ] @@ -623,13 +739,21 @@ export const KanbanBoard = memo(function KanbanBoard({ bodyBg: accent.bodyBg, content: renderCards(column.id, columnTasks), showAddButton: columnSupportsAddButton(column.id, onAddTask), - skeletonCards: columnTasks.map((task) => ({ + skeletonCards: renderableColumnTasks(column.id, columnTasks).map((task) => ({ key: task.id, height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), })), }; }), - [visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers] + [ + visibleColumns, + groupedOrdered, + renderCards, + onAddTask, + renderableColumnTasks, + kanbanState, + hasReviewers, + ] ); const boardContent = ( From bbafedf06aebf1af4badc0b21004cf0a1511651c Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 5 May 2026 17:07:21 +0300 Subject: [PATCH 4/7] fix: stabilize OpenCode team runtime delivery --- .../services/team/TeamProvisioningService.ts | 362 ++++++++++++-- .../bridge/OpenCodeBridgeCommandContract.ts | 1 + .../delivery/OpenCodePromptDeliveryLedger.ts | 13 +- .../OpenCodePromptDeliveryWatchdog.ts | 1 + .../runtime/OpenCodeTeamRuntimeAdapter.ts | 1 + .../components/team/TeamDetailView.tsx | 48 +- .../team/messages/MessagesPanel.tsx | 450 ++++++++---------- .../components/team/messages/StatusBlock.tsx | 6 +- .../components/team/teamRuntimeDisplayRows.ts | 17 +- .../openCodeRuntimeDeliveryDiagnostics.ts | 3 + src/shared/types/team.ts | 1 + ...ProductionPromptArtifacts.safe-e2e.test.ts | 1 + .../team/OpenCodePromptDeliveryLedger.test.ts | 32 ++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 1 + .../team/TeamProvisioningService.test.ts | 266 ++++++++++- .../team/teamRuntimeDisplayRows.test.ts | 2 +- ...openCodeRuntimeDeliveryDiagnostics.test.ts | 25 + 17 files changed, 935 insertions(+), 295 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b848d0fe..6bc52e46 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -305,6 +305,9 @@ interface PersistedRuntimeMemberLike { backendType?: string; providerId?: string; cwd?: string; + bootstrapExpectedAfter?: string; + bootstrapProofToken?: string; + bootstrapRuntimeEventsPath?: string; runtimePid?: number; runtimeSessionId?: string; } @@ -338,6 +341,40 @@ interface LaunchStateWriteResult { type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text'; +const BOOTSTRAP_RUNTIME_PROOF_SOURCE = 'member_briefing_tool_success'; +const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024; + +function sanitizeRuntimeEventFilePrefix(value: string): string { + return String(value || 'default') + .replace(/[^a-zA-Z0-9]/g, '-') + .toLowerCase(); +} + +function parseRuntimeBootstrapProofDetail(detail: unknown): Record { + if (typeof detail !== 'string' || detail.trim().length === 0) { + return {}; + } + try { + const parsed = JSON.parse(detail) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function getRuntimeBootstrapProofString( + event: Record, + detail: Record, + field: 'source' | 'bootstrapProofToken' +): 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; +} + type BootstrapTranscriptOutcome = | { kind: 'success'; @@ -2658,6 +2695,14 @@ function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean { ); } +function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean { + return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.'; +} + +function isBootstrapInstructionPromptFailureReason(reason?: string): boolean { + return typeof reason === 'string' && isBootstrapInstructionPrompt(reason); +} + function isTmuxNoServerRunningError(error: unknown): boolean { const text = error instanceof Error ? error.message : String(error ?? ''); return ( @@ -2673,7 +2718,9 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean { isConfigRegistrationFailureReason(reason) || isRegisteredRuntimeMetadataFailureReason(reason) || isOpenCodeBridgeLaunchFailureReason(reason) || - isBootstrapMcpResourceReadFailureReason(reason) + isBootstrapMcpResourceReadFailureReason(reason) || + isBootstrapCheckInTimeoutFailureReason(reason) || + isBootstrapInstructionPromptFailureReason(reason) ); } @@ -6605,9 +6652,34 @@ export class TeamProvisioningService { if (state === 'empty_assistant_turn') { return 'empty_assistant_turn'; } + if (state === 'prompt_delivered_no_assistant_message') { + return 'prompt_delivered_no_assistant_message'; + } return record?.lastReason ?? 'opencode_delivery_response_pending'; } + private normalizeOpenCodeDeliveryResponseObservation( + observation?: NonNullable + ): NonNullable | undefined { + if ( + observation?.state !== 'empty_assistant_turn' || + !observation.deliveredUserMessageId || + observation.assistantMessageId || + observation.latestAssistantPreview?.trim() || + observation.toolCallNames.length > 0 || + observation.visibleMessageToolCallId || + observation.visibleReplyMessageId + ) { + return observation; + } + + return { + ...observation, + state: 'prompt_delivered_no_assistant_message', + reason: 'prompt_delivered_no_assistant_message', + }; + } + private isOpenCodeDeliveryRetryablePendingResponse(input: { ledgerRecord: OpenCodePromptDeliveryLedgerRecord; visibleReply?: OpenCodeVisibleReplyProof | null; @@ -7494,7 +7566,7 @@ export class TeamProvisioningService { lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase() ); trackedSecondaryLanePresent = liveLane != null; - liveSecondaryLaneRunId = liveLane ? trackedRunId : null; + liveSecondaryLaneRunId = liveLane?.runId?.trim() || null; const liveLaneMember = liveLane ? (liveLane.result?.members?.[canonicalMemberName] ?? liveLane.result?.members?.[liveLane.member.name]) @@ -7639,11 +7711,14 @@ export class TeamProvisioningService { runtimePid: result.runtimePid, reason: 'opencode_delivery_runtime_pid_observed', }); + const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( + result.responseObservation + ); return { delivered: result.ok, accepted: result.ok, responsePending: false, - responseState: result.responseObservation?.state, + responseState: responseObservation?.state, ...(result.ok ? {} : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), @@ -7880,9 +7955,12 @@ export class TeamProvisioningService { runtimePid: observed.runtimePid, reason: 'opencode_delivery_observe_runtime_pid_observed', }); + const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( + observed.responseObservation + ); ledgerRecord = await ledger.applyObservation({ id: ledgerRecord.id, - responseObservation: observed.responseObservation ?? { + responseObservation: responseObservation ?? { state: observed.ok ? 'not_observed' : 'reconcile_failed', deliveredUserMessageId: null, assistantMessageId: null, @@ -8004,16 +8082,19 @@ export class TeamProvisioningService { runtimePid: result.runtimePid, reason: 'opencode_delivery_runtime_pid_observed', }); + const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( + result.responseObservation + ); if (ledgerRecord && ledger) { ledgerRecord = await ledger.applyDeliveryResult({ id: ledgerRecord.id, accepted: result.ok, attempted: true, - responseObservation: result.responseObservation, + responseObservation, sessionId: result.sessionId, prePromptCursor: result.prePromptCursor, diagnostics: result.diagnostics, - reason: result.ok ? result.responseObservation?.reason : result.diagnostics[0], + reason: result.ok ? responseObservation?.reason : result.diagnostics[0], now: nowIso(), }); let proof = await this.applyOpenCodeVisibleDestinationProof({ @@ -8044,7 +8125,7 @@ export class TeamProvisioningService { { accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null } ); } - const responseState = ledgerRecord?.responseState ?? result.responseObservation?.state; + const responseState = ledgerRecord?.responseState ?? responseObservation?.state; const visibleReply = ledgerRecord ? await this.findOpenCodeVisibleReplyByRelayOfMessageId({ teamName, @@ -8139,15 +8220,15 @@ export class TeamProvisioningService { } const responseVisibleReplyMessageId = ledgerRecord?.visibleReplyMessageId ?? - result.responseObservation?.visibleReplyMessageId ?? + responseObservation?.visibleReplyMessageId ?? undefined; const responseVisibleReplyCorrelation = ledgerRecord?.visibleReplyCorrelation ?? - result.responseObservation?.visibleReplyCorrelation ?? + responseObservation?.visibleReplyCorrelation ?? undefined; const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok); const responsePending = - acceptanceUnknown || (result.ok && Boolean(ledgerRecord || result.responseObservation)) + acceptanceUnknown || (result.ok && Boolean(ledgerRecord || responseObservation)) ? !readAllowed : false; const pendingReason = @@ -8162,8 +8243,8 @@ export class TeamProvisioningService { : result.diagnostics; return { delivered: result.ok || acceptanceUnknown, - ...(ledgerRecord || result.responseObservation ? { accepted: result.ok } : {}), - ...(ledgerRecord || result.responseObservation ? { responsePending } : {}), + ...(ledgerRecord || responseObservation ? { accepted: result.ok } : {}), + ...(ledgerRecord || responseObservation ? { responsePending } : {}), ...(acceptanceUnknown ? { acceptanceUnknown: true } : {}), ...(ledgerRecord ? { @@ -11521,7 +11602,8 @@ export class TeamProvisioningService { private confirmMemberSpawnStatusFromTranscript( run: ProvisioningRun, memberName: string, - observedAt: string + observedAt: string, + source: 'transcript' | 'runtime-proof' = 'transcript' ): void { const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); const updatedAt = nowIso(); @@ -11530,7 +11612,7 @@ export class TeamProvisioningService { status: 'online', updatedAt, agentToolAccepted: true, - runtimeAlive: prev.runtimeAlive === true, + runtimeAlive: source === 'runtime-proof' ? true : prev.runtimeAlive === true, bootstrapConfirmed: true, hardFailure: false, bootstrapStalled: undefined, @@ -11564,7 +11646,13 @@ export class TeamProvisioningService { run.memberSpawnStatuses.set(memberName, next); run.pendingMemberRestarts?.delete(memberName); this.syncMemberLaunchGraceCheck(run, memberName, next); - this.appendMemberBootstrapDiagnostic(run, memberName, 'bootstrap confirmed via transcript'); + this.appendMemberBootstrapDiagnostic( + run, + memberName, + source === 'runtime-proof' + ? 'bootstrap confirmed via runtime proof' + : 'bootstrap confirmed via transcript' + ); if (!this.isCurrentTrackedRun(run)) return; this.emitMemberSpawnChange(run, memberName); if (run.isLaunch) { @@ -13914,12 +14002,26 @@ export class TeamProvisioningService { (current.launchState === 'failed_to_start' && !canClearFailedBootstrap) || current.launchState === 'confirmed_alive' || current.bootstrapConfirmed === true || - current.agentToolAccepted !== true + (current.agentToolAccepted !== true && !canClearFailedBootstrap) ) { continue; } const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt( + run.teamName, + memberName, + current + ); + if (runtimeProofObservedAt) { + this.confirmMemberSpawnStatusFromTranscript( + run, + memberName, + runtimeProofObservedAt, + 'runtime-proof' + ); + continue; + } const transcriptOutcome = await this.findBootstrapTranscriptOutcome( run.teamName, memberName, @@ -22658,6 +22760,19 @@ export class TeamProvisioningService { } const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + if ( + current.launchState !== 'failed_to_start' || + isAutoClearableLaunchFailureReason(current.hardFailureReason ?? current.runtimeDiagnostic) + ) { + const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt( + snapshot.teamName, + expected, + current + ); + if (runtimeProofObservedAt) { + return true; + } + } const transcriptOutcome = await this.findBootstrapTranscriptOutcome( snapshot.teamName, expected, @@ -22673,6 +22788,159 @@ export class TeamProvisioningService { return false; } + private resolveBootstrapRuntimeMember( + teamName: string, + memberName: string + ): PersistedRuntimeMemberLike | undefined { + return this.readPersistedRuntimeMembers(teamName).find((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); + }); + } + + private getBootstrapRuntimeEventsPath( + teamName: string, + memberName: string, + runtimeMember: PersistedRuntimeMemberLike | undefined + ): string { + const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim(); + if (configuredPath) { + return configuredPath; + } + const filePrefix = sanitizeRuntimeEventFilePrefix(runtimeMember?.name ?? memberName); + return path.join(getTeamsBasePath(), teamName, 'runtime', `${filePrefix}.runtime.jsonl`); + } + + private async readRuntimeBootstrapProofEvents( + eventsPath: string + ): Promise[]> { + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(eventsPath, 'r'); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size <= 0) { + return []; + } + const start = Math.max(0, stat.size - BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES); + const buffer = Buffer.alloc(stat.size - start); + if (buffer.length === 0) { + return []; + } + await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.toString('utf8').split('\n'); + if (start > 0) { + lines.shift(); + } + const events: Record[] = []; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + try { + const parsed = JSON.parse(line) as unknown; + if ( + parsed && + typeof parsed === 'object' && + (parsed as { version?: unknown }).version === 1 && + typeof (parsed as { type?: unknown }).type === 'string' && + typeof (parsed as { timestamp?: unknown }).timestamp === 'string' + ) { + events.push(parsed as Record); + } + } catch { + // Ignore partial lines from concurrently written runtime event files. + } + } + return events; + } catch { + return []; + } finally { + await handle?.close().catch(() => undefined); + } + } + + private isRuntimeBootstrapProofEventValid(input: { + event: Record; + detail: Record; + teamName: string; + memberName: string; + runtimeMember?: PersistedRuntimeMemberLike; + boundaryMs: number; + }): boolean { + const { event, detail, teamName, memberName, runtimeMember, boundaryMs } = input; + if (event.type !== 'bootstrap_confirmed') { + return false; + } + if (typeof event.teamName === 'string' && event.teamName.trim() !== teamName) { + return false; + } + const source = getRuntimeBootstrapProofString(event, detail, 'source'); + if (source !== BOOTSTRAP_RUNTIME_PROOF_SOURCE) { + return false; + } + const timestamp = typeof event.timestamp === 'string' ? event.timestamp : ''; + const eventMs = Date.parse(timestamp); + if (Number.isFinite(boundaryMs) && (!Number.isFinite(eventMs) || eventMs < boundaryMs)) { + return false; + } + const expectedToken = runtimeMember?.bootstrapProofToken?.trim(); + const eventToken = getRuntimeBootstrapProofString(event, detail, 'bootstrapProofToken'); + if (expectedToken && eventToken !== expectedToken) { + return false; + } + const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : ''; + const eventAgentId = typeof event.agentId === 'string' ? event.agentId.trim() : ''; + const runtimeName = runtimeMember?.name?.trim() ?? ''; + const runtimeAgentId = runtimeMember?.agentId?.trim() ?? ''; + return ( + (eventAgentName.length > 0 && + (matchesMemberNameOrBase(eventAgentName, memberName) || + (runtimeName.length > 0 && matchesTeamMemberIdentity(eventAgentName, runtimeName)))) || + (eventAgentId.length > 0 && runtimeAgentId.length > 0 && eventAgentId === runtimeAgentId) + ); + } + + private async findBootstrapRuntimeProofObservedAt( + teamName: string, + memberName: string, + member: Pick< + PersistedTeamLaunchMemberState, + 'firstSpawnAcceptedAt' | 'launchState' | 'hardFailureReason' + > + ): Promise { + const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName); + const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter; + const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN; + if (!runtimeMember?.bootstrapProofToken && !Number.isFinite(boundaryMs)) { + return null; + } + const eventsPath = this.getBootstrapRuntimeEventsPath(teamName, memberName, runtimeMember); + const events = await this.readRuntimeBootstrapProofEvents(eventsPath); + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const event of events) { + const detail = parseRuntimeBootstrapProofDetail(event.detail); + if ( + !this.isRuntimeBootstrapProofEventValid({ + event, + detail, + teamName, + memberName, + runtimeMember, + boundaryMs, + }) + ) { + continue; + } + const timestamp = typeof event.timestamp === 'string' ? event.timestamp : ''; + const timestampMs = Date.parse(timestamp); + if (Number.isFinite(timestampMs) && timestampMs >= latestMs) { + latest = timestamp; + latestMs = timestampMs; + } + } + return latest; + } + private async applyBootstrapTranscriptEvidenceOverlay( snapshot: PersistedTeamLaunchSnapshot | null ): Promise { @@ -22691,15 +22959,30 @@ export class TeamProvisioningService { ) { continue; } + const failureReason = current.hardFailureReason ?? current.runtimeDiagnostic; + const canClearFailedBootstrap = + current.launchState !== 'failed_to_start' || + isAutoClearableLaunchFailureReason(failureReason); + if (!canClearFailedBootstrap) { + continue; + } const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt( + snapshot.teamName, + expected, + current + ); const transcriptOutcome = await this.findBootstrapTranscriptOutcome( snapshot.teamName, expected, Number.isFinite(acceptedAtMs) ? acceptedAtMs : null ); - if (transcriptOutcome?.kind !== 'success') { + const observedAt = + runtimeProofObservedAt ?? + (transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null); + if (!observedAt) { continue; } @@ -22707,9 +22990,13 @@ export class TeamProvisioningService { ...current, agentToolAccepted: true, bootstrapConfirmed: true, + runtimeAlive: runtimeProofObservedAt ? true : current.runtimeAlive === true, hardFailure: false, hardFailureReason: undefined, - lastHeartbeatAt: current.lastHeartbeatAt ?? transcriptOutcome.observedAt, + lastHeartbeatAt: current.lastHeartbeatAt ?? observedAt, + lastRuntimeAliveAt: runtimeProofObservedAt + ? (current.lastRuntimeAliveAt ?? observedAt) + : current.lastRuntimeAliveAt, lastEvaluatedAt: nowIso(), sources: { ...(current.sources ?? {}), @@ -22997,6 +23284,8 @@ export class TeamProvisioningService { : null; const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic; + const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason); current.runtimeAlive = observedRuntimeAlive; current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; current.livenessKind = runtimeMetadata?.[1].livenessKind; @@ -23019,7 +23308,7 @@ export class TeamProvisioningService { const currentProvesSpawnAcceptance = current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string'; if ( - isAutoClearableLaunchFailureReason(current.hardFailureReason) && + hadAutoClearableFailure && (bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance) ) { current.hardFailure = false; @@ -23049,15 +23338,34 @@ export class TeamProvisioningService { current.hardFailure = false; current.hardFailureReason = undefined; } - if (!current.bootstrapConfirmed) { - const transcriptOutcome = await this.findBootstrapTranscriptOutcome( - teamName, - expected, - Number.isFinite(acceptedAtMs) ? acceptedAtMs : null - ); - if (transcriptOutcome?.kind === 'success' && !isOpenCodeSecondaryLaneMember) { + const canApplyBootstrapSuccess = + !heartbeatReason && + (current.launchState !== 'failed_to_start' || + hadAutoClearableFailure || + isAutoClearableLaunchFailureReason( + current.hardFailureReason ?? current.runtimeDiagnostic + )); + if (!current.bootstrapConfirmed && canApplyBootstrapSuccess) { + const runtimeProofObservedAt = !isOpenCodeSecondaryLaneMember + ? await this.findBootstrapRuntimeProofObservedAt(teamName, expected, current) + : null; + const transcriptOutcome = runtimeProofObservedAt + ? null + : await this.findBootstrapTranscriptOutcome( + teamName, + expected, + Number.isFinite(acceptedAtMs) ? acceptedAtMs : null + ); + const bootstrapObservedAt = + runtimeProofObservedAt ?? + (transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null); + if (bootstrapObservedAt && !isOpenCodeSecondaryLaneMember) { current.bootstrapConfirmed = true; - current.lastHeartbeatAt = current.lastHeartbeatAt ?? transcriptOutcome.observedAt; + current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapObservedAt; + current.runtimeAlive = runtimeProofObservedAt ? true : current.runtimeAlive === true; + current.lastRuntimeAliveAt = runtimeProofObservedAt + ? (current.lastRuntimeAliveAt ?? bootstrapObservedAt) + : current.lastRuntimeAliveAt; current.hardFailure = false; current.hardFailureReason = undefined; if (current.sources) { diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index f941cc6d..ba59a021 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -177,6 +177,7 @@ export type OpenCodeDeliveryResponseState = | 'permission_blocked' | 'tool_error' | 'empty_assistant_turn' + | 'prompt_delivered_no_assistant_message' | 'session_stale' | 'session_error' | 'reconcile_failed'; diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 98180867..d00fb850 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -87,6 +87,7 @@ const OPENCODE_DELIVERY_RESPONSE_STATES = new Set 'permission_blocked', 'tool_error', 'empty_assistant_turn', + 'prompt_delivered_no_assistant_message', 'session_stale', 'session_error', 'reconcile_failed', @@ -274,7 +275,7 @@ export class OpenCodePromptDeliveryLedgerStore { const responseState = observation?.state ?? (input.accepted ? record.responseState : 'not_observed'); const responded = isOpenCodePromptResponseStateResponded(responseState); - const unanswered = responseState === 'empty_assistant_turn'; + const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(responseState); return { ...record, status: input.accepted @@ -321,7 +322,9 @@ export class OpenCodePromptDeliveryLedgerStore { }): Promise { return await this.updateExisting(input.id, (record) => { const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state); - const unanswered = input.responseObservation.state === 'empty_assistant_turn'; + const unanswered = isOpenCodePromptDeliveryUnansweredResponseState( + input.responseObservation.state + ); return { ...record, status: responded @@ -637,6 +640,12 @@ export function isOpenCodePromptResponseStateResponded( ); } +function isOpenCodePromptDeliveryUnansweredResponseState( + state: OpenCodeDeliveryResponseState +): boolean { + return state === 'empty_assistant_turn' || state === 'prompt_delivered_no_assistant_message'; +} + export function isOpenCodePromptDeliveryAttemptDue( record: OpenCodePromptDeliveryLedgerRecord, nowMs: number = Date.now() diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts index 7247620b..a20c3885 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts @@ -90,6 +90,7 @@ export function isOpenCodePromptDeliveryRetryableResponseState( ): boolean { return ( state === 'empty_assistant_turn' || + state === 'prompt_delivered_no_assistant_message' || state === 'tool_error' || state === 'reconcile_failed' || state === 'not_observed' || diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index b686585f..ff37753d 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -798,6 +798,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) ? `Include relayOfMessageId="${input.messageId}" in that message_send call.` : null, 'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.', + 'You must not end this turn empty.', 'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.', 'Do not answer only with plain assistant text when agent-teams_message_send is available.', 'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.', diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e6d930bf..1e13a85e 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -345,6 +345,33 @@ interface LeadContextBridgeProps { isThisTabActive: boolean; } +const EMPTY_MESSAGES_PANEL_TASKS: TeamTaskWithKanban[] = []; + +function buildMessagesPanelTasksSignature(tasks: readonly TeamTaskWithKanban[]): string { + return JSON.stringify( + tasks.map((task) => [ + task.id, + task.displayId ?? '', + task.subject, + task.owner ?? '', + task.reviewer ?? '', + task.status, + task.reviewState ?? '', + task.kanbanColumn ?? '', + ]) + ); +} + +function useStableMessagesPanelTasks( + tasks: TeamTaskWithKanban[] | undefined +): TeamTaskWithKanban[] { + const sourceTasks = tasks ?? EMPTY_MESSAGES_PANEL_TASKS; + const signature = useMemo(() => buildMessagesPanelTasksSignature(sourceTasks), [sourceTasks]); + + // eslint-disable-next-line react-hooks/exhaustive-deps -- sourceTasks identity is gated by render-relevant task fields. + return useMemo(() => sourceTasks, [signature]); +} + // Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet. const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set(['codex', 'opencode']); @@ -1783,9 +1810,12 @@ export const TeamDetailView = memo(function TeamDetailView({ } }, []); - const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { - setSelectedTask(task); - }, []); + const handleOpenMessagePanelTask = useCallback( + (task: TeamTaskWithKanban) => { + handleOpenTaskById(task.id); + }, + [handleOpenTaskById] + ); const handleTaskIdClick = useCallback( (taskId: string) => { @@ -2017,21 +2047,22 @@ export const TeamDetailView = memo(function TeamDetailView({ })(); }; + const messagesPanelTasks = useStableMessagesPanelTasks(data?.tasks); + const sharedMessagesPanelProps = useMemo( () => ({ teamName, onPositionChange: changeMessagesPanelMode, mountPoint: messagesPanelMountPoint, members: activeMembers, - tasks: data?.tasks ?? [], + tasks: messagesPanelTasks, isTeamAlive: data?.isAlive, timeWindow, - teamSessionIds, currentLeadSessionId: data?.config.leadSessionId, pendingRepliesByMember, onPendingReplyChange: setPendingRepliesByMember, onMemberClick: handleSelectMember, - onTaskClick: handleOpenTask, + onTaskClick: handleOpenMessagePanelTask, onCreateTaskFromMessage: handleCreateTaskFromMessage, onReplyToMessage: handleReplyToMessage, onRestartTeam: handleRestartTeam, @@ -2042,17 +2073,16 @@ export const TeamDetailView = memo(function TeamDetailView({ activeMembers, data?.config.leadSessionId, data?.isAlive, - data?.tasks, handleCreateTaskFromMessage, - handleOpenTask, + handleOpenMessagePanelTask, handleReplyToMessage, handleRestartTeam, handleSelectMember, handleTaskIdClick, + messagesPanelTasks, messagesPanelMountPoint, pendingRepliesByMember, teamName, - teamSessionIds, timeWindow, changeMessagesPanelMode, ] diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 145dd9b0..8a3e3841 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,4 +1,5 @@ import { + type ComponentProps, memo, type RefObject, useCallback, @@ -216,6 +217,109 @@ export function hasVisibleReplyForSendMessageDiagnostics( }); } +const MessagesComposerSection = memo(MessageComposer); +const MessagesStatusSection = memo(StatusBlock); + +type MessagesTimelineSectionProps = ComponentProps & { + hasMore: boolean; + loadingOlderMessages: boolean; + onLoadOlderMessages: () => void; + expandedItem: TimelineItem | null; + expandedItemKey: string | null; + onExpandDialogChange: (open: boolean) => void; +}; + +const MessagesTimelineSection = memo(function MessagesTimelineSection({ + hasMore, + loadingOlderMessages, + onLoadOlderMessages, + expandedItem, + expandedItemKey, + onExpandDialogChange, + messages, + teamName, + members, + readState, + allCollapsed, + expandOverrides, + onToggleExpandOverride, + currentLeadSessionId, + isTeamAlive, + leadActivity, + leadContextUpdatedAt, + teamNames, + teamColorByName, + onTeamClick, + onMemberClick, + onCreateTaskFromMessage, + onReplyToMessage, + onMessageVisible, + onRestartTeam, + onTaskIdClick, + onExpandItem, + onExpandContent, + viewport, +}: MessagesTimelineSectionProps): React.JSX.Element { + return ( + <> + + {hasMore && ( +
+ +
+ )} + + + ); +}); + export const MessagesPanel = memo(function MessagesPanel({ teamName, position, @@ -282,8 +386,10 @@ export const MessagesPanel = memo(function MessagesPanel({ await loadOlderTeamMessages(teamName); }, [loadOlderTeamMessages, messagesState, teamName]); - const messagesLoading = - (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const handleLoadOlderMessagesClick = useCallback(() => { + void loadOlderMessages(); + }, [loadOlderMessages]); + const loadingOlderMessages = messagesState?.loadingOlder ?? false; const hasMore = messagesState?.hasMore ?? false; const effectiveMessages = messages; @@ -723,6 +829,99 @@ export const MessagesPanel = memo(function MessagesPanel({ ); }, [bottomSheetSnapIndex]); + const defaultComposerSection = ( + + ); + + const compactComposerSection = ( + + ); + + const inlineStatusSection = ( + + ); + + const sidebarStatusSection = ( + + ); + + const timelineSection = ( + + ); + // ---- Shared content (used in both modes) ---- const searchAndFilterControls = (
@@ -785,83 +984,9 @@ export const MessagesPanel = memo(function MessagesPanel({ const messagesContent = (
- - - - {hasMore && ( -
- -
- )} - + {defaultComposerSection} + {inlineStatusSection} + {timelineSection}
); @@ -972,84 +1097,10 @@ export const MessagesPanel = memo(function MessagesPanel({ onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)} >
- - {' '} + {defaultComposerSection} + {sidebarStatusSection}
- - {hasMore && ( -
- -
- )} - + {timelineSection}
); @@ -1256,91 +1307,10 @@ export const MessagesPanel = memo(function MessagesPanel({ {searchAndFilterControls} )} -
- -
+
{compactComposerSection}
-
- -
-
- - {hasMore && ( -
- -
- )} -
- +
{inlineStatusSection}
+
{timelineSection}
)} diff --git a/src/renderer/components/team/messages/StatusBlock.tsx b/src/renderer/components/team/messages/StatusBlock.tsx index 2974ec29..dc272180 100644 --- a/src/renderer/components/team/messages/StatusBlock.tsx +++ b/src/renderer/components/team/messages/StatusBlock.tsx @@ -66,12 +66,12 @@ export const StatusBlock = ({ return hasActiveTasks; }, [hasActiveTasks, hasPendingReplies]); - // Only run the 1-second timer when the block actually has content to show. + // Only pending reply TTL labels need a 1-second refresh. useEffect(() => { - if (!hasItems) return; + if (!hasPendingReplies) return; const id = window.setInterval(() => setNowMs(Date.now()), 1000); return () => window.clearInterval(id); - }, [hasItems]); + }, [hasPendingReplies]); if (!hasItems) return null; diff --git a/src/renderer/components/team/teamRuntimeDisplayRows.ts b/src/renderer/components/team/teamRuntimeDisplayRows.ts index 60527737..430c2d9a 100644 --- a/src/renderer/components/team/teamRuntimeDisplayRows.ts +++ b/src/renderer/components/team/teamRuntimeDisplayRows.ts @@ -135,8 +135,11 @@ function buildRuntimeBackedDisplayRow( const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error'; const spawnDegradation = getSpawnDegradation(spawn); const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null); + const degradedReason = spawnDegradation + ? withLiveProcessContext(spawnDegradation.reason, runtime) + : undefined; const stateReason = - spawnDegradation?.reason ?? + degradedReason ?? runtime.runtimeDiagnostic ?? (runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive'); @@ -151,7 +154,10 @@ function buildRuntimeBackedDisplayRow( laneId: runtime.laneId, laneKind: runtime.laneKind, runtimeModel: runtime.runtimeModel, - diagnostic: spawnDegradation?.diagnostic ?? runtime.runtimeDiagnostic, + diagnostic: + spawnDegradation && degradedReason + ? withLiveProcessContext(spawnDegradation.diagnostic ?? degradedReason, runtime) + : runtime.runtimeDiagnostic, diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity, pidLabel: formatRuntimePidLabel(runtime), actionsAllowed: false, @@ -213,6 +219,13 @@ function getRuntimeBackedState( return runtime.alive === true ? 'running' : 'stopped'; } +function withLiveProcessContext(reason: string, runtime: TeamAgentRuntimeEntry): string { + if (runtime.alive !== true || /process is still alive/i.test(reason)) { + return reason; + } + return `${reason}. Process is still alive.`; +} + function buildSpawnBackedDisplayRow( memberName: string, spawn: MemberSpawnStatusEntry diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 5cce4646..3916c01c 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -30,6 +30,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde if (normalized === 'empty_assistant_turn') { return 'OpenCode returned an empty assistant turn.'; } + if (normalized === 'prompt_delivered_no_assistant_message') { + return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; + } return ''; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 7507ca1a..37a8e8f5 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -712,6 +712,7 @@ export interface SendMessageResult { | 'permission_blocked' | 'tool_error' | 'empty_assistant_turn' + | 'prompt_delivered_no_assistant_message' | 'session_stale' | 'session_error' | 'reconcile_failed'; diff --git a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts index cdde0127..60d4b1a8 100644 --- a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts +++ b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts @@ -125,6 +125,7 @@ describe('OpenCode production prompt artifacts safe e2e', () => { expect(directCommand?.text).toContain('Include source="runtime_delivery"'); expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-'); expect(directCommand?.text).toContain('Action mode for this message: ask.'); + expect(directCommand?.text).toContain('You must not end this turn empty.'); expect(directCommand?.text).toContain('"displayId":"59560c95"'); expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message'); expect(directCommand?.text).toContain('never use #00000000'); diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts index c1f5f2aa..8f21a502 100644 --- a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -255,6 +255,38 @@ describe('OpenCodePromptDeliveryLedger', () => { expect(emptyResult.responseState).toBe('empty_assistant_turn'); expect(emptyResult.attempts).toBe(1); + const noAssistant = await store.ensurePending({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + inboxMessageId: 'msg-no-assistant', + inboxTimestamp: '2026-04-25T09:59:05.000Z', + source: 'watcher', + replyRecipient: 'user', + payloadHash: 'sha256:no-assistant', + now: '2026-04-25T10:00:06.000Z', + }); + const noAssistantResult = await store.applyDeliveryResult({ + id: noAssistant.id, + accepted: true, + attempted: true, + responseObservation: { + state: 'prompt_delivered_no_assistant_message', + deliveredUserMessageId: 'oc-user-no-assistant', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'prompt_delivered_no_assistant_message', + }, + now: '2026-04-25T10:00:07.000Z', + }); + + expect(noAssistantResult.status).toBe('unanswered'); + expect(noAssistantResult.responseState).toBe('prompt_delivered_no_assistant_message'); + const plain = await store.ensurePending({ teamName: 'team-a', memberName: 'jack', diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index ef3c6dac..65c80af3 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -531,6 +531,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('Include source="runtime_delivery"'); expect(sentText).toContain('Include relayOfMessageId="msg-1"'); expect(sentText).toContain('Action mode for this message: delegate.'); + expect(sentText).toContain('You must not end this turn empty.'); expect(sentText).toContain(''); expect(sentText).toContain('"kind":"opencode-delivery-context"'); expect(sentText).toContain('"inboundMessageId":"msg-1"'); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 69dc98d1..742ff85a 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -6955,22 +6955,123 @@ describe('TeamProvisioningService', () => { delivered: true, diagnostics: [], }); - expect(sendMessageToMember).toHaveBeenCalledWith( - expect.objectContaining({ - runId: 'opencode-run-durable', - teamName, - laneId, + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-durable', + teamName, + laneId, memberName: 'bob', cwd: '/repo', text: 'hello after restart', messageId: 'msg-after-restart', - }) - ); - }); + }) + ); + }); - it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => { - const svc = new TeamProvisioningService(); - const teamName = 'team-a'; + it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'team-a'; + const laneId = 'secondary:opencode:bob'; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + diagnostics: [], + })); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]) + ); + + (svc as any).aliveRunByTeam.set(teamName, 'primary-run'); + (svc as any).runs.set('primary-run', { + runId: 'primary-run', + teamName, + processKilled: false, + cancelRequested: false, + progress: { state: 'ready' }, + request: { providerId: 'codex', cwd: '/repo' }, + mixedSecondaryLanes: [ + { + laneId, + providerId: 'opencode', + member: { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + runId: 'opencode-run-live', + state: 'finished', + result: { + members: { + bob: { + bootstrapConfirmed: true, + launchState: 'confirmed_alive', + sessionId: 'oc-session-bob', + }, + }, + }, + warnings: [], + diagnostics: [], + }, + ], + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'hello live lane', + messageId: 'msg-live-lane', + }) + ).resolves.toMatchObject({ + delivered: true, + diagnostics: [], + }); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-live', + teamName, + laneId, + memberName: 'bob', + }) + ); + expect(sendMessageToMember).not.toHaveBeenCalledWith( + expect.objectContaining({ runId: 'primary-run' }) + ); + }); + + it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'team-a'; const laneId = 'secondary:opencode:bob'; const sendMessageToMember = vi.fn(async (input: Record) => ({ ok: true, @@ -12351,6 +12452,149 @@ describe('TeamProvisioningService', () => { }); }); + it('heals terminal bootstrap-state failures when runtime proof confirms member_briefing', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-state-runtime-proof-heals'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const proofAt = new Date(Date.now() - 60_000).toISOString(); + const failureAt = new Date(Date.now() - 30_000).toISOString(); + const proofToken = 'proof-token-jack'; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + bootstrapExpectedAfter: acceptedAt, + bootstrapProofToken: proofToken, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'failed', + lastAttemptAt: Date.parse(acceptedAt), + lastObservedAt: Date.parse(failureAt), + failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.', + }, + ], + failureAt + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_confirmed', + timestamp: proofAt, + pid: 1234, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + source: 'member_briefing_tool_success', + bootstrapProofToken: proofToken, + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.jack).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: true, + hardFailure: false, + error: undefined, + }); + }); + + it('does not heal bootstrap-state failures from stale runtime proof before spawn acceptance', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const proofAt = new Date(Date.now() - 120_000).toISOString(); + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const failureAt = new Date(Date.now() - 30_000).toISOString(); + const proofToken = 'proof-token-jack'; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + bootstrapExpectedAfter: acceptedAt, + bootstrapProofToken: proofToken, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'failed', + lastAttemptAt: Date.parse(acceptedAt), + lastObservedAt: Date.parse(failureAt), + failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.', + }, + ], + failureAt + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_confirmed', + timestamp: proofAt, + pid: 1234, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + source: 'member_briefing_tool_success', + bootstrapProofToken: proofToken, + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.statuses.jack).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + bootstrapConfirmed: false, + runtimeAlive: false, + hardFailure: true, + }); + }); + it('does not heal bootstrap-state failures from stale pre-launch transcript success', async () => { allowConsoleLogs(); const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored'; diff --git a/test/renderer/components/team/teamRuntimeDisplayRows.test.ts b/test/renderer/components/team/teamRuntimeDisplayRows.test.ts index 186c52f9..3298f44e 100644 --- a/test/renderer/components/team/teamRuntimeDisplayRows.test.ts +++ b/test/renderer/components/team/teamRuntimeDisplayRows.test.ts @@ -158,7 +158,7 @@ describe('buildTeamRuntimeDisplayRows', () => { memberName: 'alice', state: 'degraded', source: 'mixed', - stateReason: 'Bootstrap command failed', + stateReason: 'Bootstrap command failed. Process is still alive.', actionsAllowed: false, }); }); diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index 84e91000..f4f3be51 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -27,4 +27,29 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { reason: 'empty_assistant_turn', }); }); + + it('surfaces prompt delivery with no recorded assistant turn separately', () => { + const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ + deliveredToInbox: true, + messageId: 'msg-no-assistant', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'prompt_delivered_no_assistant_message', + ledgerStatus: 'failed_terminal', + reason: 'prompt_delivered_no_assistant_message', + diagnostics: ['prompt_delivered_no_assistant_message'], + }, + }); + + expect(diagnostics.warning).toBe( + 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode accepted the prompt, but no assistant turn was recorded.' + ); + expect(diagnostics.debugDetails).toMatchObject({ + responseState: 'prompt_delivered_no_assistant_message', + reason: 'prompt_delivered_no_assistant_message', + }); + }); }); From 899596b258e5267776a64b41c8300eaf95e8bb27 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 5 May 2026 23:00:10 +0300 Subject: [PATCH 5/7] feat(member-work-sync): track task impact handoffs --- .../src/internal/runtimeHelpers.js | 24 +- agent-teams-controller/src/internal/tasks.js | 6 +- .../src/internal/workSync.js | 24 +- .../test/controller.test.js | 83 ++- .../member-work-sync-control-plane-plan.md | 43 +- ...er-work-sync-opencode-turn-settled-plan.md | 6 +- ...member-work-sync-runtime-stop-hook-plan.md | 12 +- .../member-work-sync/contracts/ipc.ts | 1 + .../MemberWorkSyncDiagnosticsReader.ts | 46 +- .../MemberWorkSyncNudgeDispatcher.ts | 34 +- .../core/domain/ActionableWorkAgenda.ts | 118 +++- .../core/domain/MemberWorkSyncNudge.ts | 13 +- .../domain/MemberWorkSyncReportValidator.ts | 25 +- .../core/domain/currentReviewCycle.ts | 12 +- .../input/MemberWorkSyncTaskImpactResolver.ts | 191 +++++++ .../input/MemberWorkSyncTeamChangeRouter.ts | 52 +- .../input/registerMemberWorkSyncIpc.ts | 14 + .../createMemberWorkSyncFeature.ts | 99 ++-- src/features/member-work-sync/main/index.ts | 2 - .../HmacMemberWorkSyncReportTokenAdapter.ts | 8 +- .../MemberWorkSyncEventQueue.ts | 203 ++++++- .../member-work-sync/preload/index.ts | 3 + src/main/http/teams.ts | 69 ++- src/main/index.ts | 3 +- .../services/infrastructure/FileWatcher.ts | 1 + .../services/team/TeamProvisioningService.ts | 20 + .../bridge/OpenCodeBridgeCommandContract.ts | 7 + .../delivery/OpenCodePromptDeliveryLedger.ts | 30 +- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 45 +- src/renderer/api/httpClient.ts | 7 + .../components/team/ClaudeLogsPanel.tsx | 3 +- src/renderer/utils/streamJsonParser.ts | 154 ++++- src/shared/types/api.ts | 1 + .../core/ActionableWorkAgenda.test.ts | 136 ++++- .../MemberWorkSyncReportValidator.test.ts | 83 ++- .../core/MemberWorkSyncUseCases.test.ts | 74 ++- ...acMemberWorkSyncReportTokenAdapter.test.ts | 57 +- .../main/MemberWorkSyncEventQueue.test.ts | 176 ++++++ .../MemberWorkSyncTaskImpactResolver.test.ts | 132 +++++ .../MemberWorkSyncTeamChangeRouter.test.ts | 37 ++ .../main/createMemberWorkSyncFeature.test.ts | 246 ++++++-- .../main/registerMemberWorkSyncIpc.test.ts | 37 +- .../preload/memberWorkSyncPreload.test.ts | 18 +- test/main/http/teams.test.ts | 143 +++++ .../MemberWorkSyncClaudeStopHook.live.test.ts | 5 - .../team/MemberWorkSyncCodex.live.test.ts | 525 +++++++++++++++++- .../team/MixedProviderTeamLaunch.live.test.ts | 4 - .../team/OpenCodePromptDeliveryLedger.test.ts | 138 +++-- .../team/OpenCodeTeamRuntimeAdapter.test.ts | 55 +- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 50 +- .../team/TeamProvisioningService.test.ts | 423 +++++++++----- .../team/TeamProvisioningServiceRelay.test.ts | 46 +- .../team/memberWorkSyncLiveHarness.ts | 26 +- .../api/httpClient.memberWorkSync.test.ts | 16 +- .../components/team/ClaudeLogsPanel.test.ts | 6 +- test/renderer/utils/streamJsonParser.test.ts | 45 +- 56 files changed, 3352 insertions(+), 485 deletions(-) create mode 100644 src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts create mode 100644 test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index 93784df0..35ba9df8 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -253,11 +253,13 @@ function formatAllowedTaskCommentAuthors(paths, explicitMembers, options = {}) { } } - const leadName = inferLeadName(paths); - const leadKey = normalizeMemberKey(leadName); - if (leadKey && explicitMembers.membersByKey.has(leadKey)) { - allowed.add('lead'); - allowed.add('team-lead'); + if (options.allowLeadAliases !== false) { + const leadName = inferLeadName(paths); + const leadKey = normalizeMemberKey(leadName); + if (leadKey && explicitMembers.membersByKey.has(leadKey)) { + allowed.add('lead'); + allowed.add('team-lead'); + } } return Array.from(allowed).sort((a, b) => a.localeCompare(b)).join(', '); @@ -285,14 +287,18 @@ function resolveTaskCommentAuthorName(paths, candidate, label = 'task comment au return directMember.name; } - const leadAlias = resolveExplicitTeamMemberName(paths, normalized, { allowLeadAliases: true }); + const leadAlias = resolveExplicitTeamMemberName(paths, normalized, { + allowLeadAliases: options.allowLeadAliases !== false, + }); if (leadAlias) { return leadAlias; } - const { leadName, keys } = getLeadProviderKeys(paths, explicit); - if (leadName && keys.has(key)) { - return leadName; + if (options.allowProviderAliases !== false) { + const { leadName, keys } = getLeadProviderKeys(paths, explicit); + if (leadName && keys.has(key)) { + return leadName; + } } throw new Error( diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 56551a28..0040643f 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -518,7 +518,11 @@ function addTaskCommentWithOptions(context, taskId, flags, options = {}) { context.paths, commentFlags.from, 'task comment author', - { allowReservedAuthors: !fromRequiredForAgentTool } + { + allowReservedAuthors: !fromRequiredForAgentTool, + allowLeadAliases: !fromRequiredForAgentTool, + allowProviderAliases: !fromRequiredForAgentTool, + } ); const result = withTeamBoardLock(context.paths, () => taskStore.addTaskComment(context.paths, taskId, commentFlags.text, { diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js index 5ac48411..fd17af45 100644 --- a/agent-teams-controller/src/internal/workSync.js +++ b/agent-teams-controller/src/internal/workSync.js @@ -94,14 +94,16 @@ async function requestJsonWithFallback(baseUrls, pathname, options = {}) { } function compactReportBody(context, memberName, flags = {}) { + const taskIds = normalizeTaskIds( + Array.isArray(flags['task-ids']) ? flags['task-ids'] : flags.taskIds + ); return { teamName: context.teamName, memberName, state: flags.state, agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'], reportToken: flags.reportToken || flags['report-token'], - ...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}), - ...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}), + ...(taskIds.length > 0 ? { taskIds } : {}), ...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}), ...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim() ? { reportedAt: flags.reportedAt.trim() } @@ -110,6 +112,12 @@ function compactReportBody(context, memberName, flags = {}) { }; } +function normalizeTaskIds(value) { + return Array.isArray(value) + ? Array.from(new Set(value.map((taskId) => String(taskId).trim()).filter(Boolean))) + : []; +} + function stableStringify(value) { if (value == null || typeof value !== 'object') { return JSON.stringify(value); @@ -125,7 +133,9 @@ function stableStringify(value) { function buildPendingIntentId(body) { const taskIds = Array.isArray(body.taskIds) - ? Array.from(new Set(body.taskIds.map((taskId) => String(taskId)).filter(Boolean))).sort() + ? Array.from( + new Set(body.taskIds.map((taskId) => String(taskId).trim()).filter(Boolean)) + ).sort() : []; const payload = { teamName: body.teamName, @@ -229,8 +239,12 @@ async function memberWorkSyncStatus(context, flags = {}) { baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent( memberName - )}`, - { timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) } + )}/refresh`, + { + method: 'POST', + body: {}, + timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']), + } ); } diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 6ac878a4..c4136a2f 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -1228,6 +1228,67 @@ describe('agent-teams-controller API', () => { expect(fromSystem.comment.author).toBe('system'); }); + it('rejects provider and lead aliases for agent-facing task comments', () => { + const claudeDir = makeClaudeDir(); + fs.writeFileSync( + path.join(claudeDir, 'teams', 'my-team', 'config.json'), + JSON.stringify( + { + name: 'my-team', + leadSessionId: 'lead-session-1', + members: [ + { name: 'team-lead', role: 'team-lead', providerId: 'codex', provider: 'codex' }, + { name: 'bob', role: 'developer' }, + ], + }, + null, + 2 + ) + ); + + const appController = createController({ teamName: 'my-team', claudeDir }); + const agentController = createController({ + teamName: 'my-team', + claudeDir, + allowUserMessageSender: false, + }); + const task = appController.tasks.createTask({ + subject: 'Reject agent aliases', + owner: 'bob', + notifyOwner: false, + }); + + expect(() => + agentController.tasks.addTaskComment(task.id, { + from: 'codex', + text: 'Provider alias should not be accepted from MCP.', + }) + ).toThrow('Unknown task comment author: codex'); + + expect(() => + agentController.tasks.addTaskComment(task.id, { + from: 'lead', + text: 'Lead alias should not be accepted from MCP.', + }) + ).toThrow('Unknown task comment author: lead'); + + let unknownAuthorError; + try { + agentController.tasks.addTaskComment(task.id, { + from: 'Codex', + text: 'Provider alias case should not be accepted from MCP.', + }); + } catch (error) { + unknownAuthorError = error; + } + expect(unknownAuthorError.message).toContain('Unknown task comment author: Codex'); + expect(unknownAuthorError.message).toContain('Use one of: bob, team-lead'); + expect(unknownAuthorError.message).not.toContain('user'); + expect(unknownAuthorError.message).not.toContain('system'); + + expect(appController.tasks.getTask(task.id).comments || []).toEqual([]); + }); + it('does not map a real teammate named like the lead provider id to the lead', () => { const claudeDir = makeClaudeDir(); fs.writeFileSync( @@ -1255,6 +1316,18 @@ describe('agent-teams-controller API', () => { }); expect(commented.comment.author).toBe('codex'); + + const agentController = createController({ + teamName: 'my-team', + claudeDir, + allowUserMessageSender: false, + }); + const agentCommented = agentController.tasks.addTaskComment(task.id, { + from: 'codex', + text: 'Agent-facing real teammate comment.', + }); + + expect(agentCommented.comment.author).toBe('codex'); }); it('rejects task comments from unknown authors', () => { @@ -2445,7 +2518,7 @@ describe('agent-teams-controller API', () => { const server = await startControlServer(async ({ method, url, body }) => { calls.push({ method, url, body }); - if (method === 'GET' && url === '/api/teams/my-team/member-work-sync/bob') { + if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/bob/refresh') { return { body: { teamName: 'my-team', @@ -2483,7 +2556,7 @@ describe('agent-teams-controller API', () => { state: 'still_working', agendaFingerprint: 'agenda:v1:abc', reportToken: 'wrs:v1.test.token', - taskIds: ['task-1'], + taskIds: [' task-1 ', '', 'task-1'], note: 'Continuing work', leaseTtlMs: 120000, }); @@ -2492,9 +2565,9 @@ describe('agent-teams-controller API', () => { expect(report.accepted).toBe(true); expect(calls).toEqual([ { - method: 'GET', - url: '/api/teams/my-team/member-work-sync/bob', - body: undefined, + method: 'POST', + url: '/api/teams/my-team/member-work-sync/bob/refresh', + body: {}, }, { method: 'POST', diff --git a/docs/team-management/member-work-sync-control-plane-plan.md b/docs/team-management/member-work-sync-control-plane-plan.md index 218314d2..095f8815 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -1,6 +1,6 @@ # Member Work Sync Control Plane Plan -**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and opt-in Phase 2 nudge outbox/dispatcher/scheduler implemented +**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and active-by-default Phase 2 nudge outbox/dispatcher/scheduler implemented **Scope:** Team management, task work synchronization, agent work coordination **Primary repo:** `claude_team` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` @@ -35,11 +35,11 @@ Current implementation note: - Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. - Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges. - Phase 2 storage foundation is implemented as a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. -- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam gate and keeps UI/status reads passive. -- Phase 2 nudge side effects are additionally disabled by default in production composition. Set `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1` only for isolated live validation. This keeps status/report/metrics active while guaranteeing that shadow-ready metrics cannot start inbox nudges by accident. -- Dispatcher use case can run after queued reconcile and is also exposed through the facade when nudge side effects are explicitly enabled. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port. +- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam guard and keeps UI/status reads passive. +- Phase 2 nudge delivery is active by default in production composition. Safety is provided by internal guards: `shadow_ready`, current fingerprint, active team, busy signal, watchdog cooldown, rate limit, and idempotent outbox. +- Dispatcher use case runs after queued reconcile and is also exposed through the facade. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port. - Production busy revalidation is wired through a tool-activity busy signal adapter. Active or recently finished tool calls defer Phase 2 nudges instead of interrupting work. -- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams only when nudge side effects are enabled. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write. +- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams when a lifecycle-active team source is available. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write. - Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts. - Superseded-but-undelivered outbox items can be revived by a fresh queued reconcile for the same agenda fingerprint. Delivered nudges remain one-per-fingerprint. - Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. @@ -378,7 +378,7 @@ Pre-coding hardening checklist: - Add identity tests before report persistence. `from` is not authority unless runtime context or report token proves it. - Treat every app restart as a replay scenario. Pending intents, queued reconciles, and stale reports must be safe to process again. - Make every Phase 2 side effect idempotent before adding the dispatcher. -- Add one explicit kill switch per side-effect class: reconcile/status, report acceptance, and nudges. +- Keep side effects bounded by internal guards and narrow ports. Do not add permanent product switches for reconcile/status, report acceptance, or nudges. - Do not merge watchdog and work-sync concepts. Work-sync is agenda observation; watchdog is semantic progress. Failure-mode matrix: @@ -801,7 +801,7 @@ It should not mean: - a delivery retry marker was appended; - a status condition timestamp changed. -Phase 1 must track `fingerprintChangeCount` and store the last few fingerprint transition reasons. If this count rises without visible agenda changes, do not enable Phase 2 nudges. +Phase 1 must track `fingerprintChangeCount` and store the last few fingerprint transition reasons. If this count rises without visible agenda changes, keep `phase2Readiness` blocked until the source of churn is fixed. Recommended transition diagnostic: @@ -1364,7 +1364,7 @@ Expired leases are ignored by `SyncDecisionPolicy`. ### 10.4 Shadow Would-Nudge Semantics -Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send. Production composition enforces this by default by not wiring `outboxStore`/`inboxNudge` unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1`. +Read-only status and diagnostics may compute `wouldNudgeCount`, but must not enqueue or send. Production composition always wires the outbox and inbox nudge sink; side effects stay limited to queued reconcile planning and dispatcher delivery guards. `wouldNudge` is true only when all are true: @@ -1911,7 +1911,7 @@ Preferred Phase 1 read surface: - extend `task_briefing` with a compact `workSync` block; - include current `agendaFingerprint`; - include a short actionable agenda preview; -- include report instructions only when the feature is enabled. +- include report instructions only when the report tool is available. Example `task_briefing` addition: @@ -2235,7 +2235,7 @@ Dispatcher revalidation: Before inserting an inbox nudge, dispatcher must re-read: -- feature gate state; +- current `phase2Readiness`; - current roster membership; - current agenda fingerprint; - latest accepted report; @@ -2729,9 +2729,9 @@ busy suppressions: 8 ### 18.1 Phase 2 Entry Thresholds -Do not enable nudges until shadow metrics are stable. +Do not let nudges deliver until shadow metrics are stable and `phase2Readiness=shadow_ready`. -Recommended gates: +Recommended guardrails: | Metric | Target before Phase 2 | |---|---:| @@ -2742,7 +2742,7 @@ Recommended gates: | busy suppression correctness | no known prompt during active tool/runtime turn | | report intent replay errors | 0 lost accepted reports | -If a metric misses the target, keep Phase 2 disabled and fix the specific source of noise. Do not compensate with a shorter lease or more nudges. +If a metric misses the target, keep `phase2Readiness` blocked/collecting and fix the specific source of noise. Do not compensate with a shorter lease or more nudges. --- @@ -3039,7 +3039,8 @@ No accelerator is proof. Current implementation: - tool-finish enqueue and tool-activity busy suppression are implemented through `TeamChangeEvent` and the feature-owned busy signal; -- Claude Stop hook and OpenCode turn-settled hooks are intentionally not wired yet because the current feature boundary does not expose one authoritative cross-provider "turn settled and idle" signal. Adding an adapter around prompt text, idle notifications, or provider-specific transcript heuristics would be less reliable than the current tool-finish + scheduled reconcile path; +- Claude Stop hook settings are wired through the `member-work-sync` feature facade into `TeamProvisioningService`, then merged into Anthropic launch settings; +- Codex native and OpenCode turn-settled signals are wired through provider-specific runtime env/spool emitters, shared payload normalization, `RuntimeTurnSettledIngestor`, and the feature-owned drain scheduler. OpenCode bridge env receives the spool root before `OpenCodeBridgeCommandClient` construction; - manual "sync now" remains optional because details/status reads are passive by design, and explicit manual nudges should reuse the existing outbox/dispatcher instead of bypassing readiness guards. --- @@ -3070,7 +3071,7 @@ Do not add: - `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED` - `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY` -- `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED` +- any new member-work-sync nudge env or feature flag If Phase 1 needs to be disabled during development, revert or patch the narrow composition wiring. Do not add a permanent product branch for a passive feature. @@ -3088,7 +3089,7 @@ const MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS = 10 * 60_000; const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; ``` -If we ever need an emergency kill switch for production nudges, it must only wrap the Phase 2 dispatcher. It must not disable agenda/status/report validation. +No config/env kill switch is part of the design. Production nudge safety must come from dispatcher guards and typed runtime defaults, without disabling agenda/status/report validation. --- @@ -3238,7 +3239,7 @@ Phase 2 must not start if any of these are true: - report validation can accept leases with claimed `from` only and no trusted identity/report token; - queue can run more than one reconcile for the same member concurrently; - watchdog cooldown integration is untested; -- outbox dispatcher can send while feature is disabled; +- outbox dispatcher can send without `shadow_ready`; - outbox dispatcher can send for a stopped/cancelled team; - pending intent replay can turn stale reports into accepted leases; - fingerprint transition diagnostics are missing; @@ -3379,8 +3380,8 @@ Step order: - Writes status only through `MemberWorkSyncStatusStorePort`. - Does not send any message or call runtime delivery. -10. Add shadow trigger wiring behind feature gate. - - Default shadow status can be on, but all side effects are status-only. +10. Add shadow trigger wiring without an env gate. + - Default shadow status is on, while read-only status/diagnostics stay passive. - Use quiet-window queue and bounded concurrency. - Wire broad team/task change events only after domain tests are green. - Drop queued entries when team/member is removed or stopped. @@ -3536,12 +3537,12 @@ Cut 3 stop criteria: ### 27.4 Phase 2: Nudges Later, Separate Work -Do not start Phase 2 until shadow metrics prove low noise. +Do not rely on Phase 2 delivery until shadow metrics prove low noise. Phase 2 sequence: 1. Add outbox schema and idempotency key. -2. Add dispatcher with feature gate default off. +2. Add dispatcher active by default behind internal guards. 3. Add stale revalidation before dispatch. 4. Add watchdog cooldown integration. 5. Add one-in-flight per `(teamName, memberName, fingerprint)`. diff --git a/docs/team-management/member-work-sync-opencode-turn-settled-plan.md b/docs/team-management/member-work-sync-opencode-turn-settled-plan.md index 37cb7355..9cb5b496 100644 --- a/docs/team-management/member-work-sync-opencode-turn-settled-plan.md +++ b/docs/team-management/member-work-sync-opencode-turn-settled-plan.md @@ -248,7 +248,7 @@ Rules: - turn-settled does not directly nudge; - turn-settled does not count as meaningful task progress; - watchdog cooldowns still prevent duplicate nudges; -- existing `member-work-sync` nudge side-effects gate remains the only way to deliver sync nudges. +- `member-work-sync` dispatcher remains the only path that can deliver sync nudges, and it must pass its internal guards first. --- @@ -2039,7 +2039,7 @@ More reconcile triggers could expose existing Phase 2 nudges. Mitigation: -- current nudge side effects remain gated by `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED`; +- nudges are active by default, but delivery remains bounded by dispatcher guards; - queue quiet window debounces events; - outbox has one item per fingerprint; - dispatcher revalidates busy/watchdog cooldown before delivery. @@ -2472,7 +2472,7 @@ and a valid OpenCode turn-settled event file when drainRuntimeTurnSettledEvents runs then queue receives member-turn-settled and member-work-sync status is recomputed -and no direct nudge is sent unless existing nudge side-effects are enabled +and no direct nudge is sent outside the existing outbox/dispatcher path ``` ### 13.4 Live E2E Prototype Test diff --git a/docs/team-management/member-work-sync-runtime-stop-hook-plan.md b/docs/team-management/member-work-sync-runtime-stop-hook-plan.md index a15dc7c4..ab8963d4 100644 --- a/docs/team-management/member-work-sync-runtime-stop-hook-plan.md +++ b/docs/team-management/member-work-sync-runtime-stop-hook-plan.md @@ -1,7 +1,7 @@ # Member Work Sync Runtime Stop Hook Plan -**Status:** design ready, not implemented -**Scope:** `member-work-sync`, Claude runtime hook integration, future Codex hook adapter +**Status:** implemented for provider-neutral spool/drain, Claude Stop hook settings, and Codex/OpenCode turn-settled adapters +**Scope:** `member-work-sync`, Claude runtime hook integration, Codex/OpenCode runtime turn-settled adapters **Primary repo:** `claude_team` **Secondary dependency:** `agent_teams_orchestrator` runtime hook payload contract **Feature name:** `member-work-sync` @@ -33,15 +33,15 @@ Recommended approach: Why this is the safest direction: - `Stop` is a useful "turn settled" signal, but not proof that work is complete. -- `TeammateIdle` is not used as the base because it is Claude/team-specific and does not generalize to future Codex runtime hooks. +- `TeammateIdle` is not used as the base because it is Claude/team-specific and does not generalize to Codex/OpenCode turn-settled adapters. - Hook execution must be fast, non-blocking, and fail-open. - The existing `member-work-sync` agenda fingerprint, lease, cooldown, busy signal, and watchdog separation remain authoritative. -- Codex can be added later by implementing a second provider adapter that emits the same normalized event contract. +- Codex/OpenCode use provider adapters that emit the same normalized event contract. Architecture checkpoint: - No blocker question is required before implementation. The safest defaults are clear. -- The only intentionally deferred decision is production Codex installation. Codex receives a tested adapter seam, but no production launch behavior until its hook payload/config contract is verified. +- Codex/OpenCode production wiring is fail-open: missing runtime turn-settled env disables telemetry for that provider process, but does not fail team launch or prompt delivery. - The implementation should be done as an extension of `member-work-sync`, not as ad hoc logic inside `TeamProvisioningService`. - The hook pipeline is an input signal. It must not become a second watchdog, a second delivery ledger, or a runtime liveness detector. @@ -174,7 +174,7 @@ Reason: - Existing leases, fingerprints, cooldowns, and watchdog separation still decide behavior. - A flag would add another state combination without reducing the main risk, which is attribution correctness. -If an emergency kill switch is needed later, prefer an internal config/env-only disable around hook settings generation, not branching inside the core policy. +If emergency disable behavior is needed later, patch the narrow hook composition wiring directly rather than adding a permanent config/env flag or branching inside the core policy. --- diff --git a/src/features/member-work-sync/contracts/ipc.ts b/src/features/member-work-sync/contracts/ipc.ts index 5b4e070f..6dd15784 100644 --- a/src/features/member-work-sync/contracts/ipc.ts +++ b/src/features/member-work-sync/contracts/ipc.ts @@ -1,3 +1,4 @@ export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus'; +export const MEMBER_WORK_SYNC_REFRESH_STATUS = 'member-work-sync:refreshStatus'; export const MEMBER_WORK_SYNC_GET_METRICS = 'member-work-sync:getMetrics'; export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report'; diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts index 7e70b52f..8267dd79 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts @@ -1,16 +1,48 @@ -import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; +import { decideMemberWorkSyncStatus } from '../domain'; +import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler'; import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; export class MemberWorkSyncDiagnosticsReader { - private readonly reconciler: MemberWorkSyncReconciler; - - constructor(deps: MemberWorkSyncUseCaseDeps) { - this.reconciler = new MemberWorkSyncReconciler(deps); - } + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} async execute(request: MemberWorkSyncStatusRequest): Promise { - return this.reconciler.execute(request); + const stored = await this.deps.statusStore.read(request); + if (stored) { + return stored; + } + + const source = await this.deps.agendaSource.loadAgenda(request); + const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); + const nowIso = this.deps.clock.now().toISOString(); + const teamActive = this.deps.lifecycle + ? await this.deps.lifecycle.isTeamActive(agenda.teamName) + : true; + const decision = decideMemberWorkSyncStatus({ + agenda, + nowIso, + inactive: source.inactive || !teamActive, + }); + + return { + teamName: agenda.teamName, + memberName: agenda.memberName, + state: decision.state, + agenda, + shadow: { + reconciledBy: 'request', + wouldNudge: false, + fingerprintChanged: false, + }, + evaluatedAt: nowIso, + diagnostics: [ + ...agenda.diagnostics, + ...(!teamActive ? ['team_runtime_inactive'] : []), + ...decision.diagnostics, + 'status_snapshot_not_persisted', + ], + ...(source.providerId ? { providerId: source.providerId } : {}), + }; } } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index eb98d87f..c41fdd28 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -1,4 +1,6 @@ import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit'; +import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler'; +import { decideMemberWorkSyncStatus } from '../domain'; import type { MemberWorkSyncOutboxItem } from '../../contracts'; import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports'; @@ -188,21 +190,41 @@ export class MemberWorkSyncNudgeDispatcher { ): Promise< { ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } > { - if (this.deps.lifecycle && !(await this.deps.lifecycle.isTeamActive(item.teamName))) { + const teamActive = this.deps.lifecycle + ? await this.deps.lifecycle.isTeamActive(item.teamName) + : true; + if (!teamActive) { return { ok: false, reason: 'team_inactive', retryable: false }; } - const status = await this.deps.statusStore.read({ + const previous = await this.deps.statusStore.read({ teamName: item.teamName, memberName: item.memberName, }); - if (!status) { + if (!previous) { return { ok: false, reason: 'status_missing', retryable: false }; } + + let source; + try { + source = await this.deps.agendaSource.loadAgenda({ + teamName: item.teamName, + memberName: item.memberName, + }); + } catch (error) { + return { ok: false, reason: `agenda_revalidation_failed:${String(error)}`, retryable: true }; + } + const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); + const decision = decideMemberWorkSyncStatus({ + agenda, + latestAcceptedReport: previous.report?.accepted ? previous.report : null, + nowIso, + inactive: source.inactive || !teamActive, + }); if ( - status.state !== 'needs_sync' || - status.shadow?.wouldNudge !== true || - status.agenda.fingerprint !== item.agendaFingerprint + decision.state !== 'needs_sync' || + agenda.items.length === 0 || + agenda.fingerprint !== item.agendaFingerprint ) { return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false }; } diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index c29a018c..7c8bb66a 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -58,6 +58,22 @@ function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set ); } +function isLeadLike(member: MemberWorkSyncMemberLike): boolean { + const name = normalizeMemberName(member.name); + const agentType = typeof member.agentType === 'string' ? member.agentType : ''; + return ( + name === 'team-lead' || + agentType === 'team-lead' || + agentType === 'lead' || + agentType === 'orchestrator' + ); +} + +function getActiveLeadName(members: MemberWorkSyncMemberLike[]): string | null { + const lead = members.find((member) => !member.removedAt && isLeadLike(member)); + return lead ? normalizeMemberName(lead.name) : null; +} + function buildBaseItem( task: MemberWorkSyncTaskLike, memberName: string @@ -70,12 +86,23 @@ function buildBaseItem( }; } +function taskReferenceKeys(task: Pick): string[] { + const keys = [task.id, task.displayId] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))]; +} + export function buildActionableWorkAgenda( input: BuildActionableWorkAgendaInput ): MemberWorkSyncAgenda { const memberName = normalizeMemberName(input.memberName); const diagnostics: string[] = []; const activeMemberNames = getActiveMemberNames(input.members); + const activeLeadName = getActiveLeadName(input.members); + const tasksByReference = new Map( + input.tasks.flatMap((task) => taskReferenceKeys(task).map((key) => [key, task] as const)) + ); if (!memberName || isReservedMemberName(memberName)) { diagnostics.push('member_invalid_or_reserved'); @@ -95,6 +122,57 @@ export function buildActionableWorkAgenda( const base = buildBaseItem(task, memberName); const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort(); const blocks = [...(task.blocks ?? [])].filter(Boolean).sort(); + const brokenDependencyIds: string[] = []; + const waitingDependencyIds: string[] = []; + for (const dependencyId of blockedBy) { + const dependency = tasksByReference.get(dependencyId) ?? null; + if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) { + brokenDependencyIds.push(dependencyId); + } else if (dependency.status !== 'completed') { + waitingDependencyIds.push(dependencyId); + } + } + + if ( + activeLeadName && + sameMemberName(activeLeadName, memberName) && + task.needsClarification === 'lead' + ) { + items.push({ + ...base, + kind: 'clarification', + priority: 'needs_clarification', + reason: 'task_needs_lead_clarification', + evidence: { + status: task.status, + ...(owner ? { owner } : {}), + ...(task.reviewState ? { reviewState: task.reviewState } : {}), + needsClarification: 'lead', + }, + }); + continue; + } + + if ( + activeLeadName && + sameMemberName(activeLeadName, memberName) && + brokenDependencyIds.length > 0 + ) { + items.push({ + ...base, + kind: 'blocked_dependency', + priority: 'blocked', + reason: 'task_has_broken_dependency', + evidence: { + status: task.status, + ...(owner ? { owner } : {}), + ...(task.reviewState ? { reviewState: task.reviewState } : {}), + blockedByTaskIds: brokenDependencyIds, + ...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}), + }, + }); + continue; + } const reviewOwner = resolveCurrentReviewOwner({ reviewState: task.reviewState, @@ -126,44 +204,28 @@ export function buildActionableWorkAgenda( } if (task.needsClarification === 'lead' || task.needsClarification === 'user') { - items.push({ - ...base, - kind: 'clarification', - priority: 'needs_clarification', - reason: `task_needs_${task.needsClarification}_clarification`, - evidence: { - status: task.status, - owner: memberName, - ...(task.reviewState ? { reviewState: task.reviewState } : {}), - needsClarification: task.needsClarification, - }, - }); continue; } - if (blockedBy.length > 0) { - items.push({ - ...base, - kind: 'blocked_dependency', - priority: 'blocked', - reason: 'owned_task_has_blocked_dependency', - evidence: { - status: task.status, - owner: memberName, - ...(task.reviewState ? { reviewState: task.reviewState } : {}), - blockedByTaskIds: blockedBy, - ...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}), - }, - }); + if (waitingDependencyIds.length > 0 || brokenDependencyIds.length > 0) { continue; } - if (task.status === 'pending' || task.status === 'in_progress') { + if ( + task.status === 'pending' || + task.status === 'in_progress' || + task.reviewState === 'needsFix' + ) { items.push({ ...base, kind: 'work', priority: 'normal', - reason: task.status === 'pending' ? 'owned_pending_task' : 'owned_in_progress_task', + reason: + task.reviewState === 'needsFix' + ? 'review_changes_requested' + : task.status === 'pending' + ? 'owned_pending_task' + : 'owned_in_progress_task', evidence: { status: task.status, owner: memberName, diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts index 4b48a074..5fc61d7d 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts @@ -38,7 +38,9 @@ export function buildMemberWorkSyncNudgeId(input: { ].join(':'); } -export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload { +export function buildMemberWorkSyncNudgePayload( + status: MemberWorkSyncStatus +): MemberWorkSyncNudgePayload { const taskRefs = status.agenda.items.map((item) => ({ teamName: status.teamName, taskId: item.taskId, @@ -48,6 +50,7 @@ export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): M .slice(0, 3) .map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`) .join('; '); + const taskIds = status.agenda.items.map((item) => item.taskId).filter(Boolean); return { from: 'system', @@ -59,7 +62,13 @@ export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): M text: [ 'Work sync check: you have current actionable work assigned.', preview ? `Current agenda: ${preview}.` : '', - 'Continue concrete task work, report a real blocker with task tools, or call member_work_sync_report for the current fingerprint.', + `Required sync action: call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then call member_work_sync_report with the same teamName/memberName and the returned agendaFingerprint and reportToken.`, + taskIds.length + ? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` + : '', + `Do not use provider names, runtime names, or team names as memberName; use exactly "${status.memberName}".`, + 'If you are still working, report state "still_working"; if you are blocked, report state "blocked" and record the blocker on the task.', + 'Continue concrete task work, report a real blocker with task tools, or sync your current fingerprint before going idle.', 'Do not reply only with acknowledgement.', ] .filter(Boolean) diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts index 9aa26760..f763fb74 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts @@ -38,15 +38,32 @@ function agendaHasBlockedEvidence( agenda: MemberWorkSyncAgenda, taskIds: string[] | undefined ): boolean { - const targetIds = new Set((taskIds ?? []).filter(Boolean)); + const targetIds = new Set((taskIds ?? []).flatMap(taskReferenceKeys)); return agenda.items.some((item) => { - if (targetIds.size > 0 && !targetIds.has(item.taskId)) { + if ( + targetIds.size > 0 && + !taskReferenceKeys(item).some((reference) => targetIds.has(reference)) + ) { return false; } return item.kind === 'blocked_dependency' || item.priority === 'blocked'; }); } +function taskReferenceKeys( + task: Pick | string +): string[] { + const values = typeof task === 'string' ? [task] : [task.taskId, task.displayId]; + return [ + ...new Set( + values + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)) + .flatMap((value) => [value, value.replace(/^#/, '')]) + ), + ]; +} + export function validateMemberWorkSyncReport(input: { request: MemberWorkSyncReportRequest; agenda: MemberWorkSyncAgenda; @@ -92,9 +109,9 @@ export function validateMemberWorkSyncReport(input: { }; } - const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId)); + const agendaTaskIds = new Set(input.agenda.items.flatMap(taskReferenceKeys)); for (const taskId of input.request.taskIds ?? []) { - if (!agendaTaskIds.has(taskId)) { + if (!taskReferenceKeys(taskId).some((reference) => agendaTaskIds.has(reference))) { return { ok: false, code: 'foreign_task_id', diff --git a/src/features/member-work-sync/core/domain/currentReviewCycle.ts b/src/features/member-work-sync/core/domain/currentReviewCycle.ts index e962649d..47261959 100644 --- a/src/features/member-work-sync/core/domain/currentReviewCycle.ts +++ b/src/features/member-work-sync/core/domain/currentReviewCycle.ts @@ -50,6 +50,14 @@ export function resolveCurrentReviewOwner(input: { return null; } + const kanbanReviewer = normalizeMemberName(input.kanbanReviewer); + if (kanbanReviewer) { + return { + reviewer: kanbanReviewer, + historyEventIds: [], + }; + } + const latestStarted = [...historyEvents] .reverse() .find((event) => event.type === 'review_started'); @@ -58,9 +66,7 @@ export function resolveCurrentReviewOwner(input: { .find((event) => event.type === 'review_requested'); const reviewer = - normalizeMemberName(latestStarted?.actor) || - normalizeMemberName(latestRequested?.reviewer) || - normalizeMemberName(input.kanbanReviewer); + normalizeMemberName(latestStarted?.actor) || normalizeMemberName(latestRequested?.reviewer); if (!reviewer) { return null; diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts new file mode 100644 index 00000000..8b0835bb --- /dev/null +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts @@ -0,0 +1,191 @@ +import { isLeadMember } from '@shared/utils/leadDetection'; + +import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain'; + +import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; +import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; +import type { TeamTask } from '@shared/types'; + +export interface MemberWorkSyncTaskImpactResolverDeps { + taskReader: Pick; + kanbanManager: Pick; + activeMemberSource: { + loadActiveMemberNames(teamName: string): Promise; + }; +} + +export interface MemberWorkSyncTaskImpactResolverResult { + memberNames: string[]; + fallbackTeamWide: boolean; + diagnostics: string[]; +} + +function isTerminalTask(task: Pick): boolean { + return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt); +} + +function isDeletedTask(task: Pick): boolean { + return task.status === 'deleted' || Boolean(task.deletedAt); +} + +function taskMatchesId(task: TeamTask, taskId: string): boolean { + const normalized = taskId.trim().replace(/^#/, ''); + return ( + task.id === taskId || + task.id === normalized || + task.displayId === taskId || + task.displayId === normalized || + task.displayId === `#${normalized}` + ); +} + +function taskReferenceKeys(task: Pick): string[] { + const keys = [task.id, task.displayId] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))]; +} + +function findLeadMemberName(activeMembers: string[]): string | null { + return activeMembers.find((memberName) => isLeadMember({ name: memberName })) ?? null; +} + +export function extractMemberWorkSyncTaskId(input: { + taskId?: string; + detail?: string; +}): string | null { + const explicit = input.taskId?.trim(); + if (explicit) { + return explicit; + } + + const detail = input.detail?.trim(); + if (!detail || detail.startsWith('.') || !detail.endsWith('.json')) { + return null; + } + + const fileName = detail.split(/[\\/]/).filter(Boolean).at(-1); + const taskId = fileName?.replace(/\.json$/i, '').trim(); + return taskId && !taskId.startsWith('.') ? taskId : null; +} + +export class MemberWorkSyncTaskImpactResolver { + constructor(private readonly deps: MemberWorkSyncTaskImpactResolverDeps) {} + + async resolve(input: { + teamName: string; + taskId: string; + }): Promise { + const taskId = input.taskId.trim(); + if (!taskId) { + return { + memberNames: [], + fallbackTeamWide: true, + diagnostics: ['task_id_missing'], + }; + } + + const [activeMembers, tasks, kanban] = await Promise.all([ + this.deps.activeMemberSource.loadActiveMemberNames(input.teamName), + this.deps.taskReader.getTasks(input.teamName), + this.deps.kanbanManager.getState(input.teamName), + ]); + const activeByName = new Map( + activeMembers.map((memberName) => [normalizeMemberName(memberName), memberName] as const) + ); + const impacted = new Set(); + const diagnostics: string[] = []; + const addDiagnostic = (diagnostic: string): void => { + if (!diagnostics.includes(diagnostic)) { + diagnostics.push(diagnostic); + } + }; + const addMember = (value: unknown): void => { + const normalized = normalizeMemberName(value); + const activeName = activeByName.get(normalized); + if (activeName) { + impacted.add(activeName); + } + }; + const addLead = (): void => { + const leadName = findLeadMemberName(activeMembers); + if (leadName) { + impacted.add(leadName); + } else { + addDiagnostic('lead_member_unavailable'); + } + }; + + const task = tasks.find((candidate) => taskMatchesId(candidate, taskId)); + if (!task) { + return { + memberNames: [], + fallbackTeamWide: true, + diagnostics: ['task_not_found'], + }; + } + + addMember(task.owner); + + const reviewOwner = resolveCurrentReviewOwner({ + reviewState: task.reviewState, + kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null, + historyEvents: task.historyEvents, + }); + addMember(reviewOwner?.reviewer); + + if (!normalizeMemberName(task.owner)) { + addLead(); + addDiagnostic('task_owner_missing'); + } else if (!activeByName.has(normalizeMemberName(task.owner))) { + addLead(); + addDiagnostic('task_owner_inactive'); + } + + if (task.reviewState === 'review' && !reviewOwner?.reviewer) { + addLead(); + addDiagnostic('task_reviewer_missing'); + } + + if (task.needsClarification === 'lead') { + addLead(); + } + + const tasksByReference = new Map( + tasks.flatMap((candidate) => + taskReferenceKeys(candidate).map((key) => [key, candidate] as const) + ) + ); + const brokenDependencies = (task.blockedBy ?? []).filter((dependencyId) => { + const dependency = tasksByReference.get(dependencyId); + return !dependency || isDeletedTask(dependency); + }); + if (brokenDependencies.length > 0) { + addLead(); + addDiagnostic('task_has_broken_dependencies'); + } + + for (const candidate of tasks) { + if (candidate.id === task.id || isTerminalTask(candidate)) { + continue; + } + if ( + (candidate.blockedBy ?? []).some( + (dependencyId) => tasksByReference.get(dependencyId) === task + ) + ) { + addMember(candidate.owner); + if (isDeletedTask(task)) { + addLead(); + addDiagnostic('dependent_task_has_deleted_dependency'); + } + } + } + + return { + memberNames: [...impacted].sort((left, right) => left.localeCompare(right)), + fallbackTeamWide: false, + diagnostics, + }; + } +} diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts index 110e8c88..0beef1fc 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts @@ -2,7 +2,9 @@ import type { MemberWorkSyncEventQueue, MemberWorkSyncTriggerReason, } from '../../infrastructure/MemberWorkSyncEventQueue'; +import type { MemberWorkSyncTaskImpactResolver } from './MemberWorkSyncTaskImpactResolver'; import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; +import { extractMemberWorkSyncTaskId } from './MemberWorkSyncTaskImpactResolver'; interface MemberTurnSettledEventPayload { memberName?: string; @@ -20,8 +22,6 @@ interface MemberWorkSyncMemberStorageMaterializer { const TEAM_WIDE_REASONS: Partial> = { config: 'config_changed', - task: 'task_changed', - 'task-log-change': 'runtime_activity', 'log-source-change': 'runtime_activity', process: 'runtime_activity', 'lead-activity': 'runtime_activity', @@ -63,7 +63,8 @@ export class MemberWorkSyncTeamChangeRouter { constructor( private readonly rosterSource: MemberWorkSyncRosterSource, private readonly queue: MemberWorkSyncEventQueue, - private readonly materializer?: MemberWorkSyncMemberStorageMaterializer + private readonly materializer?: MemberWorkSyncMemberStorageMaterializer, + private readonly taskImpactResolver?: MemberWorkSyncTaskImpactResolver ) {} async enqueueStartupScan(teamNames: string[]): Promise { @@ -118,6 +119,14 @@ export class MemberWorkSyncTeamChangeRouter { return; } + if (event.type === 'task' || event.type === 'task-log-change') { + const triggerReason = event.type === 'task' ? 'task_changed' : 'runtime_activity'; + void this.enqueueTaskRelatedMembers(event, triggerReason).catch(() => + this.enqueueTeam(event.teamName, triggerReason).catch(() => undefined) + ); + return; + } + if (event.type === 'inbox' || event.type === 'lead-message') { const recipient = parseInboxRecipient(event.detail); if (recipient) { @@ -152,4 +161,41 @@ export class MemberWorkSyncTeamChangeRouter { this.queue.enqueue({ teamName, memberName, triggerReason, runAfterMs }); } } + + private async enqueueTaskRelatedMembers( + event: TeamChangeEvent, + triggerReason: MemberWorkSyncTriggerReason + ): Promise { + const taskId = extractMemberWorkSyncTaskId({ + taskId: event.taskId, + detail: event.detail, + }); + if (!taskId || !this.taskImpactResolver) { + await this.enqueueTeam(event.teamName, triggerReason); + return; + } + + const impact = await this.taskImpactResolver.resolve({ + teamName: event.teamName, + taskId, + }); + if (impact.fallbackTeamWide) { + await this.enqueueTeam(event.teamName, triggerReason); + return; + } + if (this.materializer) { + await Promise.allSettled( + impact.memberNames.map((memberName) => + this.materializer?.materializeMember(event.teamName, memberName) + ) + ); + } + for (const memberName of impact.memberNames) { + this.queue.enqueue({ + teamName: event.teamName, + memberName, + triggerReason, + }); + } + } } diff --git a/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts index 57b9463f..be32c8f7 100644 --- a/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts +++ b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts @@ -3,6 +3,7 @@ import { createLogger } from '@shared/utils/logger'; import { MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REFRESH_STATUS, MEMBER_WORK_SYNC_REPORT, type MemberWorkSyncMetricsRequest, type MemberWorkSyncReportRequest, @@ -45,6 +46,18 @@ export function registerMemberWorkSyncIpc( } ); + ipcMain.handle( + MEMBER_WORK_SYNC_REFRESH_STATUS, + async (_event, request: MemberWorkSyncStatusRequest): Promise => { + try { + return await feature.refreshStatus(request); + } catch (error) { + logger.error('Failed to refresh member work sync status', error); + throw error; + } + } + ); + ipcMain.handle( MEMBER_WORK_SYNC_REPORT, async (_event, request: MemberWorkSyncReportRequest): Promise => { @@ -60,6 +73,7 @@ export function registerMemberWorkSyncIpc( export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void { ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS); + ipcMain.removeHandler(MEMBER_WORK_SYNC_REFRESH_STATUS); ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS); ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT); } diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 1d758fc5..77f0e4d9 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -13,6 +13,7 @@ import { type RuntimeTurnSettledTargetResolverPort, } from '../../core/application'; import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; +import { MemberWorkSyncTaskImpactResolver } from '../adapters/input/MemberWorkSyncTaskImpactResolver'; import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver'; import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; @@ -53,34 +54,6 @@ import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaSt import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; import type { TeamChangeEvent } from '@shared/types'; -export const MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV = - 'CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED'; - -const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); -const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off', '']); - -function emptyNudgeDispatchSummary(): MemberWorkSyncNudgeDispatchSummary { - return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 }; -} - -export function resolveMemberWorkSyncNudgeSideEffectsEnabled( - env: Record = process.env -): boolean { - const rawValue = env[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]; - if (rawValue == null) { - return false; - } - - const value = rawValue.trim().toLowerCase(); - if (TRUE_ENV_VALUES.has(value)) { - return true; - } - if (FALSE_ENV_VALUES.has(value)) { - return false; - } - return false; -} - export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: { teamsBasePath: string; provider: RuntimeTurnSettledProvider; @@ -92,6 +65,7 @@ export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: { export interface MemberWorkSyncFeatureFacade { getStatus(request: MemberWorkSyncStatusRequest): Promise; + refreshStatus(request: MemberWorkSyncStatusRequest): Promise; getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; noteTeamChange(event: TeamChangeEvent): void; @@ -117,7 +91,6 @@ export function createMemberWorkSyncFeature(deps: { membersMetaStore: TeamMembersMetaStore; isTeamActive?: (teamName: string) => Promise | boolean; listLifecycleActiveTeamNames?: () => Promise; - nudgeSideEffectsEnabled?: boolean; queueQuietWindowMs?: number; runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort; logger?: MemberWorkSyncLoggerPort; @@ -166,17 +139,15 @@ export function createMemberWorkSyncFeature(deps: { const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath); const busySignal = new MemberWorkSyncToolActivityBusySignal(); - const nudgeSideEffectsEnabled = - deps.nudgeSideEffectsEnabled ?? resolveMemberWorkSyncNudgeSideEffectsEnabled(); - const inboxNudge = nudgeSideEffectsEnabled ? new TeamInboxMemberWorkSyncNudgeSink() : null; + const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink(); const useCaseDeps = { clock, hash, agendaSource, statusStore: store, reportStore: store, - ...(nudgeSideEffectsEnabled ? { outboxStore: store } : {}), - ...(inboxNudge ? { inboxNudge } : {}), + outboxStore: store, + inboxNudge, watchdogCooldown, busySignal, reportToken, @@ -193,22 +164,30 @@ export function createMemberWorkSyncFeature(deps: { const queue = new MemberWorkSyncEventQueue({ reconcile: async (request, context: MemberWorkSyncReconcileContext) => { await reconciler.execute(request, context); - if (nudgeSideEffectsEnabled) { - await nudgeDispatcher.dispatchDue({ - teamNames: [request.teamName], - claimedBy: `member-work-sync:${process.pid}`, - }); - } + await nudgeDispatcher.dispatchDue({ + teamNames: [request.teamName], + claimedBy: `member-work-sync:${process.pid}`, + }); }, isTeamActive: deps.isTeamActive ?? (() => true), ...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}), auditJournal, logger: deps.logger, }); - const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue, { - materializeMember: (teamName, memberName) => - storePaths.ensureMemberWorkSyncDir(teamName, memberName), + const taskImpactResolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: deps.taskReader, + kanbanManager: deps.kanbanManager, + activeMemberSource: agendaSource, }); + const router = new MemberWorkSyncTeamChangeRouter( + agendaSource, + queue, + { + materializeMember: (teamName, memberName) => + storePaths.ensureMemberWorkSyncDir(teamName, memberName), + }, + taskImpactResolver + ); const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({ eventStore: runtimeTurnSettledStore, normalizer: runtimeTurnSettledNormalizer, @@ -234,23 +213,23 @@ export function createMemberWorkSyncFeature(deps: { drain: () => runtimeTurnSettledIngestor.drainPending(), logger: deps.logger, }); - const nudgeDispatchScheduler = - nudgeSideEffectsEnabled && deps.listLifecycleActiveTeamNames - ? new MemberWorkSyncNudgeDispatchScheduler({ - listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames, - dispatchDue: (teamNames) => - nudgeDispatcher.dispatchDue({ - teamNames, - claimedBy: `member-work-sync:${process.pid}:scheduled`, - }), - logger: deps.logger, - }) - : null; + const nudgeDispatchScheduler = deps.listLifecycleActiveTeamNames + ? new MemberWorkSyncNudgeDispatchScheduler({ + listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames, + dispatchDue: (teamNames) => + nudgeDispatcher.dispatchDue({ + teamNames, + claimedBy: `member-work-sync:${process.pid}:scheduled`, + }), + logger: deps.logger, + }) + : null; runtimeTurnSettledDrainScheduler.start(); nudgeDispatchScheduler?.start(); return { getStatus: (request) => diagnosticsReader.execute(request), + refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }), getMetrics: (request) => metricsReader.execute(request), report: (request) => reporter.execute(request), noteTeamChange: (event) => { @@ -277,12 +256,10 @@ export function createMemberWorkSyncFeature(deps: { ); }, dispatchDueNudges: (teamNames) => - nudgeSideEffectsEnabled - ? nudgeDispatcher.dispatchDue({ - teamNames, - claimedBy: `member-work-sync:${process.pid}`, - }) - : Promise.resolve(emptyNudgeDispatchSummary()), + nudgeDispatcher.dispatchDue({ + teamNames, + claimedBy: `member-work-sync:${process.pid}`, + }), buildRuntimeTurnSettledHookSettings: async ({ provider }) => runtimeTurnSettledSpool.buildHookSettings({ provider }), buildRuntimeTurnSettledEnvironment: async ({ provider }) => diff --git a/src/features/member-work-sync/main/index.ts b/src/features/member-work-sync/main/index.ts index bed28883..3adc9bf8 100644 --- a/src/features/member-work-sync/main/index.ts +++ b/src/features/member-work-sync/main/index.ts @@ -7,6 +7,4 @@ export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWork export { buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, - MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, - resolveMemberWorkSyncNudgeSideEffectsEnabled, } from './composition/createMemberWorkSyncFeature'; diff --git a/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts b/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts index c8d5bc23..23a51dc1 100644 --- a/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts +++ b/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts @@ -139,7 +139,10 @@ export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncRepor return existing; } - const next = this.loadOrCreateSecret(teamName); + const next = this.loadOrCreateSecret(teamName).catch((error: unknown) => { + this.secretCache.delete(teamName); + throw error; + }); this.secretCache.set(teamName, next); return next; } @@ -153,7 +156,8 @@ export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncRepor } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; + // A corrupt token secret only affects short-lived proof tokens. Regenerate it so + // member work sync can recover without requiring an app restart. } } diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts index 82436b55..074851d6 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts @@ -24,20 +24,57 @@ export interface MemberWorkSyncQueueDiagnostics { reconciled: number; dropped: number; failed: number; + nextRunAt?: string; + oldestQueuedAgeMs?: number; + oldestRunningAgeMs?: number; + queuedItems: MemberWorkSyncQueuedItemDiagnostics[]; + runningItems: MemberWorkSyncRunningItemDiagnostics[]; +} + +export interface MemberWorkSyncQueuedItemDiagnostics { + teamName: string; + memberName: string; + firstQueuedAt: string; + lastQueuedAt: string; + runAt: string; + maxRunAt: string; + triggerReasons: MemberWorkSyncTriggerReason[]; + triggerReasonCounts: Partial>; +} + +export interface MemberWorkSyncRunningItemDiagnostics { + teamName: string; + memberName: string; + startedAt: string; + ageMs: number; + rerunRequested: boolean; + triggerReasons: MemberWorkSyncTriggerReason[]; } interface QueueItem { teamName: string; memberName: string; + firstQueuedAt: number; + lastQueuedAt: number; runAt: number; + maxRunAt: number; triggerReasons: Set; + triggerReasonCounts: Map; } interface RunningItem { + teamName: string; + memberName: string; + startedAt: number; rerunRequested: boolean; triggerReasons: Set; } +interface TriggerTimingPolicy { + runAfterMs: number; + maxCoalesceWaitMs: number; +} + export interface MemberWorkSyncEventQueueDeps { reconcile( input: { teamName: string; memberName: string }, @@ -45,6 +82,7 @@ export interface MemberWorkSyncEventQueueDeps { ): Promise; isTeamActive(teamName: string): Promise | boolean; quietWindowMs?: number; + triggerTiming?: Partial>>; concurrency?: number; now?: () => number; nowIso?: () => string; @@ -85,6 +123,29 @@ export class MemberWorkSyncEventQueue { this.nowIso = deps.nowIso ?? (() => new Date().toISOString()); } + private resolveTimingPolicy( + triggerReason: MemberWorkSyncTriggerReason, + explicitRunAfterMs?: number + ): TriggerTimingPolicy { + const custom = this.deps.triggerTiming?.[triggerReason]; + const quietWindowFallback = + this.deps.quietWindowMs != null && triggerReason !== 'manual_refresh'; + const runAfterMs = Math.max( + 0, + explicitRunAfterMs ?? + custom?.runAfterMs ?? + (quietWindowFallback ? this.quietWindowMs : defaultRunAfterMs(triggerReason)) + ); + const maxCoalesceWaitMs = Math.max( + runAfterMs, + custom?.maxCoalesceWaitMs ?? + (quietWindowFallback + ? Math.max(this.quietWindowMs, this.quietWindowMs * 5) + : defaultMaxCoalesceWaitMs(triggerReason)) + ); + return { runAfterMs, maxCoalesceWaitMs }; + } + enqueue(input: { teamName: string; memberName: string; @@ -103,7 +164,9 @@ export class MemberWorkSyncEventQueue { } const key = keyOf(teamName, memberName); - const runAt = this.now() + (input.runAfterMs ?? this.quietWindowMs); + const now = this.now(); + const timing = this.resolveTimingPolicy(input.triggerReason, input.runAfterMs); + const runAt = now + timing.runAfterMs; const running = this.running.get(key); if (running) { running.rerunRequested = true; @@ -122,7 +185,20 @@ export class MemberWorkSyncEventQueue { const existing = this.items.get(key); if (existing) { existing.triggerReasons.add(input.triggerReason); - existing.runAt = Math.max(existing.runAt, runAt); + existing.lastQueuedAt = now; + existing.maxRunAt = Math.max( + existing.maxRunAt, + existing.firstQueuedAt + timing.maxCoalesceWaitMs + ); + const preserveEarlierRun = + existing.runAt <= now || + existing.triggerReasons.has('manual_refresh') || + input.triggerReason === 'manual_refresh' || + runAt < existing.runAt; + existing.runAt = preserveEarlierRun + ? Math.min(existing.runAt, runAt) + : Math.min(Math.max(existing.runAt, runAt), existing.maxRunAt); + incrementReasonCount(existing.triggerReasonCounts, input.triggerReason); this.counters.coalesced += 1; this.appendAudit({ teamName, @@ -138,8 +214,12 @@ export class MemberWorkSyncEventQueue { this.items.set(key, { teamName, memberName, + firstQueuedAt: now, + lastQueuedAt: now, runAt, + maxRunAt: now + timing.maxCoalesceWaitMs, triggerReasons: new Set([input.triggerReason]), + triggerReasonCounts: new Map([[input.triggerReason, 1]]), }); this.counters.enqueued += 1; this.appendAudit({ @@ -163,10 +243,50 @@ export class MemberWorkSyncEventQueue { } getDiagnostics(): MemberWorkSyncQueueDiagnostics { + const now = this.now(); + const queuedItems = [...this.items.values()] + .sort((left, right) => left.runAt - right.runAt) + .map((item) => ({ + teamName: item.teamName, + memberName: item.memberName, + firstQueuedAt: new Date(item.firstQueuedAt).toISOString(), + lastQueuedAt: new Date(item.lastQueuedAt).toISOString(), + runAt: new Date(item.runAt).toISOString(), + maxRunAt: new Date(item.maxRunAt).toISOString(), + triggerReasons: [...item.triggerReasons].sort(), + triggerReasonCounts: Object.fromEntries(item.triggerReasonCounts), + })); + const runningItems = [...this.running.values()] + .sort((left, right) => left.startedAt - right.startedAt) + .map((item) => ({ + teamName: item.teamName, + memberName: item.memberName, + startedAt: new Date(item.startedAt).toISOString(), + ageMs: Math.max(0, now - item.startedAt), + rerunRequested: item.rerunRequested, + triggerReasons: [...item.triggerReasons].sort(), + })); + const oldestQueuedAt = + queuedItems.length > 0 + ? Math.min(...[...this.items.values()].map((item) => item.firstQueuedAt)) + : null; + const oldestRunningAt = + runningItems.length > 0 + ? Math.min(...[...this.running.values()].map((item) => item.startedAt)) + : null; + const nextRunAt = + this.items.size > 0 ? Math.min(...[...this.items.values()].map((item) => item.runAt)) : null; return { queued: this.items.size, running: this.running.size, ...this.counters, + ...(nextRunAt != null ? { nextRunAt: new Date(nextRunAt).toISOString() } : {}), + ...(oldestQueuedAt != null ? { oldestQueuedAgeMs: Math.max(0, now - oldestQueuedAt) } : {}), + ...(oldestRunningAt != null + ? { oldestRunningAgeMs: Math.max(0, now - oldestRunningAt) } + : {}), + queuedItems, + runningItems, }; } @@ -226,6 +346,9 @@ export class MemberWorkSyncEventQueue { private runItem(key: string, item: QueueItem): void { const running: RunningItem = { + teamName: item.teamName, + memberName: item.memberName, + startedAt: this.now(), rerunRequested: false, triggerReasons: new Set(item.triggerReasons), }; @@ -244,13 +367,7 @@ export class MemberWorkSyncEventQueue { this.running.delete(key); this.inFlight.delete(promise); if (running.rerunRequested && !this.stopped) { - for (const reason of running.triggerReasons) { - this.enqueue({ - teamName: item.teamName, - memberName: item.memberName, - triggerReason: reason, - }); - } + this.enqueueFollowUp(item, running); } this.pump(); }); @@ -258,6 +375,31 @@ export class MemberWorkSyncEventQueue { this.inFlight.add(promise); } + private enqueueFollowUp(item: QueueItem, running: RunningItem): void { + const reasons = [...running.triggerReasons].sort(); + const primaryReason = + reasons.find((reason) => reason === 'manual_refresh') ?? + reasons.find((reason) => reason === 'turn_settled' || reason === 'tool_finished') ?? + reasons[0] ?? + 'manual_refresh'; + this.enqueue({ + teamName: item.teamName, + memberName: item.memberName, + triggerReason: primaryReason, + runAfterMs: Math.min(this.resolveTimingPolicy(primaryReason).runAfterMs, 5_000), + }); + const queued = this.items.get(keyOf(item.teamName, item.memberName)); + if (!queued) { + return; + } + for (const reason of reasons) { + queued.triggerReasons.add(reason); + if (reason !== primaryReason) { + incrementReasonCount(queued.triggerReasonCounts, reason); + } + } + } + private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise { if (!(await this.deps.isTeamActive(item.teamName))) { this.counters.dropped += 1; @@ -307,3 +449,46 @@ export class MemberWorkSyncEventQueue { }); } } + +function incrementReasonCount( + counts: Map, + reason: MemberWorkSyncTriggerReason +): void { + counts.set(reason, (counts.get(reason) ?? 0) + 1); +} + +function defaultRunAfterMs(reason: MemberWorkSyncTriggerReason): number { + switch (reason) { + case 'manual_refresh': + return 0; + case 'turn_settled': + case 'tool_finished': + return 5_000; + case 'task_changed': + case 'inbox_changed': + case 'runtime_activity': + return 15_000; + case 'startup_scan': + case 'config_changed': + case 'member_spawned': + return 30_000; + } +} + +function defaultMaxCoalesceWaitMs(reason: MemberWorkSyncTriggerReason): number { + switch (reason) { + case 'manual_refresh': + return 0; + case 'turn_settled': + case 'tool_finished': + return 30_000; + case 'task_changed': + case 'inbox_changed': + case 'runtime_activity': + return 60_000; + case 'startup_scan': + case 'config_changed': + case 'member_spawned': + return 90_000; + } +} diff --git a/src/features/member-work-sync/preload/index.ts b/src/features/member-work-sync/preload/index.ts index 4f53d1d7..ef07cced 100644 --- a/src/features/member-work-sync/preload/index.ts +++ b/src/features/member-work-sync/preload/index.ts @@ -1,6 +1,7 @@ import { MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REFRESH_STATUS, MEMBER_WORK_SYNC_REPORT, type MemberWorkSyncMetricsRequest, type MemberWorkSyncReportRequest, @@ -14,6 +15,7 @@ import type { IpcRenderer } from 'electron'; export interface MemberWorkSyncElectronApi { getStatus(request: MemberWorkSyncStatusRequest): Promise; + refreshStatus(request: MemberWorkSyncStatusRequest): Promise; getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; } @@ -21,6 +23,7 @@ export interface MemberWorkSyncElectronApi { export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi { return { getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request), + refreshStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REFRESH_STATUS, request), getMetrics: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_METRICS, request), report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request), }; diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 19adbfa3..2eb9f01d 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -753,6 +753,34 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) } ); + app.get<{ Params: { teamName: string } }>( + '/api/teams/:teamName/member-work-sync/diagnostics', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + const feature = getMemberWorkSyncFeature(services); + const metrics = await feature.getMetrics({ teamName: validatedTeamName.value! }); + return reply.send({ + teamName: validatedTeamName.value!, + generatedAt: new Date().toISOString(), + queue: feature.getQueueDiagnostics(), + metrics, + }); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in GET /api/teams/${request.params.teamName}/member-work-sync/diagnostics:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + app.get<{ Params: { teamName: string } }>( '/api/teams/:teamName/member-work-sync/metrics', async (request, reply) => { @@ -808,6 +836,36 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) } ); + app.post<{ Params: { teamName: string; memberName: string } }>( + '/api/teams/:teamName/member-work-sync/:memberName/refresh', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + const memberName = request.params.memberName?.trim(); + if (!memberName) { + return reply.status(400).send({ error: 'memberName is required' }); + } + return reply.send( + await getMemberWorkSyncFeature(services).refreshStatus({ + teamName: validatedTeamName.value!, + memberName, + }) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/member-work-sync/${request.params.memberName}/refresh:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + app.post<{ Params: { teamName: string }; Body: Record }>( '/api/teams/:teamName/member-work-sync/report', async (request, reply) => { @@ -832,7 +890,14 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) .send({ error: 'state must be still_working, blocked, or caught_up' }); } const taskIds = Array.isArray(payload.taskIds) - ? payload.taskIds.filter((taskId): taskId is string => typeof taskId === 'string') + ? [ + ...new Set( + payload.taskIds + .filter((taskId): taskId is string => typeof taskId === 'string') + .map((taskId) => taskId.trim()) + .filter(Boolean) + ), + ] : undefined; return reply.send( await getMemberWorkSyncFeature(services).report({ @@ -843,7 +908,7 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) ...(typeof payload.reportToken === 'string' ? { reportToken: payload.reportToken } : {}), - ...(taskIds ? { taskIds } : {}), + ...(taskIds?.length ? { taskIds } : {}), ...(typeof payload.note === 'string' ? { note: payload.note } : {}), ...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}), ...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}), diff --git a/src/main/index.ts b/src/main/index.ts index c9ee0254..37d5534d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -834,7 +834,6 @@ function wireFileWatcherEvents(context: ServiceContext): void { const teamName = row.teamName.trim(); const detail = typeof row.detail === 'string' ? row.detail : ''; launchIoGovernor?.noteTeamChange(row as TeamChangeEvent); - memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); if (row.type === 'config') { if (detail === 'config.json') { @@ -850,6 +849,8 @@ function wireFileWatcherEvents(context: ServiceContext): void { TeamTaskReader.invalidateAllTasksCache(); } + memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); + if ( teamDataService && (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index e3664fac..b1400d3f 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -1065,6 +1065,7 @@ export class FileWatcher extends EventEmitter { type: 'task', teamName, detail: relative, + taskId: relative.replace(/\.json$/i, ''), }; this.emit('team-change', event); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6bc52e46..b78c3026 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6558,6 +6558,12 @@ export class TeamProvisioningService { const toolNames = ledgerRecord?.observedToolCallNames ?? []; return toolNames.some((toolName) => { const normalized = this.normalizeOpenCodeObservedToolName(toolName); + if ( + ledgerRecord?.messageKind === 'member_work_sync_nudge' && + normalized === 'member_work_sync_report' + ) { + return true; + } return ( normalized === 'task_start' || normalized === 'task_add_comment' || @@ -6577,6 +6583,7 @@ export class TeamProvisioningService { private normalizeOpenCodeObservedToolName(toolName: string): string { return toolName .trim() + .toLowerCase() .replace(/^mcp__agent[-_]teams__/, '') .replace(/^agent[-_]teams_/, '') .replace(/^mcp__agent_teams__/, '') @@ -7449,6 +7456,7 @@ export class TeamProvisioningService { source: 'watchdog', replyRecipient, actionMode: message.actionMode ?? null, + messageKind: message.messageKind ?? null, taskRefs: message.taskRefs ?? [], payloadHash: hashOpenCodePromptDeliveryPayload({ text: message.text, @@ -7500,6 +7508,7 @@ export class TeamProvisioningService { messageId?: string; replyRecipient?: string; actionMode?: AgentActionMode; + messageKind?: InboxMessage['messageKind']; taskRefs?: TaskRef[]; source?: OpenCodeMemberInboxRelayOptions['source']; inboxTimestamp?: string; @@ -7700,6 +7709,7 @@ export class TeamProvisioningService { messageId: input.messageId, replyRecipient: input.replyRecipient, actionMode: input.actionMode, + messageKind: input.messageKind, taskRefs: input.taskRefs, }); await this.rememberOpenCodeRuntimePidFromBridge({ @@ -7806,6 +7816,7 @@ export class TeamProvisioningService { source: input.source ?? 'manual', replyRecipient: input.replyRecipient ?? 'user', actionMode: input.actionMode ?? null, + messageKind: input.messageKind ?? null, taskRefs: input.taskRefs ?? [], payloadHash: hashOpenCodePromptDeliveryPayload({ text: input.text, @@ -7943,6 +7954,7 @@ export class TeamProvisioningService { messageId, replyRecipient: input.replyRecipient, actionMode: input.actionMode, + messageKind: input.messageKind, taskRefs: input.taskRefs, prePromptCursor: ledgerRecord.prePromptCursor, }); @@ -8071,6 +8083,7 @@ export class TeamProvisioningService { messageId: input.messageId, replyRecipient: input.replyRecipient, actionMode: input.actionMode, + messageKind: input.messageKind, taskRefs: input.taskRefs, }); await this.rememberOpenCodeRuntimePidFromBridge({ @@ -18484,6 +18497,7 @@ export class TeamProvisioningService { source: effectiveSource, replyRecipient: effectiveReplyRecipient, actionMode: effectiveActionMode, + messageKind: message.messageKind ?? null, taskRefs: effectiveTaskRefs, payloadHash: hashOpenCodePromptDeliveryPayload({ text: message.text, @@ -18524,6 +18538,7 @@ export class TeamProvisioningService { messageId: message.messageId, replyRecipient: effectiveReplyRecipient, actionMode: effectiveActionMode ?? undefined, + messageKind: message.messageKind, taskRefs: effectiveTaskRefs, source: effectiveSource, inboxTimestamp: message.timestamp, @@ -18913,6 +18928,10 @@ export class TeamProvisioningService { ...(member.role?.trim() ? { role: member.role.trim() } : {}), })); const rosterContextBlock = buildLeadRosterContextBlock(teamName, leadName, teammateRoster); + const workSyncControlUrl = await this.resolveControlApiBaseUrl(); + const workSyncControlUrlClause = workSyncControlUrl + ? `, controlUrl="${workSyncControlUrl}"` + : ''; run.activeCrossTeamReplyHints = batch.flatMap((m) => { if (m.source !== 'cross_team') return []; const sourceTeam = m.from.includes('.') ? m.from.split('.', 1)[0] : ''; @@ -18937,6 +18956,7 @@ export class TeamProvisioningService { `For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`, `Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`, `If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`, + `If a message below has Message kind: member_work_sync_nudge, it is actionable work-sync control traffic, not routine notification noise. Do NOT ignore it as a pure system notification. Call member_work_sync_status with teamName="${teamName}", memberName="${leadName}"${workSyncControlUrlClause}, then call member_work_sync_report with the same teamName/memberName${workSyncControlUrlClause}, the returned agendaFingerprint/reportToken, and taskIds from the nudge task refs. Do not use provider names, runtime names, or team names as memberName. If the agenda still has actionable work you are continuing, use state "still_working"; if blocked, use state "blocked" and record the blocker on the task.`, `Do NOT post acknowledgement-only task comments such as "Принято", "Ок", "На связи", "Жду", or similar low-signal echoes. If the task comment notification is FYI and no durable update is needed, say nothing.`, `If a message below includes a hidden structured task-context block, treat that block as authoritative for teamName/taskId/commentId. Do NOT infer alternate ids or namespaces from visible prose.`, `If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`, diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index ba59a021..d772f05b 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -161,6 +161,13 @@ export interface OpenCodeSendMessageCommandBody { text: string; messageId?: string; actionMode?: 'do' | 'ask' | 'delegate'; + messageKind?: + | 'default' + | 'slash_command' + | 'slash_command_result' + | 'task_comment_notification' + | 'member_work_sync_nudge' + | 'agent_error'; taskRefs?: { taskId: string; displayId: string; teamName: string }[]; agent?: string; noReply?: boolean; diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index d00fb850..34d7037d 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -6,7 +6,7 @@ import type { OpenCodeDeliveryResponseState, OpenCodeDeliveryVisibleReplyCorrelation, } from '../bridge/OpenCodeBridgeCommandContract'; -import type { AgentActionMode, TaskRef } from '@shared/types/team'; +import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team'; export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1; export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; @@ -32,6 +32,7 @@ export interface OpenCodePromptDeliveryLedgerRecord { inboxMessageId: string; inboxTimestamp: string; source: 'watcher' | 'ui-send' | 'manual' | 'watchdog'; + messageKind: InboxMessageKind | null; replyRecipient: string; actionMode: AgentActionMode | null; taskRefs: TaskRef[]; @@ -117,6 +118,7 @@ export interface EnsureOpenCodePromptDeliveryInput { inboxMessageId: string; inboxTimestamp: string; source: OpenCodePromptDeliveryLedgerRecord['source']; + messageKind?: InboxMessageKind | null; replyRecipient: string; actionMode?: AgentActionMode | null; taskRefs?: TaskRef[]; @@ -175,6 +177,15 @@ export class OpenCodePromptDeliveryLedgerStore { result = updated; return records.map((record) => (record.id === existing.id ? updated : record)); } + if (existing.messageKind == null && input.messageKind) { + const updated: OpenCodePromptDeliveryLedgerRecord = { + ...existing, + messageKind: input.messageKind, + updatedAt: input.now, + }; + result = updated; + return records.map((record) => (record.id === existing.id ? updated : record)); + } result = existing; return records; } @@ -189,6 +200,7 @@ export class OpenCodePromptDeliveryLedgerStore { inboxMessageId: input.inboxMessageId, inboxTimestamp: input.inboxTimestamp, source: input.source, + messageKind: input.messageKind ?? null, replyRecipient: input.replyRecipient, actionMode: input.actionMode ?? null, taskRefs: input.taskRefs ?? [], @@ -691,6 +703,7 @@ function isOpenCodePromptDeliveryLedgerRecord( typeof record.inboxMessageId === 'string' && typeof record.inboxTimestamp === 'string' && isOpenCodePromptDeliverySource(record.source) && + isOptionalNullableInboxMessageKind(record.messageKind) && typeof record.replyRecipient === 'string' && isOptionalNullableActionMode(record.actionMode) && isTaskRefArray(record.taskRefs) && @@ -769,6 +782,21 @@ function isOptionalNullableActionMode(value: unknown): value is AgentActionMode ); } +function isOptionalNullableInboxMessageKind( + value: unknown +): value is InboxMessageKind | null | undefined { + return ( + value === undefined || + value === null || + value === 'default' || + value === 'slash_command' || + value === 'slash_command_result' || + value === 'task_comment_notification' || + value === 'member_work_sync_nudge' || + value === 'agent_error' + ); +} + function isOptionalNullableString(value: unknown): value is string | null | undefined { return value === undefined || value === null || typeof value === 'string'; } diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index ff37753d..1912dd3a 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -26,7 +26,7 @@ import type { TeamRuntimeStopInput, TeamRuntimeStopResult, } from './TeamRuntimeAdapter'; -import type { AgentActionMode, TaskRef } from '@shared/types/team'; +import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team'; export interface OpenCodeTeamRuntimeBridgePort { checkOpenCodeTeamLaunchReadiness(input: { @@ -58,6 +58,7 @@ export interface OpenCodeTeamRuntimeMessageInput { messageId?: string; replyRecipient?: string; actionMode?: AgentActionMode; + messageKind?: InboxMessageKind; taskRefs?: TaskRef[]; bootstrapCheckinRetry?: { runtimeSessionId: string; @@ -313,6 +314,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { text: buildOpenCodeRuntimeMessageText(input), messageId: input.messageId, actionMode: input.actionMode, + messageKind: input.messageKind, taskRefs: input.taskRefs, agent: 'teammate', }); @@ -773,7 +775,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) const replyRecipient = input.replyRecipient?.trim() || 'user'; const deliveryContext = - input.messageId && input.taskRefs?.length + input.messageId && (input.taskRefs?.length || input.messageKind) ? JSON.stringify({ schemaVersion: 1, kind: 'opencode-delivery-context', @@ -781,9 +783,38 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) laneId: input.laneId, memberName: input.memberName, inboundMessageId: input.messageId, + ...(input.messageKind ? { messageKind: input.messageKind } : {}), taskRefs: input.taskRefs, }) : null; + const isWorkSyncNudge = input.messageKind === 'member_work_sync_nudge'; + const taskIds = + input.taskRefs + ?.map((ref) => ref.taskId?.trim()) + .filter((taskId): taskId is string => Boolean(taskId)) ?? []; + const responseInstructions = isWorkSyncNudge + ? [ + 'This delivered app message is a member-work-sync nudge.', + 'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response 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) with teamName="${input.teamName}", memberName="${input.memberName}", the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`, + taskIds.length + ? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` + : null, + `Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`, + 'Do not reply only with acknowledgement.', + ] + : [ + 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).', + `Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`, + 'Include source="runtime_delivery" in that message_send call.', + input.messageId + ? `Include relayOfMessageId="${input.messageId}" in that message_send call.` + : null, + 'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.', + 'You must not end this turn empty.', + 'Do not answer only with plain assistant text when agent-teams_message_send is available.', + ]; return [ '', @@ -791,16 +822,8 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) ? `${deliveryContext}` : null, 'You are running in OpenCode, not Claude Code or Codex native.', - 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).', - `Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`, - 'Include source="runtime_delivery" in that message_send call.', - input.messageId - ? `Include relayOfMessageId="${input.messageId}" in that message_send call.` - : null, - 'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.', - 'You must not end this turn empty.', + ...responseInstructions, 'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.', - 'Do not answer only with plain assistant text when agent-teams_message_send is available.', 'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.', 'Do not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.', 'The inbound app message follows. Treat it as the actual instruction to process now, not as background context.', diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 6e5ee57a..52722276 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1310,6 +1310,13 @@ export class HttpAPIClient implements ElectronAPI { request.memberName )}` ), + refreshStatus: (request) => + this.post( + `/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/${encodeURIComponent( + request.memberName + )}/refresh`, + {} + ), getMetrics: (request) => this.get(`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/metrics`), report: (request) => diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx index 6637d6df..3477ee27 100644 --- a/src/renderer/components/team/ClaudeLogsPanel.tsx +++ b/src/renderer/components/team/ClaudeLogsPanel.tsx @@ -72,7 +72,8 @@ export const ClaudeLogsPanel = ({ {data.total > 0 ? ( <> - {data.total} lines + {data.total} raw line + {data.total === 1 ? '' : 's'} ) : isAlive ? ( 'No logs yet.' diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index ab3fa125..3004f6fa 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -64,6 +64,13 @@ interface CodexNativeJsonEvent { result?: unknown; error?: unknown; status?: string; + command?: string; + aggregated_output?: string; + output?: string; + stderr?: string; + exit_code?: number; + exitCode?: number; + changes?: unknown; }; usage?: { input_tokens?: number; @@ -165,7 +172,17 @@ function getCodexToolDisplayName(serverName: string, toolName: string): string { return serverName === 'agent-teams' ? `agent-teams_${toolName}` : `${serverName}_${toolName}`; } -function createCodexToolItem( +function readRawString(record: Record | undefined, key: string): string | null { + const value = record?.[key]; + return typeof value === 'string' ? value : null; +} + +function readFiniteNumber(record: Record | undefined, key: string): number | null { + const value = record?.[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function createCodexMcpToolItem( event: CodexNativeJsonEvent, timestamp: Date, lineIndex: number @@ -210,6 +227,141 @@ function createCodexToolItem( return { type: 'tool', tool: linkedTool }; } +function createCodexCommandExecutionToolItem( + event: CodexNativeJsonEvent, + timestamp: Date, + lineIndex: number +): AIGroupDisplayItem | null { + const item = event.item; + if ( + (event.type !== 'item.started' && event.type !== 'item.completed') || + item?.type !== 'command_execution' + ) { + return null; + } + + const isCompleted = event.type === 'item.completed'; + const itemRecord = asRecord(item) ?? {}; + const command = readRawString(itemRecord, 'command') ?? ''; + const status = + typeof item.status === 'string' && item.status.trim() + ? item.status + : isCompleted + ? 'unknown' + : 'in_progress'; + const exitCode = + readFiniteNumber(itemRecord, 'exit_code') ?? readFiniteNumber(itemRecord, 'exitCode'); + const output = + readRawString(itemRecord, 'aggregated_output') ?? + readRawString(itemRecord, 'output') ?? + readRawString(itemRecord, 'stderr') ?? + ''; + const input = { command }; + const linkedTool: LinkedToolItem = { + id: item.id ?? `codex-command-L${lineIndex}`, + name: 'Bash', + input, + inputPreview: getToolSummary('Bash', input), + startTime: timestamp, + isOrphaned: !isCompleted, + }; + + if (isCompleted) { + const isError = + status === 'failed' || status === 'declined' || (exitCode !== null && exitCode !== 0); + linkedTool.endTime = timestamp; + linkedTool.isOrphaned = false; + linkedTool.result = { + content: output, + isError, + }; + linkedTool.outputPreview = output || undefined; + } + + return { type: 'tool', tool: linkedTool }; +} + +function getFirstFileChangePath(changes: unknown[]): string { + for (const change of changes) { + const record = asRecord(change); + if (typeof record?.path === 'string' && record.path.trim()) { + return record.path; + } + } + return ''; +} + +function formatFileChangesResult(changes: unknown[]): string { + const rows = changes.map((change) => { + const record = asRecord(change); + const path = + typeof record?.path === 'string' && record.path.trim() ? record.path : '(unknown path)'; + const kind = typeof record?.kind === 'string' && record.kind.trim() ? record.kind : 'update'; + return `- ${path} (${kind})`; + }); + return ['File changes:', ...rows].join('\n'); +} + +function createCodexFileChangeToolItem( + event: CodexNativeJsonEvent, + timestamp: Date, + lineIndex: number +): AIGroupDisplayItem | null { + const item = event.item; + if ( + (event.type !== 'item.started' && event.type !== 'item.completed') || + item?.type !== 'file_change' + ) { + return null; + } + + const isCompleted = event.type === 'item.completed'; + const changes = Array.isArray(item.changes) ? item.changes : []; + const input = { + file_path: getFirstFileChangePath(changes), + changes, + }; + const status = + typeof item.status === 'string' && item.status.trim() + ? item.status + : isCompleted + ? 'unknown' + : 'in_progress'; + const linkedTool: LinkedToolItem = { + id: item.id ?? `codex-file-change-L${lineIndex}`, + name: 'Edit', + input, + inputPreview: getToolSummary('Edit', input), + startTime: timestamp, + isOrphaned: !isCompleted, + }; + + if (isCompleted) { + const resultContent = formatFileChangesResult(changes); + linkedTool.endTime = timestamp; + linkedTool.isOrphaned = false; + linkedTool.result = { + content: resultContent, + isError: status === 'failed', + }; + linkedTool.outputPreview = resultContent; + } + + return { type: 'tool', tool: linkedTool }; +} + +function createCodexToolItem( + event: CodexNativeJsonEvent, + timestamp: Date, + lineIndex: number +): AIGroupDisplayItem | null { + return ( + createCodexMcpToolItem(event, timestamp, lineIndex) ?? + createCodexCommandExecutionToolItem(event, timestamp, lineIndex) ?? + createCodexFileChangeToolItem(event, timestamp, lineIndex) + ); +} + function codexNativeEventToDisplayItems( parsed: unknown, timestamp: Date, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 5e41c625..b52d8fc2 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -624,6 +624,7 @@ export interface TeamsAPI { export interface MemberWorkSyncElectronApi { getStatus(request: MemberWorkSyncStatusRequest): Promise; + refreshStatus(request: MemberWorkSyncStatusRequest): Promise; getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; } diff --git a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts index ae876095..5c269201 100644 --- a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts +++ b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts @@ -113,12 +113,48 @@ describe('buildActionableWorkAgenda', () => { expect(agenda.items).toEqual([]); }); - it('projects clarification and blocked dependency work for the owner', () => { + it('prefers current kanban reviewer over older review history while task remains in review', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'carol', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }, { name: 'carol' }], + kanbanReviewersByTaskId: { 'task-1': 'carol' }, + tasks: [ + { + id: 'task-1', + subject: 'Review me', + status: 'in_progress', + owner: 'bob', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-1', + type: 'review_started', + timestamp: '2026-04-29T00:00:00.000Z', + actor: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items).toHaveLength(1); + expect(agenda.items[0]).toMatchObject({ + taskId: 'task-1', + kind: 'review', + assignee: 'carol', + evidence: { reviewer: 'carol' }, + }); + }); + + it('does not nudge owners while work is waiting on user or unfinished dependencies', () => { const agenda = buildActionableWorkAgenda({ teamName: 'team-a', memberName: 'bob', generatedAt: '2026-04-29T00:00:00.000Z', - members: [{ name: 'bob' }], + members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }], tasks: [ { id: 'task-1', @@ -127,20 +163,106 @@ describe('buildActionableWorkAgenda', () => { owner: 'bob', needsClarification: 'user', }, + { + id: 'task-3', + displayId: '#33333333', + subject: 'Dependency', + status: 'in_progress', + owner: 'alice', + }, { id: 'task-2', - subject: 'Blocked', + subject: 'Waiting dependency', status: 'in_progress', owner: 'bob', - blockedBy: ['task-3'], + blockedBy: ['#33333333'], }, ], hash, }); - expect(agenda.items.map((item) => [item.taskId, item.kind, item.priority])).toEqual([ - ['task-1', 'clarification', 'needs_clarification'], - ['task-2', 'blocked_dependency', 'blocked'], + expect(agenda.items).toEqual([]); + }); + + it('does not project display-id dependencies as broken when the dependency exists', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'team-lead', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }], + tasks: [ + { + id: 'task-dep', + displayId: '#33333333', + subject: 'Existing dependency', + status: 'in_progress', + owner: 'alice', + }, + { + id: 'task-2', + subject: 'Waiting dependency', + status: 'in_progress', + owner: 'bob', + blockedBy: ['33333333'], + }, + ], + hash, + }); + + expect(agenda.items).toEqual([]); + }); + + it('projects lead-owned oversight for lead clarification and broken dependencies', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'team-lead', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }], + tasks: [ + { + id: 'task-1', + subject: 'Need lead', + status: 'in_progress', + owner: 'bob', + needsClarification: 'lead', + }, + { + id: 'task-2', + subject: 'Broken dependency', + status: 'in_progress', + owner: 'bob', + blockedBy: ['missing-task'], + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([ + ['task-1', 'clarification', 'task_needs_lead_clarification'], + ['task-2', 'blocked_dependency', 'task_has_broken_dependency'], + ]); + }); + + it('treats needsFix as owner work', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Fix review', + status: 'in_progress', + owner: 'bob', + reviewState: 'needsFix', + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([ + ['task-1', 'work', 'review_changes_requested'], ]); }); diff --git a/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts index 8116b071..b20c2482 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts @@ -15,7 +15,35 @@ function agendaWithWork() { memberName: 'bob', generatedAt: nowIso, members: [{ name: 'bob' }], - tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }], + tasks: [ + { + id: 'task-1', + displayId: '#11111111', + subject: 'Work', + status: 'pending', + owner: 'bob', + }, + ], + hash, + }); +} + +function leadAgendaWithBrokenDependency() { + return buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'team-lead', + generatedAt: nowIso, + members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'bob' }], + tasks: [ + { + id: 'task-2', + displayId: '#22222222', + subject: 'Blocked work', + status: 'pending', + owner: 'bob', + blockedBy: ['missing-task'], + }, + ], hash, }); } @@ -40,6 +68,39 @@ describe('validateMemberWorkSyncReport', () => { expect(result.expiresAt).toBe('2026-04-29T00:15:00.000Z'); }); + it('accepts display task ids for current agenda references', () => { + const agenda = agendaWithWork(); + const withHash = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + taskIds: ['#11111111'], + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + tokenValidation: validToken, + }); + const withoutHash = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + taskIds: ['11111111'], + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + tokenValidation: validToken, + }); + + expect(withHash.ok).toBe(true); + expect(withoutHash.ok).toBe(true); + }); + it('rejects caught_up while actionable work remains', () => { const agenda = agendaWithWork(); const result = validateMemberWorkSyncReport({ @@ -79,6 +140,26 @@ describe('validateMemberWorkSyncReport', () => { expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' }); }); + it('accepts blocked reports when blocker evidence is referenced by display id', () => { + const agenda = leadAgendaWithBrokenDependency(); + const result = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'team-lead', + state: 'blocked', + agendaFingerprint: agenda.fingerprint, + taskIds: ['22222222'], + }, + agenda, + nowIso, + activeMemberNames: ['team-lead', 'bob'], + tokenValidation: validToken, + }); + + expect(result.ok).toBe(true); + expect(result.expiresAt).toBe('2026-04-29T00:30:00.000Z'); + }); + it('rejects stale fingerprints and foreign task ids', () => { const agenda = agendaWithWork(); const stale = validateMemberWorkSyncReport({ diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index ec9f7b99..0dc9137f 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -210,10 +210,7 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { } } - async countRecentDelivered(input: { - memberName: string; - sinceIso: string; - }): Promise { + async countRecentDelivered(input: { memberName: string; sinceIso: string }): Promise { return [...this.items.values()].filter( (item) => item.status === 'delivered' && @@ -300,7 +297,7 @@ function createDeps(options?: { describe('MemberWorkSync use cases', () => { it('reconciles actionable work into needs_sync without side effects', async () => { const { auditEvents, deps, store } = createDeps(); - const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + const status = await new MemberWorkSyncReconciler(deps).execute({ teamName: 'team-a', memberName: 'bob', }); @@ -324,7 +321,7 @@ describe('MemberWorkSync use cases', () => { it('accepts still_working as a bounded lease for the current fingerprint', async () => { const { auditEvents, clock, deps } = createDeps(); - const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reader = new MemberWorkSyncReconciler(deps); const reporter = new MemberWorkSyncReporter(deps); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); @@ -357,7 +354,7 @@ describe('MemberWorkSync use cases', () => { it('uses app clock instead of model supplied reportedAt for lease timing', async () => { const { deps } = createDeps(); - const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reader = new MemberWorkSyncReconciler(deps); const reporter = new MemberWorkSyncReporter(deps); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); @@ -409,7 +406,7 @@ describe('MemberWorkSync use cases', () => { it('accepts caught_up only when the app-side agenda is empty', async () => { const { deps } = createDeps({ items: [] }); - const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reader = new MemberWorkSyncReconciler(deps); const reporter = new MemberWorkSyncReporter(deps); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); @@ -428,7 +425,7 @@ describe('MemberWorkSync use cases', () => { it('marks status inactive when the team runtime is not active', async () => { const { deps } = createDeps({ teamActive: false }); - const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + const status = await new MemberWorkSyncReconciler(deps).execute({ teamName: 'team-a', memberName: 'bob', }); @@ -440,7 +437,7 @@ describe('MemberWorkSync use cases', () => { it('records fingerprint transitions without treating them as progress proof', async () => { const { deps, source } = createDeps(); - const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reader = new MemberWorkSyncReconciler(deps); await reader.execute({ teamName: 'team-a', memberName: 'bob' }); source.agenda.items = [ @@ -487,6 +484,7 @@ describe('MemberWorkSync use cases', () => { }); expect(outbox.ensures).toEqual([]); + expect(store.writes).toEqual([]); }); it('creates one idempotent outbox nudge intent when Phase 2 readiness is green', async () => { @@ -517,6 +515,15 @@ describe('MemberWorkSync use cases', () => { taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], }, }); + const nudgeText = outbox.ensures[0]?.payload.text ?? ''; + expect(nudgeText).toContain( + 'member_work_sync_status with teamName "team-a" and memberName "bob"' + ); + expect(nudgeText).toContain('member_work_sync_report with the same teamName/memberName'); + expect(nudgeText).toContain('taskIds: "task-1"'); + expect(nudgeText).toContain( + 'Do not use provider names, runtime names, or team names as memberName' + ); }); it('dispatches due nudges only after revalidating current status and readiness', async () => { @@ -544,12 +551,41 @@ describe('MemberWorkSync use cases', () => { memberName: 'bob', messageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`, }); - expect(outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)).toMatchObject({ + expect( + outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`) + ).toMatchObject({ status: 'delivered', deliveredMessageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`, }); }); + it('recomputes agenda before dispatch and supersedes stale outbox fingerprints', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { deps, source, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); + store.phase2ReadinessState = 'shadow_ready'; + + const status = await new MemberWorkSyncReconciler(deps).execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + source.agenda.items = []; + + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 }); + expect(inbox.inserted).toEqual([]); + expect( + outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`) + ).toMatchObject({ + status: 'superseded', + lastError: 'status_no_longer_matches_outbox', + }); + }); + it('does not dispatch stale outbox items after the member reports still working', async () => { const outbox = new InMemoryOutboxStore(); const inbox = new InMemoryInboxNudge(); @@ -579,7 +615,9 @@ describe('MemberWorkSync use cases', () => { expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 }); expect(inbox.inserted).toEqual([]); - expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({ + expect( + outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`) + ).toMatchObject({ status: 'superseded', lastError: 'status_no_longer_matches_outbox', }); @@ -630,7 +668,9 @@ describe('MemberWorkSync use cases', () => { expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 }); expect(inbox.inserted).toEqual([]); - expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({ + expect( + outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`) + ).toMatchObject({ status: 'failed_retryable', lastError: 'member_nudge_rate_limited', nextAttemptAt: '2026-04-29T01:00:00.000Z', @@ -667,7 +707,9 @@ describe('MemberWorkSync use cases', () => { expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 }); expect(inbox.inserted).toEqual([]); - expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({ + expect( + outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`) + ).toMatchObject({ status: 'failed_retryable', lastError: 'member_busy:active_tool_activity', nextAttemptAt: '2026-04-29T00:02:00.000Z', @@ -717,7 +759,7 @@ describe('MemberWorkSync use cases', () => { it('rejects invalid report tokens without recording replayable intents', async () => { const { deps, store } = createDeps(); - const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reader = new MemberWorkSyncReconciler(deps); const reporter = new MemberWorkSyncReporter(deps); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); @@ -741,7 +783,7 @@ describe('MemberWorkSync use cases', () => { it('replays pending controller intents through the same app validator', async () => { const { deps, store } = createDeps(); - const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reader = new MemberWorkSyncReconciler(deps); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); store.pendingIntents.set('intent-1', { id: 'intent-1', diff --git a/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts b/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts index 443e1a67..64ce09de 100644 --- a/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts +++ b/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -9,11 +9,13 @@ import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infras describe('HmacMemberWorkSyncReportTokenAdapter', () => { let root: string; + let paths: MemberWorkSyncStorePaths; let adapter: HmacMemberWorkSyncReportTokenAdapter; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'member-work-sync-token-')); - adapter = new HmacMemberWorkSyncReportTokenAdapter(new MemberWorkSyncStorePaths(root)); + paths = new MemberWorkSyncStorePaths(root); + adapter = new HmacMemberWorkSyncReportTokenAdapter(paths); }); afterEach(async () => { @@ -76,4 +78,55 @@ describe('HmacMemberWorkSyncReportTokenAdapter', () => { }) ).resolves.toEqual({ ok: false, reason: 'expired' }); }); + + it('recovers from a corrupt token secret file', async () => { + await mkdir(paths.getTeamDir('team-a'), { recursive: true }); + await writeFile(paths.getReportTokenSecretPath('team-a'), '{broken', 'utf8'); + + const issued = await adapter.create({ + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + issuedAt: '2026-04-29T00:00:00.000Z', + }); + + const secretFile = JSON.parse(await readFile(paths.getReportTokenSecretPath('team-a'), 'utf8')); + expect(secretFile.schemaVersion).toBe(1); + expect(typeof secretFile.secret).toBe('string'); + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + nowIso: '2026-04-29T00:01:00.000Z', + }) + ).resolves.toEqual({ ok: true }); + }); + + it('does not cache a failed token secret load forever', async () => { + const secretPath = paths.getReportTokenSecretPath('team-a'); + await mkdir(secretPath, { recursive: true }); + + await expect( + adapter.create({ + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + issuedAt: '2026-04-29T00:00:00.000Z', + }) + ).rejects.toBeTruthy(); + + await rm(secretPath, { recursive: true, force: true }); + await expect( + adapter.create({ + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + issuedAt: '2026-04-29T00:00:00.000Z', + }) + ).resolves.toMatchObject({ + expiresAt: '2026-04-29T00:15:00.000Z', + }); + }); }); diff --git a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts index e0e8f66b..44aa5cf8 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts @@ -45,6 +45,108 @@ describe('MemberWorkSyncEventQueue', () => { await queue.stop(); }); + it('bounds coalescing so noisy event streams cannot starve reconcile forever', async () => { + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + quietWindowMs: 100, + triggerTiming: { + task_changed: { runAfterMs: 100, maxCoalesceWaitMs: 250 }, + }, + reconcile: async (request, context) => { + reconciles.push({ request, context }); + }, + isTeamActive: () => true, + }); + + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); + await vi.advanceTimersByTimeAsync(90); + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); + await vi.advanceTimersByTimeAsync(90); + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); + await vi.advanceTimersByTimeAsync(69); + + expect(reconciles).toHaveLength(0); + expect(queue.getDiagnostics()).toMatchObject({ + queued: 1, + queuedItems: [ + { + memberName: 'bob', + triggerReasonCounts: { task_changed: 3 }, + }, + ], + }); + + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(1); + await queue.stop(); + }); + + it('lets manual refresh expedite an already queued delayed reconcile', async () => { + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + triggerTiming: { + task_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 }, + manual_refresh: { runAfterMs: 0, maxCoalesceWaitMs: 0 }, + }, + reconcile: async (request, context) => { + reconciles.push({ request, context }); + }, + isTeamActive: () => true, + }); + + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); + await vi.advanceTimersByTimeAsync(100); + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' }); + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(1); + expect(reconciles[0]).toMatchObject({ + context: { triggerReasons: ['manual_refresh', 'task_changed'] }, + }); + await queue.stop(); + }); + + it('does not let legacy quiet window override delay manual refresh', async () => { + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + quietWindowMs: 10_000, + reconcile: async (request, context) => { + reconciles.push({ request, context }); + }, + isTeamActive: () => true, + }); + + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' }); + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(1); + await queue.stop(); + }); + + it('does not let a later quiet-window event delay a queued manual refresh', async () => { + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + triggerTiming: { + task_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 }, + }, + reconcile: async (request, context) => { + reconciles.push({ request, context }); + }, + isTeamActive: () => true, + }); + + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' }); + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(1); + expect(reconciles[0]).toMatchObject({ + context: { triggerReasons: ['manual_refresh', 'task_changed'] }, + }); + await queue.stop(); + }); + it('drops queued work for inactive teams without reconciling', async () => { const reconcile = vi.fn(); const queue = new MemberWorkSyncEventQueue({ @@ -93,6 +195,80 @@ describe('MemberWorkSyncEventQueue', () => { await queue.stop(); }); + it('lets manual refresh request an immediate follow-up after an active reconcile', async () => { + let release: () => void = () => { + throw new Error('reconcile did not start'); + }; + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + reconcile: async (request, context) => { + reconciles.push({ request, context }); + if (reconciles.length === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + }, + isTeamActive: () => true, + }); + + queue.enqueue({ + teamName: 'team-a', + memberName: 'bob', + triggerReason: 'config_changed', + runAfterMs: 0, + }); + await vi.advanceTimersByTimeAsync(0); + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' }); + + release(); + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(2); + expect(reconciles[1]).toMatchObject({ + context: { triggerReasons: ['config_changed', 'manual_refresh'] }, + }); + await queue.stop(); + }); + + it('does not let a later event delay a due item waiting behind concurrency', async () => { + let release: () => void = () => { + throw new Error('reconcile did not start'); + }; + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + concurrency: 1, + triggerTiming: { + task_changed: { runAfterMs: 0, maxCoalesceWaitMs: 5_000 }, + inbox_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 }, + }, + reconcile: async (request, context) => { + reconciles.push({ request, context }); + if (reconciles.length === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + }, + isTeamActive: () => true, + }); + + queue.enqueue({ teamName: 'team-a', memberName: 'alice', triggerReason: 'task_changed' }); + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); + await vi.advanceTimersByTimeAsync(0); + + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'inbox_changed' }); + release(); + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(2); + expect(reconciles[1]).toMatchObject({ + request: { memberName: 'bob' }, + context: { triggerReasons: ['inbox_changed', 'task_changed'] }, + }); + await queue.stop(); + }); + it('does not spin timers while concurrency is saturated', async () => { let release: () => void = () => { throw new Error('reconcile did not start'); diff --git a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts new file mode 100644 index 00000000..7fe02e87 --- /dev/null +++ b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MemberWorkSyncTaskImpactResolver } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver'; + +import type { TeamTask } from '@shared/types'; + +describe('MemberWorkSyncTaskImpactResolver', () => { + it('targets owner, reviewer, dependent owners and lead oversight without team-wide fan-out', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-a', + displayId: '#11111111', + subject: 'Changed', + status: 'in_progress', + owner: 'alice', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + from: 'none', + to: 'review', + reviewer: 'bob', + }, + ], + }, + { + id: 'task-b', + subject: 'Unblocked by A', + status: 'pending', + owner: 'tom', + blockedBy: ['task-a'], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'bob', 'team-lead', 'tom']), + }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: '#11111111' })).resolves.toEqual({ + memberNames: ['alice', 'bob', 'tom'], + fallbackTeamWide: false, + diagnostics: [], + }); + }); + + it('falls back to team-wide routing when a task was removed before impact can be resolved', async () => { + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => []) }, + kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) }, + activeMemberSource: { loadActiveMemberNames: vi.fn(async () => ['alice']) }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: 'deleted-task' })).resolves.toEqual( + { + memberNames: [], + fallbackTeamWide: true, + diagnostics: ['task_not_found'], + } + ); + }); + + it('targets lead when a deleted task breaks active dependent work', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-deleted', + subject: 'Deleted dependency', + status: 'deleted', + owner: 'alice', + deletedAt: '2026-04-29T00:00:00.000Z', + }, + { + id: 'task-dependent', + subject: 'Depends on deleted task', + status: 'pending', + owner: 'tom', + blockedBy: ['task-deleted'], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']), + }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-deleted' })).resolves.toEqual( + { + memberNames: ['alice', 'team-lead', 'tom'], + fallbackTeamWide: false, + diagnostics: ['dependent_task_has_deleted_dependency'], + } + ); + }); + + it('targets dependent owners when dependencies reference the changed task by display id', async () => { + const tasks: TeamTask[] = [ + { + id: 'task-a', + displayId: '#11111111', + subject: 'Changed dependency', + status: 'in_progress', + owner: 'alice', + }, + { + id: 'task-b', + subject: 'Depends on display id', + status: 'pending', + owner: 'tom', + blockedBy: ['11111111'], + }, + ]; + const resolver = new MemberWorkSyncTaskImpactResolver({ + taskReader: { getTasks: vi.fn(async () => tasks) }, + kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) }, + activeMemberSource: { + loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']), + }, + } as never); + + await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({ + memberNames: ['alice', 'tom'], + fallbackTeamWide: false, + diagnostics: [], + }); + }); +}); diff --git a/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts index 998ef979..59f0038d 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts @@ -37,6 +37,43 @@ describe('MemberWorkSyncTeamChangeRouter', () => { }); }); + it('routes task events to resolver-impacted members when task identity is available', async () => { + const queue = { + enqueue: vi.fn(), + dropTeam: vi.fn(), + }; + const resolver = { + resolve: vi.fn(async () => ({ + memberNames: ['bob'], + fallbackTeamWide: false, + diagnostics: [], + })), + }; + const router = new MemberWorkSyncTeamChangeRouter( + { loadActiveMemberNames: async () => ['alice', 'bob'] }, + queue as never, + undefined, + resolver as never + ); + + router.noteTeamChange({ + type: 'task', + teamName: 'team-a', + detail: 'task-1.json', + taskId: 'task-1', + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(resolver.resolve).toHaveBeenCalledWith({ teamName: 'team-a', taskId: 'task-1' }); + expect(queue.enqueue).toHaveBeenCalledTimes(1); + expect(queue.enqueue).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'bob', + triggerReason: 'task_changed', + }); + }); + it('routes inbox and tool-finish events to the addressed member only', () => { const { queue, router } = createRouter(); diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index c6801f22..46fb6531 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -4,12 +4,15 @@ import path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { - MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, - resolveMemberWorkSyncNudgeSideEffectsEnabled, } from '@features/member-work-sync/main'; +import { buildMemberWorkSyncOutboxEnsureInput } from '@features/member-work-sync/core/domain'; +import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore'; +import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; +import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter'; import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment'; +import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; const tempRoots: string[] = []; @@ -20,61 +23,208 @@ function makeTempRoot(): string { } afterEach(() => { + setClaudeBasePathOverride(null); for (const root of tempRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } }); +async function seedShadowReadyMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise { + const metricsPath = path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.promises.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'caught_up', + agendaFingerprint: 'agenda:v1:seed', + actionableCount: 0, + evaluatedAt: '2026-01-01T00:00:00.000Z', + }, + }, + recentEvents: Array.from({ length: 20 }, (_, index) => ({ + id: `seed-status-${index}`, + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'caught_up', + agendaFingerprint: `agenda:v1:seed-${index}`, + recordedAt: new Date(Date.UTC(2026, 0, 1, index)).toISOString(), + actionableCount: 0, + })), + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function waitForAssertion(assertion: () => Promise | void): Promise { + const deadline = Date.now() + 1_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + if (lastError) { + throw lastError; + } + await assertion(); +} + describe('createMemberWorkSyncFeature composition', () => { - it('keeps nudge side effects opt-in even when shadow readiness becomes green', () => { - expect(resolveMemberWorkSyncNudgeSideEffectsEnabled({})).toBe(false); - expect( - resolveMemberWorkSyncNudgeSideEffectsEnabled({ - [MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: 'maybe', - }) - ).toBe(false); - }); - - it.each(['1', 'true', 'yes', 'on'])( - 'enables nudge side effects only for explicit truthy env value %s', - (value) => { - expect( - resolveMemberWorkSyncNudgeSideEffectsEnabled({ - [MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value, - }) - ).toBe(true); - } - ); - - it.each(['0', 'false', 'no', 'off', ''])( - 'keeps nudge side effects disabled for explicit falsy env value %s', - (value) => { - expect( - resolveMemberWorkSyncNudgeSideEffectsEnabled({ - [MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value, - }) - ).toBe(false); - } - ); - - it('returns an empty dispatch summary when nudge side effects are disabled', async () => { + it('dispatches a due nudge through the real outbox and inbox by default', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; const feature = createMemberWorkSyncFeature({ - teamsBasePath: makeTempRoot(), - configReader: {} as never, - taskReader: {} as never, - kanbanManager: {} as never, - membersMetaStore: {} as never, - nudgeSideEffectsEnabled: false, + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, }); try { - await expect(feature.dispatchDueNudges(['team-a'])).resolves.toEqual({ - claimed: 0, - delivered: 0, + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + const status = await feature.refreshStatus({ teamName, memberName }); + expect(status).toMatchObject({ + state: 'needs_sync', + shadow: { wouldNudge: true }, + }); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { state: 'shadow_ready' }, + }); + + const outboxInput = buildMemberWorkSyncOutboxEnsureInput({ + status, + hash: new NodeHashAdapter(), + nowIso: status.evaluatedAt, + }); + expect(outboxInput).not.toBeNull(); + const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); + await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({ + ok: true, + outcome: 'created', + }); + + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 1, superseded: 0, retryable: 0, terminal: 0, }); + await expect( + fs.promises.readFile(path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`), { + encoding: 'utf8', + }) + ).resolves.toContain(outboxInput!.id); + } finally { + await feature.dispose(); + } + }); + + it('plans and dispatches due nudges after queued reconcile by default', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship sync', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ + type: 'task', + teamName, + taskId: 'task-1', + } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + const inbox = await fs.promises.readFile( + path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`), + 'utf8' + ); + expect(inbox).toContain('member_work_sync_nudge'); + expect(inbox).toContain(`member-work-sync:${teamName}:${memberName}:agenda:v1:`); + }); } finally { await feature.dispose(); } @@ -96,7 +246,6 @@ describe('createMemberWorkSyncFeature composition', () => { membersMetaStore: { getMembers: vi.fn(async () => []), } as never, - nudgeSideEffectsEnabled: false, }); try { @@ -108,7 +257,7 @@ describe('createMemberWorkSyncFeature composition', () => { } }); - it('builds Claude Stop hook settings without requiring nudge side effects', async () => { + it('builds Claude Stop hook settings with nudges active by default', async () => { const root = makeTempRoot(); const feature = createMemberWorkSyncFeature({ teamsBasePath: root, @@ -116,7 +265,6 @@ describe('createMemberWorkSyncFeature composition', () => { taskReader: {} as never, kanbanManager: {} as never, membersMetaStore: {} as never, - nudgeSideEffectsEnabled: false, }); try { @@ -145,7 +293,7 @@ describe('createMemberWorkSyncFeature composition', () => { } }); - it('builds Codex turn-settled environment without requiring nudge side effects', async () => { + it('builds Codex turn-settled environment with nudges active by default', async () => { const root = makeTempRoot(); const feature = createMemberWorkSyncFeature({ teamsBasePath: root, @@ -153,7 +301,6 @@ describe('createMemberWorkSyncFeature composition', () => { taskReader: {} as never, kanbanManager: {} as never, membersMetaStore: {} as never, - nudgeSideEffectsEnabled: false, }); try { @@ -172,7 +319,7 @@ describe('createMemberWorkSyncFeature composition', () => { } }); - it('builds OpenCode turn-settled environment without requiring nudge side effects', async () => { + it('builds OpenCode turn-settled environment with nudges active by default', async () => { const root = makeTempRoot(); const feature = createMemberWorkSyncFeature({ teamsBasePath: root, @@ -180,7 +327,6 @@ describe('createMemberWorkSyncFeature composition', () => { taskReader: {} as never, kanbanManager: {} as never, membersMetaStore: {} as never, - nudgeSideEffectsEnabled: false, }); try { diff --git a/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts b/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts index 5dde059d..b112b892 100644 --- a/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts +++ b/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts @@ -3,9 +3,13 @@ import { describe, expect, it, vi } from 'vitest'; import { MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REFRESH_STATUS, MEMBER_WORK_SYNC_REPORT, } from '@features/member-work-sync/contracts'; -import { registerMemberWorkSyncIpc, removeMemberWorkSyncIpc } from '@features/member-work-sync/main'; +import { + registerMemberWorkSyncIpc, + removeMemberWorkSyncIpc, +} from '@features/member-work-sync/main'; import type { MemberWorkSyncMetricsRequest, @@ -57,6 +61,21 @@ function makeFeature(): MemberWorkSyncFeatureFacade { evaluatedAt: '2026-04-29T00:00:00.000Z', diagnostics: [], })), + refreshStatus: vi.fn(async (request) => ({ + teamName: request.teamName, + memberName: request.memberName, + state: 'needs_sync' as const, + agenda: { + teamName: request.teamName, + memberName: request.memberName, + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:test', + items: [], + diagnostics: [], + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: [], + })), getMetrics: vi.fn(async (request) => ({ teamName: request.teamName, generatedAt: '2026-04-29T00:00:00.000Z', @@ -135,9 +154,14 @@ describe('registerMemberWorkSyncIpc', () => { registerMemberWorkSyncIpc(ipcMain, feature); - expect(ipcMain.handle).toHaveBeenCalledTimes(3); + expect(ipcMain.handle).toHaveBeenCalledTimes(4); expect([...handlers.keys()].sort()).toEqual( - [MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT].sort() + [ + MEMBER_WORK_SYNC_GET_METRICS, + MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REFRESH_STATUS, + MEMBER_WORK_SYNC_REPORT, + ].sort() ); const statusRequest: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' }; @@ -152,6 +176,9 @@ describe('registerMemberWorkSyncIpc', () => { await expect( handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, statusRequest) ).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob' }); + await expect( + handlers.get(MEMBER_WORK_SYNC_REFRESH_STATUS)?.({}, statusRequest) + ).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob', state: 'needs_sync' }); await expect( handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, metricsRequest) ).resolves.toMatchObject({ teamName: 'team-a' }); @@ -160,6 +187,7 @@ describe('registerMemberWorkSyncIpc', () => { ); expect(feature.getStatus).toHaveBeenCalledWith(statusRequest); + expect(feature.refreshStatus).toHaveBeenCalledWith(statusRequest); expect(feature.getMetrics).toHaveBeenCalledWith(metricsRequest); expect(feature.report).toHaveBeenCalledWith(reportRequest); }); @@ -209,8 +237,9 @@ describe('registerMemberWorkSyncIpc', () => { removeMemberWorkSyncIpc(ipcMain); - expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(4); expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_STATUS); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REFRESH_STATUS); expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS); expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REPORT); expect([...handlers.keys()]).toEqual(['unrelated:channel']); diff --git a/test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts b/test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts index 3019bca4..80e2adbc 100644 --- a/test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts +++ b/test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REFRESH_STATUS, MEMBER_WORK_SYNC_REPORT, } from '@features/member-work-sync/contracts'; import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload'; @@ -41,6 +42,19 @@ describe('createMemberWorkSyncBridge', () => { expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS, request); }); + it('invokes the refresh status channel without changing the request payload', async () => { + const request: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' }; + const response = { ok: true }; + const ipcRenderer = { + invoke: vi.fn(async () => response), + } as unknown as IpcRenderer; + const bridge = createMemberWorkSyncBridge(ipcRenderer); + + await expect(bridge.refreshStatus(request)).resolves.toBe(response); + + expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REFRESH_STATUS, request); + }); + it('invokes the report channel without changing the request payload', async () => { const request: MemberWorkSyncReportRequest = { teamName: 'team-a', @@ -71,8 +85,6 @@ describe('createMemberWorkSyncBridge', () => { } as unknown as IpcRenderer; const bridge = createMemberWorkSyncBridge(ipcRenderer); - await expect(bridge.getStatus({ teamName: 'team-a', memberName: 'bob' })).rejects.toBe( - failure - ); + await expect(bridge.getStatus({ teamName: 'team-a', memberName: 'bob' })).rejects.toBe(failure); }); }); diff --git a/test/main/http/teams.test.ts b/test/main/http/teams.test.ts index 87ad021f..cd0e2c07 100644 --- a/test/main/http/teams.test.ts +++ b/test/main/http/teams.test.ts @@ -453,4 +453,147 @@ describe('HTTP team runtime routes', () => { await app.close(); } }); + + it('serves member work sync diagnostics and explicit refresh routes', async () => { + const app = Fastify(); + const mocks = createServicesMock(); + const queueDiagnostics = { + queued: 0, + running: 0, + enqueued: 2, + coalesced: 1, + reconciled: 1, + dropped: 0, + failed: 0, + queuedItems: [], + runningItems: [], + }; + const metrics = { + teamName: 'demo-team', + generatedAt: '2026-05-05T00:00:00.000Z', + memberCount: 1, + stateCounts: { + caught_up: 1, + needs_sync: 0, + still_working: 0, + blocked: 0, + inactive: 0, + unknown: 0, + }, + actionableItemCount: 0, + wouldNudgeCount: 0, + fingerprintChangeCount: 0, + reportAcceptedCount: 0, + reportRejectedCount: 0, + recentEvents: [], + phase2Readiness: { + state: 'collecting_shadow_data', + reasons: ['insufficient_members'], + thresholds: { + minObservedMembers: 2, + minStatusEvents: 10, + minObservationHours: 1, + maxWouldNudgesPerMemberHour: 1, + maxFingerprintChangesPerMemberHour: 1, + maxReportRejectionRate: 0.1, + }, + rates: { + observationHours: 0, + statusEventCount: 0, + wouldNudgesPerMemberHour: 0, + fingerprintChangesPerMemberHour: 0, + reportRejectionRate: 0, + }, + diagnostics: [], + }, + }; + const refreshedStatus = { + teamName: 'demo-team', + memberName: 'bob', + state: 'caught_up', + agenda: { + teamName: 'demo-team', + memberName: 'bob', + generatedAt: '2026-05-05T00:00:00.000Z', + fingerprint: 'empty', + items: [], + diagnostics: [], + }, + evaluatedAt: '2026-05-05T00:00:00.000Z', + diagnostics: [], + }; + const memberWorkSyncFeature = { + getStatus: vi.fn(), + refreshStatus: vi.fn(async () => refreshedStatus), + getMetrics: vi.fn(async () => metrics), + report: vi.fn(async () => ({ + accepted: true, + code: 'accepted', + message: 'ok', + status: refreshedStatus, + })), + noteTeamChange: vi.fn(), + enqueueStartupScan: vi.fn(), + replayPendingReports: vi.fn(), + dispatchDueNudges: vi.fn(), + buildRuntimeTurnSettledHookSettings: vi.fn(), + buildRuntimeTurnSettledEnvironment: vi.fn(), + drainRuntimeTurnSettledEvents: vi.fn(), + getQueueDiagnostics: vi.fn(() => queueDiagnostics), + dispose: vi.fn(), + }; + registerTeamRoutes(app, { + ...mocks.services, + memberWorkSyncFeature: memberWorkSyncFeature as any, + }); + await app.ready(); + + try { + const diagnosticsResponse = await app.inject({ + method: 'GET', + url: '/api/teams/demo-team/member-work-sync/diagnostics', + }); + expect(diagnosticsResponse.statusCode).toBe(200); + expect(diagnosticsResponse.json()).toMatchObject({ + teamName: 'demo-team', + queue: queueDiagnostics, + metrics, + }); + + const refreshResponse = await app.inject({ + method: 'POST', + url: '/api/teams/demo-team/member-work-sync/bob/refresh', + }); + expect(refreshResponse.statusCode).toBe(200); + expect(refreshResponse.json()).toMatchObject(refreshedStatus); + expect(memberWorkSyncFeature.refreshStatus).toHaveBeenCalledWith({ + teamName: 'demo-team', + memberName: 'bob', + }); + + const reportResponse = await app.inject({ + method: 'POST', + url: '/api/teams/demo-team/member-work-sync/report', + payload: { + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', + taskIds: [' task-a ', '', 'task-a'], + }, + }); + expect(reportResponse.statusCode).toBe(200); + expect(memberWorkSyncFeature.report).toHaveBeenCalledWith({ + teamName: 'demo-team', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', + taskIds: ['task-a'], + source: 'mcp', + }); + } finally { + await app.close(); + } + }); }); diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 6c07fede..21d8d7c7 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -90,7 +90,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { let previousCliPath: string | undefined; let previousCliFlavor: string | undefined; let previousControlUrl: string | undefined; - let previousNudgeFlag: string | undefined; let previousDisableAppBootstrap: string | undefined; let previousDisableRuntimeBootstrap: string | undefined; let previousHome: string | undefined; @@ -112,7 +111,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL; - previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED; previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; previousHome = process.env.HOME; @@ -125,7 +123,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; - process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0'; delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; @@ -148,7 +145,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl); - restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag); restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap); restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap); restoreEnv('HOME', previousHome); @@ -200,7 +196,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { isTeamActive: (name) => activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), listLifecycleActiveTeamNames: async () => [teamName!], - nudgeSideEffectsEnabled: false, queueQuietWindowMs: 500, // Native Claude teammates are registered by the real lead process, but in this // headless harness their bootstrap turn can finish before there is a durable diff --git a/test/main/services/team/MemberWorkSyncCodex.live.test.ts b/test/main/services/team/MemberWorkSyncCodex.live.test.ts index 27d58a20..0681b51c 100644 --- a/test/main/services/team/MemberWorkSyncCodex.live.test.ts +++ b/test/main/services/team/MemberWorkSyncCodex.live.test.ts @@ -17,6 +17,7 @@ import { FatalWaitError, formatMemberWorkSyncDiagnostics, formatProgressDump, + readRuntimeTurnSettledProcessedMetas, restoreEnv, startMemberWorkSyncControlServer, type MemberWorkSyncLiveControlServer, @@ -54,7 +55,6 @@ liveDescribe('Member work sync Codex live e2e', () => { let previousCliPath: string | undefined; let previousCliFlavor: string | undefined; let previousControlUrl: string | undefined; - let previousNudgeFlag: string | undefined; let previousCodexHome: string | undefined; let codexHomeDir: string; let ownsCodexHomeDir: boolean; @@ -71,6 +71,11 @@ liveDescribe('Member work sync Codex live e2e', () => { hasProvisioningRun(teamName: string): boolean; setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void; setControlApiBaseUrlResolver(resolver: (() => Promise) | null): void; + setRuntimeTurnSettledEnvironmentProvider( + provider: + | ((input: { provider: 'claude' | 'codex' | 'opencode' }) => Promise | null>) + | null + ): void; relayInboxFileToLiveRecipient(teamName: string, inboxName: string): Promise<{ relayed: number }>; createTeam( request: Parameters< @@ -94,7 +99,6 @@ liveDescribe('Member work sync Codex live e2e', () => { previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL; - previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED; previousCodexHome = process.env.CODEX_HOME; const shouldUseConnectedAccountHome = allowConnectedChatGptAccount && !hasLiveCodexApiKey(); @@ -112,7 +116,6 @@ liveDescribe('Member work sync Codex live e2e', () => { process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; - process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0'; process.env.CODEX_HOME = codexHomeDir; codexAccountFeature = null; @@ -128,6 +131,7 @@ liveDescribe('Member work sync Codex live e2e', () => { await svc.stopTeam(teamName).catch(() => undefined); } svc?.setControlApiBaseUrlResolver(null); + svc?.setRuntimeTurnSettledEnvironmentProvider(null); providerConnectionService?.setCodexAccountFeature(null); await feature?.dispose().catch(() => undefined); await codexAccountFeature?.dispose().catch(() => undefined); @@ -136,7 +140,6 @@ liveDescribe('Member work sync Codex live e2e', () => { restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl); - restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag); restoreEnv('CODEX_HOME', previousCodexHome); setClaudeBasePathOverride(null); if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') { @@ -151,7 +154,7 @@ liveDescribe('Member work sync Codex live e2e', () => { }); it( - 'lets a real Codex teammate report still-working for the current actionable agenda without automatic nudges', + 'lets a real Codex teammate report still-working for the current actionable agenda with active nudge guards', async () => { const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim(); expect(orchestratorCli).toBeTruthy(); @@ -221,11 +224,13 @@ liveDescribe('Member work sync Codex live e2e', () => { isTeamActive: (name) => activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), listLifecycleActiveTeamNames: async () => [teamName!], - nudgeSideEffectsEnabled: false, }); activeService.setTeamChangeEmitter((event: TeamChangeEvent) => feature!.noteTeamChange(event) ); + activeService.setRuntimeTurnSettledEnvironmentProvider((input) => + feature!.buildRuntimeTurnSettledEnvironment(input) + ); controlServer = await startMemberWorkSyncControlServer(feature); process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl; activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null); @@ -280,9 +285,9 @@ liveDescribe('Member work sync Codex live e2e', () => { `This is a live member-work-sync validation task. Marker: ${marker}.`, 'Do not edit files and do not complete this task.', 'Call task_start for this task.', - `Add one task comment containing exactly: ${marker}:still-working.`, `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`, `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`, + `Only after member_work_sync_report is accepted, add one task comment containing exactly: ${marker}:still-working.`, 'After that stop. Do not send a user-visible message.', ].join('\n'), }); @@ -335,6 +340,17 @@ liveDescribe('Member work sync Codex live e2e', () => { state: 'still_working', }); expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); + await waitUntil(async () => { + await feature!.drainRuntimeTurnSettledEvents(); + const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); + return metas.some( + ({ meta }) => + (meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider === + 'codex' && + (meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName === + teamName + ); + }, 60_000); await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ claimed: 0, delivered: 0, @@ -342,6 +358,418 @@ liveDescribe('Member work sync Codex live e2e', () => { }, 360_000 ); + + it( + 'delivers a real work-sync nudge to a Codex teammate and accepts the follow-up report', + async () => { + const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim(); + expect(orchestratorCli).toBeTruthy(); + await assertExecutable(orchestratorCli!); + + const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL; + const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() || + DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh'; + const marker = `member-work-sync-codex-nudge-${Date.now()}`; + teamName = `member-work-sync-codex-nudge-${Date.now()}`; + const projectPath = path.join(tempDir, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile( + path.join(projectPath, 'README.md'), + '# Member work sync Codex nudge live e2e\n\nKeep this project intentionally tiny.\n', + 'utf8' + ); + + const [ + { TeamProvisioningService }, + { TeamDataService }, + { TeamConfigReader }, + { TeamTaskReader }, + { TeamKanbanManager }, + { TeamMembersMetaStore }, + { createCodexAccountFeature }, + { ProviderConnectionService }, + ] = await Promise.all([ + import('../../../../src/main/services/team/TeamProvisioningService'), + import('../../../../src/main/services/team/TeamDataService'), + import('../../../../src/main/services/team/TeamConfigReader'), + import('../../../../src/main/services/team/TeamTaskReader'), + import('../../../../src/main/services/team/TeamKanbanManager'), + import('../../../../src/main/services/team/TeamMembersMetaStore'), + import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'), + import('../../../../src/main/services/runtime/ProviderConnectionService'), + ]); + + codexAccountFeature = createCodexAccountFeature({ + logger: { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }, + configManager: { + getConfig: () => ({ + providerConnections: { + codex: { + preferredAuthMode: 'chatgpt' as const, + }, + }, + }), + }, + }); + providerConnectionService = ProviderConnectionService.getInstance(); + providerConnectionService.setCodexAccountFeature(codexAccountFeature); + + svc = new TeamProvisioningService(); + const activeService = svc; + const teamDataService = new TeamDataService(); + feature = createMemberWorkSyncFeature({ + teamsBasePath: getTeamsBasePath(), + configReader: new TeamConfigReader(), + taskReader: new TeamTaskReader(), + kanbanManager: new TeamKanbanManager(), + membersMetaStore: new TeamMembersMetaStore(), + isTeamActive: (name) => + activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), + listLifecycleActiveTeamNames: async () => [teamName!], + queueQuietWindowMs: 1, + }); + activeService.setTeamChangeEmitter((event: TeamChangeEvent) => + feature!.noteTeamChange(event) + ); + activeService.setRuntimeTurnSettledEnvironmentProvider((input) => + feature!.buildRuntimeTurnSettledEnvironment(input) + ); + controlServer = await startMemberWorkSyncControlServer(feature); + process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl; + activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null); + await fs.writeFile( + path.join(tempClaudeRoot, 'team-control-api.json'), + JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2), + 'utf8' + ); + + const progressEvents: TeamProvisioningProgress[] = []; + await activeService.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'codex', + providerBackendId: 'codex-native', + model, + effort, + fastMode: 'off', + skipPermissions: true, + prompt: [ + 'Keep launch work minimal.', + 'If you receive a member_work_sync_nudge, do not complete the task.', + 'For a member_work_sync_nudge, call member_work_sync_status first, then call member_work_sync_report with state "still_working", the returned agendaFingerprint/reportToken, and taskIds for the current agenda.', + 'After reporting, stop without a user-visible message.', + ].join(' '), + members: [], + }, + (progress) => { + progressEvents.push(progress); + } + ); + + await waitUntil(async () => { + const last = progressEvents.at(-1); + if (last?.state === 'failed') { + throw new Error(formatProgressDump(progressEvents)); + } + return last?.state === 'ready'; + }, 240_000); + + const config = await new TeamConfigReader().getConfig(teamName); + const memberName = + config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() || + config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() || + config?.members?.[0]?.name?.trim() || + 'team-lead'; + await seedShadowReadyMetrics({ teamName, memberName }); + + const task = await teamDataService.createTask(teamName, { + subject: `Member work sync live nudge ${marker}`, + owner: memberName, + startImmediately: false, + prompt: [ + `This is a live member-work-sync nudge validation task. Marker: ${marker}.`, + 'Do not edit files and do not complete this task.', + 'Only report still_working if member-work-sync asks you to synchronize.', + ].join('\n'), + }); + feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); + + await waitUntil(async () => { + const status = await feature!.getStatus({ teamName: teamName!, memberName }); + if (!status.agenda.items.some((item) => item.taskId === task.id)) { + return false; + } + const inbox = await readInboxMessages(teamName!, memberName); + return inbox.some( + (message) => + message.messageKind === 'member_work_sync_nudge' && + typeof message.messageId === 'string' && + message.text.includes('Work sync check') + ); + }, 60_000, 500, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + const inbox = await readInboxMessages(teamName, memberName); + const nudge = inbox.find((message) => message.messageKind === 'member_work_sync_nudge'); + expect(nudge?.messageId).toBeTruthy(); + const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); + expect(relay.relayed).toBeGreaterThan(0); + + await waitUntil(async () => { + const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!); + if (fatalRuntimeMessage) { + throw new FatalWaitError(fatalRuntimeMessage); + } + await feature!.replayPendingReports([teamName!]); + const status = await feature!.getStatus({ teamName: teamName!, memberName }); + return status.report?.accepted === true && status.report.state === 'still_working'; + }, 240_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + const finalStatus = await feature.getStatus({ teamName, memberName }); + expect(finalStatus.state).toBe('still_working'); + expect(finalStatus.report).toMatchObject({ + accepted: true, + state: 'still_working', + }); + await waitUntil(async () => { + await feature!.drainRuntimeTurnSettledEvents(); + const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); + return metas.some( + ({ meta }) => + (meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider === + 'codex' && + (meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName === + teamName + ); + }, 60_000); + await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + }, + 420_000 + ); + + it( + 'lets a real Codex teammate complete the task and report caught-up after the board clears', + async () => { + const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim(); + expect(orchestratorCli).toBeTruthy(); + await assertExecutable(orchestratorCli!); + + const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL; + const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() || + DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh'; + const marker = `member-work-sync-codex-complete-${Date.now()}`; + teamName = `member-work-sync-codex-complete-${Date.now()}`; + const projectPath = path.join(tempDir, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile( + path.join(projectPath, 'README.md'), + '# Member work sync Codex complete live e2e\n\nKeep this project intentionally tiny.\n', + 'utf8' + ); + + const [ + { TeamProvisioningService }, + { TeamDataService }, + { TeamConfigReader }, + { TeamTaskReader }, + { TeamKanbanManager }, + { TeamMembersMetaStore }, + { createCodexAccountFeature }, + { ProviderConnectionService }, + ] = await Promise.all([ + import('../../../../src/main/services/team/TeamProvisioningService'), + import('../../../../src/main/services/team/TeamDataService'), + import('../../../../src/main/services/team/TeamConfigReader'), + import('../../../../src/main/services/team/TeamTaskReader'), + import('../../../../src/main/services/team/TeamKanbanManager'), + import('../../../../src/main/services/team/TeamMembersMetaStore'), + import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'), + import('../../../../src/main/services/runtime/ProviderConnectionService'), + ]); + + codexAccountFeature = createCodexAccountFeature({ + logger: { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }, + configManager: { + getConfig: () => ({ + providerConnections: { + codex: { + preferredAuthMode: hasLiveCodexApiKey() ? 'auto' : ('chatgpt' as const), + }, + }, + }), + }, + }); + providerConnectionService = ProviderConnectionService.getInstance(); + providerConnectionService.setCodexAccountFeature(codexAccountFeature); + + svc = new TeamProvisioningService(); + const activeService = svc; + const teamDataService = new TeamDataService(); + const taskReader = new TeamTaskReader(); + feature = createMemberWorkSyncFeature({ + teamsBasePath: getTeamsBasePath(), + configReader: new TeamConfigReader(), + taskReader, + kanbanManager: new TeamKanbanManager(), + membersMetaStore: new TeamMembersMetaStore(), + isTeamActive: (name) => + activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), + listLifecycleActiveTeamNames: async () => [teamName!], + queueQuietWindowMs: 1, + }); + activeService.setTeamChangeEmitter((event: TeamChangeEvent) => + feature!.noteTeamChange(event) + ); + activeService.setRuntimeTurnSettledEnvironmentProvider((input) => + feature!.buildRuntimeTurnSettledEnvironment(input) + ); + controlServer = await startMemberWorkSyncControlServer(feature); + process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl; + activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null); + await fs.writeFile( + path.join(tempClaudeRoot, 'team-control-api.json'), + JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2), + 'utf8' + ); + + const progressEvents: TeamProvisioningProgress[] = []; + await activeService.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'codex', + providerBackendId: 'codex-native', + model, + effort, + fastMode: 'off', + skipPermissions: true, + prompt: [ + 'Keep launch work minimal.', + 'If you receive a task, follow task instructions exactly.', + 'Use member_work_sync_status and member_work_sync_report whenever the task asks you to synchronize work state.', + ].join(' '), + members: [], + }, + (progress) => { + progressEvents.push(progress); + } + ); + + await waitUntil(async () => { + const last = progressEvents.at(-1); + if (last?.state === 'failed') { + throw new Error(formatProgressDump(progressEvents)); + } + return last?.state === 'ready'; + }, 240_000); + + const config = await new TeamConfigReader().getConfig(teamName); + const memberName = + config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() || + config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() || + config?.members?.[0]?.name?.trim() || + 'team-lead'; + await seedShadowReadyMetrics({ teamName, memberName }); + + const task = await teamDataService.createTask(teamName, { + subject: `Member work sync live completion ${marker}`, + owner: memberName, + startImmediately: true, + prompt: [ + `This is a live member-work-sync completion validation task. Marker: ${marker}.`, + 'Do not edit files.', + 'Call task_start for this task.', + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`, + 'After that, call task_complete for this task.', + `Then call member_work_sync_status again with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`, + 'If the returned agenda has no items, call member_work_sync_report with state "caught_up", no taskIds, and the exact agendaFingerprint/reportToken returned by that second status call.', + `Only after the caught_up report is accepted, add one task comment containing exactly: ${marker}:completed-and-caught-up.`, + 'After that stop. Do not send a user-visible message.', + ].join('\n'), + }); + feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); + const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); + expect(relay.relayed).toBeGreaterThan(0); + + await waitUntil(async () => { + const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!); + if (fatalRuntimeMessage) { + throw new FatalWaitError(fatalRuntimeMessage); + } + await feature!.replayPendingReports([teamName!]); + await feature!.drainRuntimeTurnSettledEvents(); + const [tasks, status] = await Promise.all([ + taskReader.getTasks(teamName!), + feature!.refreshStatus({ teamName: teamName!, memberName }), + ]); + const currentTask = tasks.find((candidate) => candidate.id === task.id); + const hasCompletionMarker = currentTask?.comments?.some((comment) => + comment.text.includes(`${marker}:completed-and-caught-up`) + ); + return Boolean( + currentTask?.status === 'completed' && + hasCompletionMarker && + status.state === 'caught_up' && + status.agenda.items.length === 0 && + status.report?.accepted === true && + status.report.state === 'caught_up' + ); + }, 300_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + const [tasks, finalStatus, metrics] = await Promise.all([ + taskReader.getTasks(teamName), + feature.getStatus({ teamName, memberName }), + feature.getMetrics({ teamName }), + ]); + const completedTask = tasks.find((candidate) => candidate.id === task.id); + expect(completedTask?.status).toBe('completed'); + expect(finalStatus.state).toBe('caught_up'); + expect(finalStatus.agenda.items).toEqual([]); + expect(finalStatus.report).toMatchObject({ + accepted: true, + state: 'caught_up', + }); + expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); + await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + }, + 480_000 + ); }); async function readFatalRuntimeMessage(teamName: string): Promise { @@ -397,3 +825,86 @@ function resolveConnectedCodexHome(previousCodexHome: string | undefined): strin } return path.join(os.userInfo().homedir, '.codex'); } + +async function seedShadowReadyMetrics(input: { + teamName: string; + memberName: string; +}): Promise { + const metricsPath = path.join( + getTeamsBasePath(), + input.teamName, + '.member-work-sync', + 'indexes', + 'metrics.json' + ); + const startMs = Date.now() - 2 * 60 * 60_000; + await fs.mkdir(path.dirname(metricsPath), { recursive: true }); + await fs.writeFile( + metricsPath, + `${JSON.stringify( + { + schemaVersion: 2, + members: { + [input.memberName]: { + memberName: input.memberName, + state: 'caught_up', + agendaFingerprint: 'agenda:v1:seed', + actionableCount: 0, + evaluatedAt: new Date(startMs).toISOString(), + providerId: 'codex', + }, + }, + recentEvents: Array.from({ length: 24 }, (_, index) => ({ + id: `seed-status-${index}`, + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'caught_up', + agendaFingerprint: `agenda:v1:seed-${index}`, + recordedAt: new Date(startMs + index * 6 * 60_000).toISOString(), + actionableCount: 0, + providerId: 'codex', + })), + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function readInboxMessages(teamName: string, memberName: string): Promise< + Array<{ + messageId?: string; + messageKind?: string; + text: string; + read?: boolean; + }> +> { + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${memberName}.json`); + const raw = await fs.readFile(inboxPath, 'utf8').catch(() => '[]'); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .filter((message): message is Record => + Boolean(message) && typeof message === 'object' + ) + .flatMap((message) => { + const text = typeof message.text === 'string' ? message.text : ''; + if (!text) { + return []; + } + return [ + { + ...(typeof message.messageId === 'string' ? { messageId: message.messageId } : {}), + ...(typeof message.messageKind === 'string' + ? { messageKind: message.messageKind } + : {}), + text, + ...(typeof message.read === 'boolean' ? { read: message.read } : {}), + }, + ]; + }); +} diff --git a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts index 794f072e..65516bf9 100644 --- a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts +++ b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts @@ -46,7 +46,6 @@ liveDescribe('Mixed provider team launch live e2e', () => { let projectPath: string; let previousCliPath: string | undefined; let previousCliFlavor: string | undefined; - let previousNudgeFlag: string | undefined; let previousCodexHome: string | undefined; let previousHome: string | undefined; let previousUserProfile: string | undefined; @@ -78,7 +77,6 @@ liveDescribe('Mixed provider team launch live e2e', () => { previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; - previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED; previousCodexHome = process.env.CODEX_HOME; previousHome = process.env.HOME; previousUserProfile = process.env.USERPROFILE; @@ -89,7 +87,6 @@ liveDescribe('Mixed provider team launch live e2e', () => { process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; - process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0'; process.env.CODEX_HOME = resolveConnectedCodexHome(previousCodexHome); process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; @@ -118,7 +115,6 @@ liveDescribe('Mixed provider team launch live e2e', () => { restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); - restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag); restoreEnv('CODEX_HOME', previousCodexHome); restoreEnv('HOME', previousHome); restoreEnv('USERPROFILE', previousUserProfile); diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts index 8f21a502..4b633499 100644 --- a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -133,15 +133,63 @@ describe('OpenCodePromptDeliveryLedger', () => { await expect(store.list()).resolves.toHaveLength(1); }); + it('upgrades legacy pending records with message kind without changing payload identity', async () => { + const store = createStore(); + const payloadHash = hashOpenCodePromptDeliveryPayload({ + text: 'Work sync check', + replyRecipient: 'team-lead', + actionMode: 'do', + source: 'watcher', + }); + + const legacy = await store.ensurePending({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + inboxMessageId: 'msg-work-sync', + inboxTimestamp: '2026-04-25T09:59:00.000Z', + source: 'watcher', + replyRecipient: 'team-lead', + actionMode: 'do', + taskRefs: [], + payloadHash, + now: '2026-04-25T10:00:00.000Z', + }); + const envelope = JSON.parse(await fs.readFile(ledgerPath(), 'utf8')) as { + data: Record[]; + }; + delete envelope.data[0].messageKind; + await fs.writeFile(ledgerPath(), `${JSON.stringify(envelope, null, 2)}\n`, 'utf8'); + + const upgraded = await store.ensurePending({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + inboxMessageId: 'msg-work-sync', + inboxTimestamp: '2026-04-25T09:59:00.000Z', + source: 'watcher', + messageKind: 'member_work_sync_nudge', + replyRecipient: 'team-lead', + actionMode: 'do', + taskRefs: [], + payloadHash, + now: '2026-04-25T10:00:30.000Z', + }); + + expect(upgraded.id).toBe(legacy.id); + expect(upgraded.messageKind).toBe('member_work_sync_nudge'); + expect(upgraded.payloadHash).toBe(payloadHash); + expect(upgraded.attempts).toBe(0); + await expect(store.list()).resolves.toHaveLength(1); + }); + it.each(corruptionCases)('rejects corrupted persisted records with %s', async (_name, mutate) => { const store = await writeCorruptedLedgerRecord(mutate); await expect(store.list()).rejects.toMatchObject({ reason: 'invalid_data', }); - await expect(fs.readdir(tempDir)).resolves.toContain( - 'opencode-prompt-delivery-ledger.json' - ); + await expect(fs.readdir(tempDir)).resolves.toContain('opencode-prompt-delivery-ledger.json'); expect((await fs.readdir(tempDir)).some((name) => name.includes('.invalid_data.'))).toBe(true); }); @@ -211,12 +259,12 @@ describe('OpenCodePromptDeliveryLedger', () => { reason: 'visible_reply_ack_only_still_requires_answer', scheduledAt: '2026-04-25T10:00:02.000Z', }); - expect(isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:29.000Z'))).toBe( - false - ); - expect(isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:30.000Z'))).toBe( - true - ); + expect( + isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:29.000Z')) + ).toBe(false); + expect( + isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:30.000Z')) + ).toBe(true); }); it('records empty assistant delivery results as unanswered and stores plain text previews', async () => { @@ -353,11 +401,13 @@ describe('OpenCodePromptDeliveryLedger', () => { }); expect(responded.status).toBe('responded'); - await expect(store.getActiveForMember({ - teamName: 'team-a', - memberName: 'jack', - laneId: 'secondary:opencode:jack', - })).resolves.toMatchObject({ + await expect( + store.getActiveForMember({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + }) + ).resolves.toMatchObject({ id: record.id, responseState: 'responded_plain_text', }); @@ -376,11 +426,13 @@ describe('OpenCodePromptDeliveryLedger', () => { visibleReplyCorrelation: 'plain_assistant_text', }); - await expect(store.getActiveForMember({ - teamName: 'team-a', - memberName: 'jack', - laneId: 'secondary:opencode:jack', - })).resolves.toBeNull(); + await expect( + store.getActiveForMember({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + }) + ).resolves.toBeNull(); }); it('does not keep responded live deliveries active when no inbox commit is needed', async () => { @@ -419,11 +471,13 @@ describe('OpenCodePromptDeliveryLedger', () => { expect(responded.status).toBe('responded'); expect(responded.inboxReadCommittedAt).toBeNull(); - await expect(store.getActiveForMember({ - teamName: 'team-a', - memberName: 'bob', - laneId: 'secondary:opencode:bob', - })).resolves.toBeNull(); + await expect( + store.getActiveForMember({ + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + }) + ).resolves.toBeNull(); const peer = await store.ensurePending({ teamName: 'team-a', @@ -439,11 +493,13 @@ describe('OpenCodePromptDeliveryLedger', () => { now: '2026-04-25T10:01:00.000Z', }); - await expect(store.getActiveForMember({ - teamName: 'team-a', - memberName: 'bob', - laneId: 'secondary:opencode:bob', - })).resolves.toMatchObject({ + await expect( + store.getActiveForMember({ + teamName: 'team-a', + memberName: 'bob', + laneId: 'secondary:opencode:bob', + }) + ).resolves.toMatchObject({ id: peer.id, inboxMessageId: 'peer-relay', }); @@ -585,21 +641,25 @@ describe('OpenCodePromptDeliveryLedger', () => { now: '2026-04-25T10:00:00.000Z', }); - await expect(store.pruneTerminalRecords({ - now: new Date('2026-04-25T10:00:20.000Z'), - respondedRetentionMs: 10_000, - failedRetentionMs: 30_000, - })).resolves.toEqual({ pruned: 1, remaining: 2 }); + await expect( + store.pruneTerminalRecords({ + now: new Date('2026-04-25T10:00:20.000Z'), + respondedRetentionMs: 10_000, + failedRetentionMs: 30_000, + }) + ).resolves.toEqual({ pruned: 1, remaining: 2 }); expect((await store.list()).map((record) => record.inboxMessageId).sort()).toEqual([ active.inboxMessageId, failed.inboxMessageId, ]); - await expect(store.pruneTerminalRecords({ - now: new Date('2026-04-25T10:00:40.000Z'), - respondedRetentionMs: 10_000, - failedRetentionMs: 30_000, - })).resolves.toEqual({ pruned: 1, remaining: 1 }); + await expect( + store.pruneTerminalRecords({ + now: new Date('2026-04-25T10:00:40.000Z'), + respondedRetentionMs: 10_000, + failedRetentionMs: 30_000, + }) + ).resolves.toEqual({ pruned: 1, remaining: 1 }); expect((await store.list()).map((record) => record.inboxMessageId)).toEqual([ active.inboxMessageId, ]); diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 65c80af3..c07efb38 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -541,6 +541,57 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('never use #00000000'); }); + it('sends member work sync nudges with report-oriented response instructions', async () => { + const sendOpenCodeTeamMessage = vi.fn< + NonNullable + >(async () => ({ + accepted: true, + sessionId: 'oc-session-bob', + memberName: 'bob', + diagnostics: [], + })); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + sendOpenCodeTeamMessage, + }) + ); + + await adapter.sendMessageToMember({ + runId: 'run-1', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + text: 'Work sync check', + messageId: 'msg-work-sync', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }], + }); + + expect(sendOpenCodeTeamMessage).toHaveBeenCalledWith( + expect.objectContaining({ + messageKind: 'member_work_sync_nudge', + actionMode: 'do', + }) + ); + const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? ''; + expect(sentText).toContain('"messageKind":"member_work_sync_nudge"'); + expect(sentText).toContain('This delivered app message is a member-work-sync nudge.'); + expect(sentText).toContain('agent-teams_member_work_sync_status'); + expect(sentText).toContain('agent-teams_member_work_sync_report'); + expect(sentText).toContain('mcp__agent-teams__member_work_sync_report'); + expect(sentText).toContain('teamName="team-a"'); + expect(sentText).toContain('memberName="bob"'); + expect(sentText).toContain('taskIds: "task-1"'); + expect(sentText).toContain( + 'Do not use provider names, runtime names, or team names as memberName' + ); + expect(sentText).not.toContain('Include relayOfMessageId="msg-work-sync"'); + expect(sentText).not.toContain('You must not end this turn empty.'); + }); + it('does not parse legacy native SendMessage wording to infer OpenCode reply recipient', async () => { const sendOpenCodeTeamMessage = vi.fn< NonNullable @@ -568,7 +619,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? ''; expect(sentText).toContain('Use teamName="team-a", to="user", from="bob", text, and summary.'); - expect(sentText).not.toContain('Use teamName="team-a", to="alice", from="bob", text, and summary.'); + expect(sentText).not.toContain( + 'Use teamName="team-a", to="alice", from="bob", text, and summary.' + ); }); it('keeps missing bridge members pending while reconcile is still launching', async () => { diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 3a7726a8..da5fa3be 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -10135,7 +10135,11 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs).toHaveLength(2); expect(adapter.messageInputs[0]).toMatchObject({ - runId: freshRun.runId, + runId: latestOpenCodeLaunchRunId( + adapter, + cancelledTeamName, + 'secondary:opencode:bob' + ), teamName: cancelledTeamName, laneId: 'secondary:opencode:bob', memberName: 'bob', @@ -10144,7 +10148,11 @@ describe('Team agent launch matrix safe e2e', () => { messageId: 'msg-fresh-mixed-opencode-after-cancelled-handoff', }); expect(adapter.messageInputs[1]).toMatchObject({ - runId: survivingRun.runId, + runId: latestOpenCodeLaunchRunId( + adapter, + survivingTeamName, + 'secondary:opencode:tom' + ), teamName: survivingTeamName, laneId: 'secondary:opencode:tom', memberName: 'tom', @@ -10247,7 +10255,11 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs[0]).toMatchObject({ - runId: survivingRun.runId, + runId: latestOpenCodeLaunchRunId( + adapter, + survivingTeamName, + 'secondary:opencode:bob' + ), teamName: survivingTeamName, laneId: 'secondary:opencode:bob', memberName: 'bob', @@ -11818,7 +11830,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs[0]).toMatchObject({ - runId: run.runId, + runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:tom'), teamName, laneId: 'secondary:opencode:tom', memberName: 'tom', @@ -11861,7 +11873,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs[0]).toMatchObject({ - runId: run.runId, + runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:bob'), teamName, laneId: 'secondary:opencode:bob', memberName: 'bob', @@ -11932,7 +11944,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs[0]).toMatchObject({ - runId: run.runId, + runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:tom'), teamName, laneId: 'secondary:opencode:tom', memberName: 'tom', @@ -12009,7 +12021,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs[0]).toMatchObject({ - runId: run.runId, + runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:bob'), teamName, laneId: 'secondary:opencode:bob', memberName: 'bob', @@ -12099,7 +12111,11 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs).toHaveLength(2); expect(adapter.messageInputs[0]).toMatchObject({ - runId: firstRun.runId, + runId: latestOpenCodeLaunchRunId( + adapter, + firstTeamName, + 'secondary:opencode:bob' + ), teamName: firstTeamName, laneId: 'secondary:opencode:bob', memberName: 'bob', @@ -12108,7 +12124,11 @@ describe('Team agent launch matrix safe e2e', () => { messageId: 'msg-cross-team-detach-first-bob', }); expect(adapter.messageInputs[1]).toMatchObject({ - runId: secondRun.runId, + runId: latestOpenCodeLaunchRunId( + adapter, + secondTeamName, + 'secondary:opencode:tom' + ), teamName: secondTeamName, laneId: 'secondary:opencode:tom', memberName: 'tom', @@ -17065,6 +17085,18 @@ class BlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { } } +function latestOpenCodeLaunchRunId( + adapter: { readonly launchInputs: readonly TeamRuntimeLaunchInput[] }, + teamName: string, + laneId: string +): string { + const launchInput = [...adapter.launchInputs] + .reverse() + .find((input) => input.teamName === teamName && input.laneId === laneId); + expect(launchInput?.runId).toBeTruthy(); + return launchInput!.runId; +} + class BlockingStopOpenCodeRuntimeAdapter extends BlockingOpenCodeRuntimeAdapter { private releaseStopGate: (() => void) | null = null; private readonly stopGate = new Promise((resolve) => { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 742ff85a..78635346 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -431,6 +431,61 @@ async function writeDefaultBobOpenCodeBootstrapEvidence(): Promise { }); } +async function configureOpenCodeBobDeliveryService(input: { + svc: TeamProvisioningService; + sendMessageToMember: ReturnType; + observeMessageDelivery?: ReturnType; +}): Promise { + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember: input.sendMessageToMember, + observeMessageDelivery: input.observeMessageDelivery ?? vi.fn(), + } as any, + ]); + input.svc.setRuntimeAdapterRegistry(registry); + + (input.svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (input.svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (input.svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + await writeDefaultBobOpenCodeBootstrapEvidence(); + (input.svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (input.svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (input.svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; +} + function createMemberSpawnStatusEntry( overrides: Record = {} ): Record { @@ -583,7 +638,6 @@ describe('TeamProvisioningService', () => { await expect(svc.warmup()).resolves.not.toThrow(); expect(spawnCli).toHaveBeenCalled(); }); - }); describe('team launch notifications', () => { @@ -1643,7 +1697,8 @@ describe('TeamProvisioningService', () => { ], })), }; - const processRows = createDeferred>>(); + const processRows = + createDeferred>>(); vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) .mockReturnValueOnce(processRows.promise) .mockResolvedValueOnce([]); @@ -1720,7 +1775,8 @@ describe('TeamProvisioningService', () => { })), }; (svc as any).provisioningRunByTeam.set('runtime-team', 'run-1'); - const processRows = createDeferred>>(); + const processRows = + createDeferred>>(); vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) .mockReturnValueOnce(processRows.promise) .mockResolvedValueOnce([]); @@ -4885,10 +4941,7 @@ describe('TeamProvisioningService', () => { }; await expect( - (svc as any).resolveOpenCodeMembersForRuntimeLane( - 'team-a', - 'secondary:opencode:bob' - ) + (svc as any).resolveOpenCodeMembersForRuntimeLane('team-a', 'secondary:opencode:bob') ).resolves.toEqual(['bob']); expect(getConfigSnapshot).toHaveBeenCalledTimes(1); @@ -5098,13 +5151,16 @@ describe('TeamProvisioningService', () => { }) ).resolves.toMatchObject({ delivered: true, - responsePending: false, - responseState: 'responded_plain_text', - visibleReplyCorrelation: 'plain_assistant_text', - }); + responsePending: false, + responseState: 'responded_plain_text', + visibleReplyCorrelation: 'plain_assistant_text', + }); const userInbox = JSON.parse( - await fsPromises.readFile(path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'), 'utf8') + await fsPromises.readFile( + path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'), + 'utf8' + ) ) as Array>; expect(userInbox).toHaveLength(1); expect(userInbox[0]).toMatchObject({ @@ -6465,6 +6521,107 @@ describe('TeamProvisioningService', () => { }); }); + it('accepts member work sync report as OpenCode delivery response proof', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_non_visible_tool' as const, + deliveredUserMessageId: 'oc-user-work-sync', + assistantMessageId: 'oc-assistant-work-sync-report', + toolCallNames: ['member_work_sync_status', 'member_work_sync_report'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', + messageId: 'msg-work-sync-report', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + taskRefs: [ + { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }, + ], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'responded', + }); + }); + + it('keeps member work sync status-only OpenCode deliveries pending', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_non_visible_tool' as const, + deliveredUserMessageId: 'oc-user-work-sync-status', + assistantMessageId: 'oc-assistant-work-sync-status', + toolCallNames: ['member_work_sync_status'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', + messageId: 'msg-work-sync-status-only', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + taskRefs: [ + { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }, + ], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'retry_scheduled', + reason: 'non_visible_tool_without_task_progress', + }); + }); + it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => { const svc = new TeamProvisioningService(); const emptyResponseObservation = { @@ -6955,123 +7112,123 @@ describe('TeamProvisioningService', () => { delivered: true, diagnostics: [], }); - expect(sendMessageToMember).toHaveBeenCalledWith( - expect.objectContaining({ - runId: 'opencode-run-durable', - teamName, - laneId, + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-durable', + teamName, + laneId, memberName: 'bob', cwd: '/repo', text: 'hello after restart', messageId: 'msg-after-restart', - }) - ); - }); + }) + ); + }); - it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => { - const svc = new TeamProvisioningService(); - const teamName = 'team-a'; - const laneId = 'secondary:opencode:bob'; - const sendMessageToMember = vi.fn(async (input: Record) => ({ - ok: true, - providerId: 'opencode', - memberName: String(input.memberName), - sessionId: 'oc-session-bob', - diagnostics: [], - })); - svc.setRuntimeAdapterRegistry( - new TeamRuntimeAdapterRegistry([ - { - providerId: 'opencode', - prepare: vi.fn(), - launch: vi.fn(), - reconcile: vi.fn(), - stop: vi.fn(), - sendMessageToMember, - } as any, - ]) - ); + it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'team-a'; + const laneId = 'secondary:opencode:bob'; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + diagnostics: [], + })); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]) + ); - (svc as any).aliveRunByTeam.set(teamName, 'primary-run'); - (svc as any).runs.set('primary-run', { - runId: 'primary-run', - teamName, - processKilled: false, - cancelRequested: false, - progress: { state: 'ready' }, - request: { providerId: 'codex', cwd: '/repo' }, - mixedSecondaryLanes: [ - { - laneId, - providerId: 'opencode', - member: { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, - runId: 'opencode-run-live', - state: 'finished', - result: { - members: { - bob: { - bootstrapConfirmed: true, - launchState: 'confirmed_alive', - sessionId: 'oc-session-bob', + (svc as any).aliveRunByTeam.set(teamName, 'primary-run'); + (svc as any).runs.set('primary-run', { + runId: 'primary-run', + teamName, + processKilled: false, + cancelRequested: false, + progress: { state: 'ready' }, + request: { providerId: 'codex', cwd: '/repo' }, + mixedSecondaryLanes: [ + { + laneId, + providerId: 'opencode', + member: { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + runId: 'opencode-run-live', + state: 'finished', + result: { + members: { + bob: { + bootstrapConfirmed: true, + launchState: 'confirmed_alive', + sessionId: 'oc-session-bob', + }, }, }, + warnings: [], + diagnostics: [], }, - warnings: [], - diagnostics: [], - }, - ], - }); - (svc as any).configReader = { - getConfig: vi.fn(async () => ({ - projectPath: '/repo', - members: [ - { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, - { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, ], - })), - }; - (svc as any).teamMetaStore = { - getMeta: vi.fn(async () => ({ - launchIdentity: { providerId: 'codex' }, - providerId: 'codex', - })), - }; - (svc as any).membersMetaStore = { - getMembers: vi.fn(async () => [ - { - name: 'bob', - providerId: 'opencode', - model: 'opencode/minimax-m2.5-free', - }, - ]), - }; + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; - await expect( - svc.deliverOpenCodeMemberMessage(teamName, { - memberName: 'bob', - text: 'hello live lane', - messageId: 'msg-live-lane', - }) - ).resolves.toMatchObject({ - delivered: true, - diagnostics: [], + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'hello live lane', + messageId: 'msg-live-lane', + }) + ).resolves.toMatchObject({ + delivered: true, + diagnostics: [], + }); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-live', + teamName, + laneId, + memberName: 'bob', + }) + ); + expect(sendMessageToMember).not.toHaveBeenCalledWith( + expect.objectContaining({ runId: 'primary-run' }) + ); }); - expect(sendMessageToMember).toHaveBeenCalledWith( - expect.objectContaining({ - runId: 'opencode-run-live', - teamName, - laneId, - memberName: 'bob', - }) - ); - expect(sendMessageToMember).not.toHaveBeenCalledWith( - expect.objectContaining({ runId: 'primary-run' }) - ); - }); - it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => { - const svc = new TeamProvisioningService(); - const teamName = 'team-a'; + it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'team-a'; const laneId = 'secondary:opencode:bob'; const sendMessageToMember = vi.fn(async (input: Record) => ({ ok: true, @@ -7549,7 +7706,9 @@ describe('TeamProvisioningService', () => { activeRunId: launchInput?.runId, highWatermark: 0, }); - await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + await expect( + readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName) + ).resolves.toMatchObject({ lanes: { 'secondary:opencode:bob': { state: 'degraded', @@ -7652,7 +7811,9 @@ describe('TeamProvisioningService', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(run); await vi.waitFor( async () => { - await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + await expect( + readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName) + ).resolves.toMatchObject({ lanes: { 'secondary:opencode:tom': { state: 'degraded', @@ -7772,7 +7933,9 @@ describe('TeamProvisioningService', () => { ).resolves.toMatchObject({ activeRunId: launchInput?.runId, }); - await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + await expect( + readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName) + ).resolves.toMatchObject({ lanes: { 'secondary:opencode:tom': { state: 'active', @@ -13502,7 +13665,9 @@ describe('TeamProvisioningService', () => { teamName, laneId, state: 'active', - diagnostics: ['OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'], + diagnostics: [ + 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.', + ], }); await writeCommittedOpenCodeSessionStore({ teamName, @@ -15434,11 +15599,11 @@ describe('TeamProvisioningService', () => { bootstrapConfirmed: true, hardFailure: false, }); - await expect(fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')).rejects.toMatchObject( - { - code: 'ENOENT', - } - ); + await expect( + fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8') + ).rejects.toMatchObject({ + code: 'ENOENT', + }); await expect( fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8') ).rejects.toMatchObject({ @@ -17005,7 +17170,9 @@ describe('TeamProvisioningService', () => { launchState: 'failed_to_start', hardFailureReason: 'Tom provider launch failed.', }); - const summary = JSON.parse(await fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8')); + const summary = JSON.parse( + await fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8') + ); expect(summary).toMatchObject({ teamLaunchState: 'partial_failure', confirmedCount: 2, @@ -17097,7 +17264,9 @@ describe('TeamProvisioningService', () => { launchState: 'failed_to_start', hardFailureReason: exactOpenCodeReason, }); - const persisted = JSON.parse(await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')); + const persisted = JSON.parse( + await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8') + ); expect(persisted.members.alice).toMatchObject({ laneId: 'secondary:opencode:alice', launchState: 'failed_to_start', diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 28718cf1..a5aa95cc 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -306,6 +306,48 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); }); + it('treats member work sync nudges as actionable in lead relay prompt', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + service.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123'); + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'system', + text: 'Work sync check: you have current actionable work assigned.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + summary: 'Work sync check', + messageId: 'm-work-sync-1', + source: 'system_notification', + messageKind: 'member_work_sync_nudge', + taskRefs: [{ teamName, taskId: 'task-1', displayId: '11111111' }], + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + expect(run?.leadRelayCapture).toBeTruthy(); + + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + expect(payload).toContain('Message kind: member_work_sync_nudge'); + expect(payload).toContain('it is actionable work-sync control traffic'); + expect(payload).toContain( + 'Call member_work_sync_status with teamName=\\"my-team\\", memberName=\\"team-lead\\", controlUrl=\\"http://127.0.0.1:43123\\"' + ); + expect(payload).toContain('call member_work_sync_report'); + expect(payload).toContain('controlUrl=\\"http://127.0.0.1:43123\\"'); + expect(payload).toContain('taskIds from the nudge task refs'); + expect(payload).toContain( + 'Do not use provider names, runtime names, or team names as memberName' + ); + expect(payload).toContain('Do NOT ignore it as a pure system notification'); + + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + await expect(relayPromise).resolves.toBe(1); + }); + it('uses snapshot config reads for lead inbox relay routing', async () => { const getConfig = vi.fn(async () => { throw new Error('verified config read should not be used for inbox relay routing'); @@ -2269,9 +2311,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 }); expect(recipientSpy).toHaveBeenCalledTimes(1); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(true); }); diff --git a/test/main/services/team/memberWorkSyncLiveHarness.ts b/test/main/services/team/memberWorkSyncLiveHarness.ts index 7a68f805..74716a5a 100644 --- a/test/main/services/team/memberWorkSyncLiveHarness.ts +++ b/test/main/services/team/memberWorkSyncLiveHarness.ts @@ -40,6 +40,21 @@ export async function startMemberWorkSyncControlServer( sendJson(response, 200, payload); return; } + if ( + request.method === 'POST' && + parts.length === 6 && + parts[0] === 'api' && + parts[1] === 'teams' && + parts[3] === 'member-work-sync' && + parts[5] === 'refresh' + ) { + const payload = await feature.refreshStatus({ + teamName: parts[2], + memberName: parts[4], + }); + sendJson(response, 200, payload); + return; + } if ( request.method === 'POST' && parts.length === 5 && @@ -152,9 +167,7 @@ export async function formatMemberWorkSyncDiagnostics(input: { input.feature.getMetrics({ teamName: input.teamName }), input.taskId ? new TeamTaskReader().getTasks(input.teamName) : Promise.resolve([]), ]); - const task = input.taskId - ? tasks.find((candidate) => candidate.id === input.taskId) - : undefined; + const task = input.taskId ? tasks.find((candidate) => candidate.id === input.taskId) : undefined; return [ 'Member work sync live diagnostics:', JSON.stringify( @@ -240,12 +253,7 @@ export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string meta: Record; }> > { - const processedDir = path.join( - teamsBasePath, - '.member-work-sync', - 'runtime-hooks', - 'processed' - ); + const processedDir = path.join(teamsBasePath, '.member-work-sync', 'runtime-hooks', 'processed'); const entries = await fs.readdir(processedDir, { withFileTypes: true }).catch(() => []); const metas = await Promise.all( entries diff --git a/test/renderer/api/httpClient.memberWorkSync.test.ts b/test/renderer/api/httpClient.memberWorkSync.test.ts index 9dd707f3..fb0c2176 100644 --- a/test/renderer/api/httpClient.memberWorkSync.test.ts +++ b/test/renderer/api/httpClient.memberWorkSync.test.ts @@ -29,12 +29,16 @@ describe('HttpAPIClient memberWorkSync', () => { const client = new HttpAPIClient('http://127.0.0.1:53123'); fetchMock .mockResolvedValueOnce(jsonResponse({ state: 'needs_sync' })) + .mockResolvedValueOnce(jsonResponse({ state: 'still_working' })) .mockResolvedValueOnce(jsonResponse({ memberCount: 1 })) .mockResolvedValueOnce(jsonResponse({ accepted: true })); await expect( client.memberWorkSync.getStatus({ teamName: 'demo team', memberName: 'bob/qa' }) ).resolves.toEqual({ state: 'needs_sync' }); + await expect( + client.memberWorkSync.refreshStatus({ teamName: 'demo team', memberName: 'bob/qa' }) + ).resolves.toEqual({ state: 'still_working' }); await expect(client.memberWorkSync.getMetrics({ teamName: 'demo team' })).resolves.toEqual({ memberCount: 1, }); @@ -57,11 +61,21 @@ describe('HttpAPIClient memberWorkSync', () => { ); expect(fetchMock).toHaveBeenNthCalledWith( 2, + 'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/bob%2Fqa/refresh', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + signal: expect.any(AbortSignal), + }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, 'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/metrics', expect.objectContaining({ signal: expect.any(AbortSignal) }) ); expect(fetchMock).toHaveBeenNthCalledWith( - 3, + 4, 'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/report', expect.objectContaining({ method: 'POST', diff --git a/test/renderer/components/team/ClaudeLogsPanel.test.ts b/test/renderer/components/team/ClaudeLogsPanel.test.ts index 9a95f41a..28d35eb4 100644 --- a/test/renderer/components/team/ClaudeLogsPanel.test.ts +++ b/test/renderer/components/team/ClaudeLogsPanel.test.ts @@ -95,11 +95,13 @@ describe('ClaudeLogsPanel', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('2 lines'); + expect(host.textContent).toContain('2 raw lines'); expect(host.textContent).toContain('first line'); expect(host.textContent).not.toContain('Team is not running.'); expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).not.toBeNull(); - expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe('[stdout]\nfirst line\nsecond line'); + expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe( + '[stdout]\nfirst line\nsecond line' + ); await act(async () => { root.unmount(); diff --git a/test/renderer/utils/streamJsonParser.test.ts b/test/renderer/utils/streamJsonParser.test.ts index 6a5140a0..5f263c26 100644 --- a/test/renderer/utils/streamJsonParser.test.ts +++ b/test/renderer/utils/streamJsonParser.test.ts @@ -17,7 +17,10 @@ describe('parseStreamJsonToGroups', () => { expect(groups).toHaveLength(1); expect(groups[0]?.items).toEqual( expect.arrayContaining([ - expect.objectContaining({ type: 'output', content: 'Codex native thread started: thread-1.' }), + expect.objectContaining({ + type: 'output', + content: 'Codex native thread started: thread-1.', + }), expect.objectContaining({ type: 'output', content: 'Codex turn started.' }), expect.objectContaining({ type: 'output', content: 'Lead response ready.' }), expect.objectContaining({ @@ -53,6 +56,46 @@ describe('parseStreamJsonToGroups', () => { }); }); + it('renders Codex native command execution and file change events from live JSONL logs', () => { + const groups = parseStreamJsonToGroups( + [ + '{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"pwd","status":"in_progress"}}', + '{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pwd","aggregated_output":"/repo\\n","exit_code":0,"status":"completed"}}', + '{"type":"item.completed","item":{"id":"item_2","type":"file_change","changes":[{"path":"/repo/src/a.ts","kind":"update"}],"status":"completed"}}', + ].join('\n') + ); + + const tools = groups.flatMap((group) => group.items).filter((item) => item.type === 'tool'); + + expect(tools).toHaveLength(2); + expect(tools[0]).toMatchObject({ + type: 'tool', + tool: { + id: 'item_1', + name: 'Bash', + input: { command: 'pwd' }, + isOrphaned: false, + result: { + content: '/repo\n', + isError: false, + }, + }, + }); + expect(tools[1]).toMatchObject({ + type: 'tool', + tool: { + id: 'item_2', + name: 'Edit', + input: { file_path: '/repo/src/a.ts' }, + isOrphaned: false, + result: { + content: 'File changes:\n- /repo/src/a.ts (update)', + isError: false, + }, + }, + }); + }); + it('renders projected Codex native system status rows from persisted logs', () => { const groups = parseStreamJsonToGroups( [ From ca28e1cef699eb1517d8318db133bf93b071cd38 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 00:02:51 +0300 Subject: [PATCH 6/7] chore(runtime): pin orchestrator 0.0.18 --- README.md | 44 ++--- ...gastown-paperclip-comparison-2026-05-05.md | 162 ++++++++++++++++++ docs/research/real-competitors-comparison.md | 6 +- .../components/sections/ComparisonSection.vue | 154 +++++++++-------- landing/locales/ar.json | 2 + landing/locales/de.json | 2 + landing/locales/en.json | 2 + landing/locales/es.json | 2 + landing/locales/fr.json | 2 + landing/locales/hi.json | 2 + landing/locales/ja.json | 2 + landing/locales/pt.json | 2 + landing/locales/ru.json | 2 + landing/locales/zh.json | 2 + runtime.lock.json | 12 +- .../MemberWorkSyncClaudeStopHook.live.test.ts | 28 ++- .../team/MemberWorkSyncCodex.live.test.ts | 23 +-- 17 files changed, 338 insertions(+), 111 deletions(-) create mode 100644 docs/research/gastown-paperclip-comparison-2026-05-05.md diff --git a/README.md b/README.md index 40ad90ff..d771585a 100644 --- a/README.md +++ b/README.md @@ -170,27 +170,31 @@ For feature architecture and implementation guidance: ## Comparison -| Feature | Agent Teams | Vibe Kanban | Aperant | Cursor | Claude Code CLI | +| Feature | Agent Teams | Gastown | Paperclip | Cursor | Claude Code CLI | |---|---|---|---|---|---| -| **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ | -| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) | -| **Linked tasks** | ✅ Cross-references in messages | ⚠️ Subtasks only | ❌ | ❌ | ❌ | -| **Session analysis** | ✅ 6-category token tracking | ❌ | ⚠️ Execution logs | ❌ | ❌ | -| **Task attachments** | ✅ Auto-attach, agents read & attach files | ❌ | ✅ Images + files | ⚠️ Chat session only | ❌ | -| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ✅ | ❌ | -| **Built-in code editor** | ✅ With Git support | ❌ | ❌ | ✅ Full IDE | ❌ | -| **Full autonomy** | ✅ Agents create, assign, review tasks end-to-end | ❌ Human manages tasks | ❌ Fixed pipeline | ⚠️ Isolated tasks only | ✅⚠️ (no UI) | -| **Task dependencies (blocked by)** | ✅ Guaranteed ordering | ❌ | ⚠️ Within plan only | ❌ | ✅⚠️ (no UI, no notifications) | -| **Review workflow** | ✅ Agents review each other | ❌ | ⚠️ Auto QA pipeline | ❌ | ✅⚠️ (no UI) | -| **Zero setup** | ✅ | ❌ Config required | ❌ Config required | ✅ | ⚠️ CLI install required | -| **Kanban board** | ✅ 5 columns, real-time | ✅ | ✅ 6 columns (pipeline) | ❌ | ❌ | -| **Execution log viewer** | ✅ Tool calls, reasoning, timeline | ❌ | ✅ Phase-based logs | ✅ | ❌ | -| **Live processes** | ✅ View, stop, open URLs in browser | ❌ | ❌ | ✅ | ❌ | -| **Per-task code review** | ✅ Accept / reject / comment | ⚠️ PR-level only | ⚠️ File-level only | ✅ BugBot on PRs | ❌ | -| **Flexible autonomy** | ✅ Granular settings, per-action approval, notifications | ❌ | ⚠️ Plan approval only | ✅ | ✅ | -| **Git worktree isolation** | ✅ Optional | ⚠️ Mandatory | ⚠️ Mandatory | ✅ | ✅ | -| **Multi-agent backend** | ✅ Codex, Claude, and 75+ providers | ✅ 6+ agents | ✅ 11 providers | ✅ Multi-model | — | -| **Price** | **Free** | Free / $30 user/mo | Free | $0–$200/mo | Provider subscription | +| **Cross-team communication** | ✅ Native cross-team messages | ⚠️ Cross-rig coordination | ⚠️ Company-scoped org work | N/A | ❌ | +| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ✅ Mailboxes + handoffs | ⚠️ Comments + @mentions | ❌ | ✅ Team mailbox, no UI | +| **Linked tasks** | ✅ Cross-refs + dependencies | ⚠️ Beads deps + convoys | ✅ Goals, parents, blockers | ❌ | ✅ Shared task list | +| **Session analysis** | ✅ Task logs + token tracking | ⚠️ Session recall, feed, OTEL | ⚠️ Run transcripts + cost audit | ❌ | ⚠️ Usage command, no UI | +| **Task attachments** | ✅ Auto-attach, agents read & attach files | ❌ Not task-level | ✅ Docs, attachments, work products | ⚠️ Chat session only | ⚠️ Chat images only | +| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ Bring your own review | ✅ | ❌ | +| **Built-in code editor** | ✅ With Git support | ❌ | ❌ Control plane, not editor | ✅ Full IDE | ❌ | +| **Full autonomy** | ✅ Agents create, assign, review tasks end-to-end | ✅ Mayor, convoys, recovery | ✅ Heartbeats + governance | ⚠️ Background agents, not teams | ✅ Experimental CLI teams | +| **Task dependencies (blocked by)** | ✅ Guaranteed ordering | ✅ DAG waves via Beads | ✅ Blockers + execution locks | ❌ | ✅ Team task deps, no UI | +| **Review workflow** | ✅ Agents review each other + human review UI | ⚠️ Refinery merge queue | ✅ Approvals + governance | ⚠️ PR/BugBot only | ✅ Team review, no UI | +| **Zero setup** | ✅ Guided runtime setup | ❌ Go/Git/Dolt/Beads/tmux | ⚠️ `npx` + embedded Postgres | ✅ | ⚠️ CLI + env flag | +| **Kanban board** | ✅ 5 columns, real-time | ❌ Dashboard, not Kanban | ✅ 7 columns, drag-and-drop | ❌ | ❌ | +| **Execution log viewer** | ✅ Tool calls, reasoning, timeline | ⚠️ Feed, OTEL, dashboard | ✅ Run transcripts + ledger | ⚠️ Agent chat + terminal | ❌ | +| **Live processes** | ✅ View, stop, open URLs in browser | ⚠️ Agent health dashboard | ⚠️ Manual services + previews | ⚠️ Native terminal only | ❌ | +| **Per-task code review** | ✅ Accept / reject / comment | ⚠️ Merge queue, no diff UI | ⚠️ PR/work products, no inline diff | ✅ BugBot on PRs | ❌ | +| **Flexible autonomy** | ✅ Per-action approvals + notifications | ✅ Gates, escalation, recovery | ✅ Board approvals, pause, terminate | ⚠️ BG agents auto-run commands | ✅ Permissions + hooks | +| **Git worktree isolation** | ✅ Optional | ✅ Core primitive | ✅ Worktrees / branches | ⚠️ Background branches/VMs | ⚠️ Manual worktrees | +| **Multi-agent backend** | ✅ Claude, Codex + OpenCode teammates | ✅ Claude, Codex, Gemini, Copilot + more | ✅ BYO agents: Claude, Codex, Cursor/OpenCode, HTTP | ⚠️ Multi-model agents, no team backend | ⚠️ Claude-only experimental teams | +| **Org chart / governance** | ⚠️ Roles + approvals, no org chart | ⚠️ Roles + escalation | ✅ Org chart + board governance | ⚠️ Team admin only | ❌ | +| **Budget controls** | ⚠️ Cost/token visibility, no hard caps | ⚠️ Cost tiers + digest, no hard caps | ✅ Per-agent budgets + hard stops | ⚠️ Usage + BG spend limits | ⚠️ `/cost` + workspace limits | +| **Price** | **Free OSS UI**, provider access needed | Free OSS, runtime plans needed | Free OSS, self-hosted + infra | Free + paid usage | Claude plan or API usage | + +Fact sources checked on May 5, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-05.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing). --- diff --git a/docs/research/gastown-paperclip-comparison-2026-05-05.md b/docs/research/gastown-paperclip-comparison-2026-05-05.md new file mode 100644 index 00000000..1570e5a3 --- /dev/null +++ b/docs/research/gastown-paperclip-comparison-2026-05-05.md @@ -0,0 +1,162 @@ +# Gastown и Paperclip comparison для лендинга и README + +> Дата проверки: 2026-05-05 +> Цель: публичная таблица `Agent Teams | Gastown | Paperclip | Cursor | Claude Code CLI` без угадываний по конкурентам. +> Метод: первичные источники, `gh repo view`, official README/docs/releases, локальные клоны `gastownhall/gastown` и `paperclipai/paperclip`. + +## Snapshot + +| Проект | Позиционирование | Статус на 2026-05-05 | Лицензия | +|---|---|---:|---| +| **Gastown** | multi-agent workspace manager для coding agents | `14,962★`, latest `v1.0.1` от `2026-04-25`, push `2026-05-03` | MIT | +| **Paperclip** | control plane для autonomous AI companies | `62,668★`, latest `v2026.428.0` от `2026-04-28`, push `2026-05-05` | MIT | + +## Что важно для публичного сравнения + +### Gastown + +Сильные факты: + +- README позиционирует Gastown как workspace manager для Claude Code, GitHub Copilot, Codex, Gemini и других AI agents. +- Есть built-in mailboxes, identities, handoffs, Beads ledger, git-backed hooks, convoys, Witness/Deacon watchdog, Refinery merge queue. +- Dependencies сильнее, чем просто "convoys": convoy stage/launch строит DAG, считает waves, запускает Wave 1, а последующие waves daemon dispatches автоматически. +- Есть `gt feed`, web dashboard, OpenTelemetry events/metrics, `gt costs record`, cost tier presets, daily cost digest и context-budget guard. +- Agent provider story шире README: provider guide прямо перечисляет Claude, Gemini, Codex, Cursor, AMP, OpenCode, Copilot и loose-coupled tmux integration tiers. +- Scheduler сильнее простого queue: `scheduler.max_polecats` включает deferred dispatch, capacity governor, pause/resume, queued sling context beads и daemon dispatch cycle. + +Ограничения: + +- Это не Kanban product: README описывает dashboard как обзор agents, convoys, hooks, queues, issues, escalations. +- Нет явного built-in editor, hunk-level review или task attachment workflow как продуктовой возможности. +- Бюджеты не равны Paperclip budgets: у Gastown есть cost tiers/logging/guards, но я не нашёл hard monthly caps с automatic pause/cancel. + +Публичная оценка: + +- `Task dependencies` - `✅ Beads DAG waves` +- `Kanban board` - `❌ Dashboard, not Kanban` +- `Per-task code review` - `⚠️ Merge queue, no diff UI` +- `Budget controls` - `⚠️ Cost tiers + digest, no hard caps` + +### Paperclip + +Сильные факты: + +- README: Node.js server + React UI that orchestrates a team of AI agents. Works with OpenClaw, Claude Code, Codex, Cursor, Bash, HTTP. +- README прямо заявляет org charts, budgets, governance, goal alignment, agent coordination. +- README under the hood: issues have company/project/goal/parent links, atomic checkout, execution locks, blocker dependencies, comments, documents, attachments, work products. +- Budget section: scoped budget policies with warning thresholds and hard stops, overspend pauses agents and cancels queued work automatically. +- `ui/src/components/KanbanBoard.tsx` содержит `backlog`, `todo`, `in_progress`, `in_review`, `blocked`, `done`, `cancelled` и drag-and-drop через `@dnd-kit`. +- `packages/shared/src/validators/work-product.ts` содержит work products: `preview_url`, `runtime_service`, `pull_request`, `branch`, `commit`, `artifact`, `document`; statuses include `ready_for_review`, `approved`, `changes_requested`, `merged`. +- Architecture docs: built-in stable adapters listed as Claude Local, Codex Local, Process, HTTP; adapter overview also lists OpenCode, Cursor, OpenClaw Gateway, Pi, Hermes and experimental Gemini. +- Execution policy docs: runtime enforces comment-required, review and approval stages; decisions are audited; reviewers/approvers can be agents or users. +- Workspace/runtime docs: services and jobs are manually controlled from UI, execution workspaces isolate checkout/branch/runtime state, and services are not auto-started by issue execution. +- Issue API docs: @mentions in comments trigger heartbeats and issue-thread interactions can request confirmations, ask questions, or suggest tasks through UI cards. + +Ограничения: + +- README явно говорит: Paperclip is not a code review tool. It orchestrates work, not pull requests. +- Нет hunk-level accept/reject UI и нет built-in code editor уровня нашего workbench. +- Это broader company/org control plane, а не coding cockpit. +- Work products are real in code, but public roadmap still has "Artifacts & Work Products" as future work, so public `Per-task code review` should stay `⚠️`, not `✅`. + +Публичная оценка: + +- `Kanban board` - `✅ 7 columns, drag-and-drop` +- `Per-task code review` - `⚠️ PR/work products, no inline diff` +- `Hunk-level review` - `❌ Bring your own review` +- `Budget controls` - `✅ Per-agent budgets + hard stops` + +## Маркетинговая позиция + +Честный framing для нас: + +- Против Gastown продаём не "мы мощнее как orchestration OS", а **coding-team workbench**: review, logs, editor, live processes, attachments, operator UX. +- Против Paperclip продаём не "у нас больше governance", а **agentic IDE / coding cockpit**: hunk-level review, task-scoped developer logs, built-in editor, live process controls. +- `Multi-agent backend` у нас больше не надо показывать как "In development": публично корректнее писать `Claude, Codex + OpenCode teammates`. + +## Второй глубокий проход + +Что поменял после повторной проверки: + +- Убрал Gemini из нашей публичной строки. Теперь у нас: `Claude, Codex + OpenCode teammates`. +- Paperclip `Agent-to-agent messaging` точнее как `Comments + @mentions`, а не как полноценный mailbox. @mentions будят агентов, но это не прямой peer mailbox как у нас или Gastown. +- Paperclip `Execution log viewer` усилил до `Run transcripts + ledger`: в UI есть `RunTranscriptView` и `IssueRunLedger`, а architecture docs описывают capture stdout, cost/session state и run records. +- Paperclip `Live processes` уточнил до `Manual services + previews`: runtime services/jobs есть в UI, но docs прямо говорят, что issue execution не стартует и не стопает их автоматически. +- Gastown `Session analysis` уточнил до `Session recall, feed, OTEL`: это сильная ops/observability модель, но не наш task-scoped analysis cockpit. +- Gastown `Budget controls` уточнил до `Cost tiers + digest, no hard caps`: есть model tiering, `gt costs record`, daily digest bead, но не нашёл Paperclip-style hard budget pause. + +Вывод не поменялся: публично выгоднее и честнее продавать наш **coding workbench** - hunk review, task logs, editor, live processes, attachments, team UI. Не надо притворяться, что мы глубже Gastown как orchestration OS или глубже Paperclip как governance/budget company control plane. + +## Третий глубокий проход по тексту таблицы + +Что уточнил после проверки README/docs/source: + +- Paperclip `Cross-team communication` заменил на `Company-scoped org work`: README говорит про multi-company isolation и org charts, но не про свободную коммуникацию между независимыми компаниями. +- Paperclip `Agent-to-agent messaging` заменил на `Comments + @mentions`: docs называют comments primary channel, а @mentions будят агента через heartbeat. Это не peer mailbox. +- Gastown `Linked tasks` заменил на `Beads deps + convoys`: source сильнее, чем просто "convoys", потому что stage/launch строит DAG по dependency edges. +- Gastown `Session analysis` заменил на `Session recall, feed, OTEL`: публично понятнее, чем термин `Seance`, но всё ещё отражает session discovery, TUI feed и OpenTelemetry. +- Gastown `Full autonomy` заменил на `Mayor, convoys, recovery`: не спорим с их глубокой autonomy моделью, но не перегружаем публичную таблицу внутренними health терминами. +- Paperclip `Zero setup` заменил на `npx + embedded Postgres`: quickstart реально идёт через `npx paperclipai onboard --yes`, и docs говорят, что embedded PostgreSQL создаётся автоматически. +- Paperclip `Price` уточнил до `OSS, self-hosted + infra`: честнее, потому что Paperclip account не нужен, но self-hosting и agent/provider runtime costs всё равно остаются. + +## Четвёртый глубокий проход: Claude Code CLI и Cursor + +Что поменял после проверки официальных docs Cursor и Claude Code: + +- Claude Code CLI нельзя честно держать как слабый baseline: официальные `agent teams` уже дают direct teammate messaging, shared task list, mailbox, task dependencies, team review scenarios и hooks for quality gates. Поэтому я поднял Claude CLI в строках `Agent-to-agent messaging`, `Linked tasks`, `Full autonomy`, `Task dependencies`, `Review workflow`. +- Но Claude Code agent teams всё ещё experimental, disabled by default, требуют `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`, работают в CLI/terminal display modes и имеют known limitations. Поэтому наш selling point против Claude CLI - не "у них нет teams", а **у них нет полноценного product UI/cockpit**: kanban, task attachments, hunk review UI, task logs, live process section, cross-team UI, cost/context dashboard. +- Claude Code `Multi-agent backend` теперь точнее как `Claude-only experimental teams`, а не просто `Claude-only teams`. +- Claude Code `Git worktree isolation` точнее как `Manual worktrees`: official workflows рекомендуют git worktree для параллельных sessions, но это не автоматическая продуктовая isolation как у нас/Gastown/Paperclip. +- Cursor `Full autonomy` точнее как `Background agents, not teams`: background agents сильные, но это не team backend with shared task list/mailbox. +- Cursor `Execution log viewer` и `Live processes` понижены до `⚠️`: есть agent chat/terminal/background terminals, но нет task-scoped execution timeline и process URL section как у нас. +- Cursor `Flexible autonomy` понижен до `⚠️`: official background-agent security docs прямо говорят, что background agents auto-run terminal commands, в отличие от foreground approvals. +- Cursor `Git worktree isolation` понижен до `⚠️ Background branches/VMs`: docs говорят про isolated remote machines and separate branches, не про встроенный local worktree strategy. +- Cursor `Price` заменил с `$0-$200/mo` на `Free + paid usage`, потому что pricing/usage docs теперь акцентируют included API usage, usage dashboard, Bugbot pricing и background-agent spend limits. + +Итоговая позиция после четвёртого прохода: + +- Против Claude Code CLI мы не врём, что у него нет multi-agent primitives. У него они есть, но experimental and CLI-first. Мы продаём **операторский UI и review/workbench layer**. +- Против Cursor мы не врём, что у него нет background agents или review. У него есть сильный IDE/PR story. Мы продаём **team/task orchestration, task-scoped logs, cross-agent workflow and review cockpit**. + +## Scores + +| Критерий | Agent Teams | Gastown | Paperclip | +|---|---:|---:|---:| +| Coding cockpit | **9.2** | 5.6 | 6.8 | +| Orchestration depth | 7.6 | **9.2** | 8.8 | +| Governance / budget control | 6.7 | 6.0 | **9.4** | +| Review UX | **9.3** | 5.8 | 6.2 | +| Setup simplicity | **8.4** | 4.7 | 7.1 | + +Вывод по 10-балльной уверенности: + +- Agent Teams против Gastown: 🎯 9 🛡️ 8 🧠 5 - сравнение честное, потому что мы отдаём Gastown orchestration depth, но забираем workbench. +- Agent Teams против Paperclip: 🎯 9 🛡️ 8 🧠 6 - сравнение честное, потому что Paperclip сильнее в budgets/governance, но слабее как code-review/editor cockpit. +- Agent Teams против Cursor: 🎯 9 🛡️ 9 🧠 5 - сравнение честное, потому что Cursor сильнее как IDE, но не как multi-agent team/task OS. +- Agent Teams против Claude Code CLI: 🎯 9 🛡️ 9 🧠 6 - сравнение честное только после повышения Claude CLI по official agent teams primitives; наш выигрыш теперь UI/workbench, не наличие базовой team механики. +- Публичная таблица после Claude/Cursor прохода: 🎯 9 🛡️ 9 🧠 5 - около 55 строк изменения в README/landing плюс локали. + +## Источники + +- Gastown repo: +- Gastown v1.0.1: +- Gastown convoy implementation: +- Gastown convoy skill docs: +- Gastown provider guide: +- Gastown scheduler docs: +- Paperclip repo: +- Paperclip v2026.428.0: +- Paperclip Kanban source: +- Paperclip work products source: +- Paperclip architecture docs: +- Paperclip execution policy docs: +- Paperclip costs and budgets docs: +- Paperclip runtime services docs: +- Cursor Background Agents: +- Cursor Diffs & Review: +- Cursor Bugbot: +- Cursor usage/pricing: +- Claude Code agent teams: +- Claude Code subagents: +- Claude Code common workflows: +- Claude Code costs: diff --git a/docs/research/real-competitors-comparison.md b/docs/research/real-competitors-comparison.md index b966a535..26121cae 100644 --- a/docs/research/real-competitors-comparison.md +++ b/docs/research/real-competitors-comparison.md @@ -1,5 +1,7 @@ # Реальные конкуренты для Comparison в README +> ⚠️ Update 2026-05-05: публичная таблица README/landing теперь сравнивает нас с `Gastown` и `Paperclip`, а не с `Claude Code Agent Teams` и `GoClaw`. Актуальная research-опора: [gastown-paperclip-comparison-2026-05-05.md](gastown-paperclip-comparison-2026-05-05.md). Ниже оставлен старый broader draft как исторический контекст. + > Дата проверки: 2026-04-13 > Статус: внутренний comparison draft > Цель: заменить в нашем внутреннем thinking `Vibe Kanban` и `Aperant` на реальные ориентиры - `Gastown`, `Claude Code Agent Teams`, `GoClaw` @@ -55,7 +57,7 @@ | **Per-task code review** | ✅ Per-task diff review with accept / reject / comment flow | ❌ | ❌ | ⚠️ Task approval exists, but not inline code diff review | | **Flexible autonomy** | ✅ Granular approvals, notifications, autonomy controls | ✅ Strong human gates, escalation and intervention, mostly via CLI/TUI | ⚠️ Plan approval, hooks and permissions exist, but control plane is thin | ✅ Team settings, approval workflows, exec approval, task approval | | **Git worktree isolation** | ✅ Optional per-agent worktree strategy | ✅ Core architectural primitive | ⚠️ Manual worktrees exist in Claude Code, but not as the native team model | ❌ Not a core team isolation model | -| **Multi-agent backend** | ⚠️ Claude is mature; Codex/Gemini plumbing exists in code but is still emerging as product surface | ✅ Claude Code, Codex, Gemini, Copilot and other runtimes | ❌ Claude-first only, models per teammate but no real multi-provider backend | ✅ 20+ providers including Claude CLI and ChatGPT OAuth | +| **Multi-agent backend** | ⚠️ Claude is mature; Codex/OpenCode plumbing exists in code but is still emerging as product surface | ✅ Claude Code, Codex, Gemini, Copilot and other runtimes | ❌ Claude-first only, models per teammate but no real multi-provider backend | ✅ 20+ providers including Claude CLI and ChatGPT OAuth | | **Price** | Free OSS UI, but a Claude Code plan is still needed today | Free OSS, but you still pay for the underlying runtime plans/seats you use | Claude subscription | Free self-hosted OSS, but infra + provider/API/subscription costs remain | ## Самые важные выводы по matrix @@ -435,7 +437,7 @@ GoClaw выигрывает у нас по: ## Места, где надо быть особенно честными про нас -- `Multi-agent backend` у нас пока не так зрел, как это можно прочитать из одной строки README. В коде есть мосты и статусы для `Anthropic`, `Codex`, `Gemini`, но продуктово основной путь всё ещё Claude-first. +- `Multi-agent backend` у нас пока не так зрел, как это можно прочитать из одной строки README. В коде есть мосты и статусы для `Anthropic`, `Codex`, `OpenCode`, но продуктово основной путь всё ещё Claude-first. - `Zero setup` у нас честно сильный именно для Claude Code path. - `Cross-team communication` у нас сильнее, чем у этих конкурентов, но cross-team attachments не выглядят как полностью общий happy path. diff --git a/landing/components/sections/ComparisonSection.vue b/landing/components/sections/ComparisonSection.vue index c9c1ce6a..9c22083e 100644 --- a/landing/components/sections/ComparisonSection.vue +++ b/landing/components/sections/ComparisonSection.vue @@ -10,8 +10,8 @@ interface CellValue { interface ComparisonRow { feature: string us: CellValue - vibeKanban: CellValue - aperant: CellValue + gastown: CellValue + paperclip: CellValue cursor: CellValue claudeCli: CellValue } @@ -20,161 +20,177 @@ const rows = computed(() => [ { feature: t('comparison.features.crossTeam'), us: { status: 'yes' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'no' }, + gastown: { status: 'partial', note: 'Cross-rig coordination' }, + paperclip: { status: 'partial', note: 'Company-scoped org work' }, cursor: { status: 'na' }, claudeCli: { status: 'no' }, }, { feature: t('comparison.features.agentMessaging'), us: { status: 'yes', note: 'Native real-time mailbox' }, - vibeKanban: { status: 'no', note: 'Agents are independent' }, - aperant: { status: 'no', note: 'Fixed pipeline' }, + gastown: { status: 'yes', note: 'Mailboxes + handoffs' }, + paperclip: { status: 'partial', note: 'Comments + @mentions' }, cursor: { status: 'no' }, - claudeCli: { status: 'partial', note: 'Built-in (no UI)' }, + claudeCli: { status: 'yes', note: 'Team mailbox, no UI' }, }, { feature: t('comparison.features.linkedTasks'), - us: { status: 'yes', note: 'Cross-references in messages' }, - vibeKanban: { status: 'partial', note: 'Subtasks only' }, - aperant: { status: 'no' }, + us: { status: 'yes', note: 'Cross-refs + dependencies' }, + gastown: { status: 'partial', note: 'Beads deps + convoys' }, + paperclip: { status: 'yes', note: 'Goals, parents, blockers' }, cursor: { status: 'no' }, - claudeCli: { status: 'no' }, + claudeCli: { status: 'yes', note: 'Shared task list' }, }, { feature: t('comparison.features.sessionAnalysis'), - us: { status: 'yes', note: '6-category token tracking' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'partial', note: 'Execution logs' }, + us: { status: 'yes', note: 'Task logs + token tracking' }, + gastown: { status: 'partial', note: 'Session recall, feed, OTEL' }, + paperclip: { status: 'partial', note: 'Run transcripts + cost audit' }, cursor: { status: 'no' }, - claudeCli: { status: 'no' }, + claudeCli: { status: 'partial', note: 'Usage command, no UI' }, }, { feature: t('comparison.features.taskAttachments'), us: { status: 'yes', note: 'Auto-attach, agents read & attach' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'yes', note: 'Images + files' }, + gastown: { status: 'no', note: 'Not task-level' }, + paperclip: { status: 'yes', note: 'Docs, attachments, work products' }, cursor: { status: 'partial', note: 'Chat session only' }, - claudeCli: { status: 'no' }, + claudeCli: { status: 'partial', note: 'Chat images only' }, }, { feature: t('comparison.features.hunkReview'), us: { status: 'yes', note: 'Accept / reject individual hunks' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'no' }, + gastown: { status: 'no' }, + paperclip: { status: 'no', note: 'Bring your own review' }, cursor: { status: 'yes' }, claudeCli: { status: 'no' }, }, { feature: t('comparison.features.codeEditor'), us: { status: 'yes', note: 'With Git support' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'no' }, + gastown: { status: 'no' }, + paperclip: { status: 'no', note: 'Control plane, not editor' }, cursor: { status: 'yes', note: 'Full IDE' }, claudeCli: { status: 'no' }, }, { feature: t('comparison.features.fullAutonomy'), us: { status: 'yes', note: 'Create, assign, review end-to-end' }, - vibeKanban: { status: 'no', note: 'Human manages tasks' }, - aperant: { status: 'no', note: 'Fixed pipeline' }, - cursor: { status: 'partial', note: 'Isolated tasks only' }, - claudeCli: { status: 'partial', note: 'No UI' }, + gastown: { status: 'yes', note: 'Mayor, convoys, recovery' }, + paperclip: { status: 'yes', note: 'Heartbeats + governance' }, + cursor: { status: 'partial', note: 'Background agents, not teams' }, + claudeCli: { status: 'yes', note: 'Experimental CLI teams' }, }, { feature: t('comparison.features.taskDeps'), us: { status: 'yes', note: 'Guaranteed ordering' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'partial', note: 'Within plan only' }, + gastown: { status: 'yes', note: 'DAG waves via Beads' }, + paperclip: { status: 'yes', note: 'Blockers + execution locks' }, cursor: { status: 'no' }, - claudeCli: { status: 'partial', note: 'No UI, no notifications' }, + claudeCli: { status: 'yes', note: 'Team task deps, no UI' }, }, { feature: t('comparison.features.reviewWorkflow'), us: { status: 'yes', note: 'Agents review each other' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'partial', note: 'Auto QA pipeline' }, - cursor: { status: 'no' }, - claudeCli: { status: 'partial', note: 'No UI' }, + gastown: { status: 'partial', note: 'Refinery merge queue' }, + paperclip: { status: 'yes', note: 'Approvals + governance' }, + cursor: { status: 'partial', note: 'PR/BugBot only' }, + claudeCli: { status: 'yes', note: 'Team review, no UI' }, }, { feature: t('comparison.features.zeroSetup'), - us: { status: 'yes' }, - vibeKanban: { status: 'no', note: 'Config required' }, - aperant: { status: 'no', note: 'Config required' }, + us: { status: 'yes', note: 'Guided runtime setup' }, + gastown: { status: 'no', note: 'Go/Git/Dolt/Beads/tmux' }, + paperclip: { status: 'partial', note: 'npx + embedded Postgres' }, cursor: { status: 'yes' }, - claudeCli: { status: 'partial', note: 'CLI install required' }, + claudeCli: { status: 'partial', note: 'CLI + env flag' }, }, { feature: t('comparison.features.kanban'), us: { status: 'yes', note: '5 columns, real-time' }, - vibeKanban: { status: 'yes' }, - aperant: { status: 'yes', note: '6 columns (pipeline)' }, + gastown: { status: 'no', note: 'Dashboard, not Kanban' }, + paperclip: { status: 'yes', note: '7 columns, drag-and-drop' }, cursor: { status: 'no' }, claudeCli: { status: 'no' }, }, { feature: t('comparison.features.execLog'), us: { status: 'yes', note: 'Tool calls, reasoning, timeline' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'yes', note: 'Phase-based logs' }, - cursor: { status: 'yes' }, + gastown: { status: 'partial', note: 'Feed, OTEL, dashboard' }, + paperclip: { status: 'yes', note: 'Run transcripts + ledger' }, + cursor: { status: 'partial', note: 'Agent chat + terminal' }, claudeCli: { status: 'no' }, }, { feature: t('comparison.features.liveProcesses'), us: { status: 'yes', note: 'View, stop, open URLs' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'no' }, - cursor: { status: 'yes' }, + gastown: { status: 'partial', note: 'Agent health dashboard' }, + paperclip: { status: 'partial', note: 'Manual services + previews' }, + cursor: { status: 'partial', note: 'Native terminal only' }, claudeCli: { status: 'no' }, }, { feature: t('comparison.features.perTaskReview'), us: { status: 'yes', note: 'Accept / reject / comment' }, - vibeKanban: { status: 'partial', note: 'PR-level only' }, - aperant: { status: 'partial', note: 'File-level only' }, + gastown: { status: 'partial', note: 'Merge queue, no diff UI' }, + paperclip: { status: 'partial', note: 'PR/work products, no diff UI' }, cursor: { status: 'yes', note: 'BugBot on PRs' }, claudeCli: { status: 'no' }, }, { feature: t('comparison.features.flexAutonomy'), - us: { status: 'yes', note: 'Granular settings, notifications' }, - vibeKanban: { status: 'no' }, - aperant: { status: 'partial', note: 'Plan approval only' }, - cursor: { status: 'yes' }, - claudeCli: { status: 'yes' }, + us: { status: 'yes', note: 'Per-action approvals + notifications' }, + gastown: { status: 'yes', note: 'Gates, escalation, recovery' }, + paperclip: { status: 'yes', note: 'Board approvals, pause, terminate' }, + cursor: { status: 'partial', note: 'BG agents auto-run commands' }, + claudeCli: { status: 'yes', note: 'Permissions + hooks' }, }, { feature: t('comparison.features.worktree'), us: { status: 'yes', note: 'Optional' }, - vibeKanban: { status: 'partial', note: 'Mandatory' }, - aperant: { status: 'partial', note: 'Mandatory' }, - cursor: { status: 'yes' }, - claudeCli: { status: 'yes' }, + gastown: { status: 'yes', note: 'Core primitive' }, + paperclip: { status: 'yes', note: 'Worktrees / branches' }, + cursor: { status: 'partial', note: 'Background branches/VMs' }, + claudeCli: { status: 'partial', note: 'Manual worktrees' }, }, { feature: t('comparison.features.multiAgent'), - us: { status: 'soon', note: 'In development', noteLink: 'https://github.com/Alishahryar1/free-claude-code' }, - vibeKanban: { status: 'yes', note: '6+ agents' }, - aperant: { status: 'yes', note: '11 providers' }, - cursor: { status: 'yes', note: 'Multi-model' }, - claudeCli: { status: 'na' }, + us: { status: 'yes', note: 'Claude, Codex + OpenCode teammates' }, + gastown: { status: 'yes', note: 'Claude, Codex, Gemini, Copilot + more' }, + paperclip: { status: 'yes', note: 'BYO agents: Claude, Codex, Cursor/OpenCode, HTTP' }, + cursor: { status: 'partial', note: 'Multi-model agents, no team backend' }, + claudeCli: { status: 'partial', note: 'Claude-only experimental teams' }, + }, + { + feature: t('comparison.features.orgGovernance'), + us: { status: 'partial', note: 'Roles + approvals, no org chart' }, + gastown: { status: 'partial', note: 'Roles + escalation' }, + paperclip: { status: 'yes', note: 'Org chart + board governance' }, + cursor: { status: 'partial', note: 'Team admin only' }, + claudeCli: { status: 'no' }, + }, + { + feature: t('comparison.features.budgetControls'), + us: { status: 'partial', note: 'Cost/token visibility, no hard caps' }, + gastown: { status: 'partial', note: 'Cost tiers + digest, no hard caps' }, + paperclip: { status: 'yes', note: 'Per-agent budgets + hard stops' }, + cursor: { status: 'partial', note: 'Usage + BG spend limits' }, + claudeCli: { status: 'partial', note: '/cost + workspace limits' }, }, { feature: t('comparison.features.price'), - us: { status: 'free' }, - vibeKanban: { status: 'text', note: 'Free / $30/mo' }, - aperant: { status: 'free' }, - cursor: { status: 'text', note: '$0–$200/mo' }, - claudeCli: { status: 'text', note: 'Claude subscription' }, + us: { status: 'free', note: 'OSS, provider access needed' }, + gastown: { status: 'free', note: 'OSS, runtime plans needed' }, + paperclip: { status: 'free', note: 'OSS, self-hosted + infra' }, + cursor: { status: 'text', note: 'Free + paid usage' }, + claudeCli: { status: 'text', note: 'Claude plan or API usage' }, }, ]) const competitors = [ { key: 'us', name: 'Agent Teams', highlight: true }, - { key: 'vibeKanban', name: 'Vibe Kanban' }, - { key: 'aperant', name: 'Aperant' }, + { key: 'gastown', name: 'Gastown' }, + { key: 'paperclip', name: 'Paperclip' }, { key: 'cursor', name: 'Cursor' }, { key: 'claudeCli', name: 'Claude Code CLI' }, ] @@ -197,7 +213,7 @@ function getStatusIcon(status: string): string { case 'yes': return '\u2713' case 'no': return '\u2717' case 'partial': return '\u25D2' - case 'na': return '\u2014' + case 'na': return 'N/A' case 'free': return 'Free' case 'soon': return '\uD83D\uDCC5' default: return '' diff --git a/landing/locales/ar.json b/landing/locales/ar.json index 316ffad0..295142a1 100644 --- a/landing/locales/ar.json +++ b/landing/locales/ar.json @@ -83,6 +83,8 @@ "flexAutonomy": "استقلالية مرنة", "worktree": "عزل Git worktree", "multiAgent": "خلفية متعددة الوكلاء", + "orgGovernance": "الهيكل التنظيمي / الحوكمة", + "budgetControls": "ضوابط الميزانية", "price": "السعر" } }, diff --git a/landing/locales/de.json b/landing/locales/de.json index 7373d66b..76257315 100644 --- a/landing/locales/de.json +++ b/landing/locales/de.json @@ -83,6 +83,8 @@ "flexAutonomy": "Flexible Autonomie", "worktree": "Git-Worktree-Isolation", "multiAgent": "Multi-Agenten-Backend", + "orgGovernance": "Organigramm / Governance", + "budgetControls": "Budgetkontrollen", "price": "Preis" } }, diff --git a/landing/locales/en.json b/landing/locales/en.json index dc79746f..da3c26cb 100644 --- a/landing/locales/en.json +++ b/landing/locales/en.json @@ -83,6 +83,8 @@ "flexAutonomy": "Flexible autonomy", "worktree": "Git worktree isolation", "multiAgent": "Multi-agent backend", + "orgGovernance": "Org chart / governance", + "budgetControls": "Budget controls", "price": "Price" } }, diff --git a/landing/locales/es.json b/landing/locales/es.json index f911ff4f..5cb5c78d 100644 --- a/landing/locales/es.json +++ b/landing/locales/es.json @@ -83,6 +83,8 @@ "flexAutonomy": "Autonomía flexible", "worktree": "Aislamiento Git worktree", "multiAgent": "Backend multi-agente", + "orgGovernance": "Organigrama / gobernanza", + "budgetControls": "Controles de presupuesto", "price": "Precio" } }, diff --git a/landing/locales/fr.json b/landing/locales/fr.json index 0af848a1..3566ed88 100644 --- a/landing/locales/fr.json +++ b/landing/locales/fr.json @@ -83,6 +83,8 @@ "flexAutonomy": "Autonomie flexible", "worktree": "Isolation Git worktree", "multiAgent": "Backend multi-agents", + "orgGovernance": "Organigramme / gouvernance", + "budgetControls": "Contrôle budgétaire", "price": "Prix" } }, diff --git a/landing/locales/hi.json b/landing/locales/hi.json index 4fb4ad8a..ff06e28a 100644 --- a/landing/locales/hi.json +++ b/landing/locales/hi.json @@ -83,6 +83,8 @@ "flexAutonomy": "लचीली स्वायत्तता", "worktree": "Git worktree आइसोलेशन", "multiAgent": "मल्टी-एजेंट बैकएंड", + "orgGovernance": "ऑर्ग चार्ट / गवर्नेंस", + "budgetControls": "बजट नियंत्रण", "price": "कीमत" } }, diff --git a/landing/locales/ja.json b/landing/locales/ja.json index efe26554..04b1c92c 100644 --- a/landing/locales/ja.json +++ b/landing/locales/ja.json @@ -83,6 +83,8 @@ "flexAutonomy": "柔軟な自律性", "worktree": "Git worktree分離", "multiAgent": "マルチエージェントバックエンド", + "orgGovernance": "組織図 / ガバナンス", + "budgetControls": "予算管理", "price": "価格" } }, diff --git a/landing/locales/pt.json b/landing/locales/pt.json index ba1c26ca..f13006b6 100644 --- a/landing/locales/pt.json +++ b/landing/locales/pt.json @@ -83,6 +83,8 @@ "flexAutonomy": "Autonomia flexível", "worktree": "Isolamento Git worktree", "multiAgent": "Backend multi-agente", + "orgGovernance": "Organograma / governança", + "budgetControls": "Controles de orçamento", "price": "Preço" } }, diff --git a/landing/locales/ru.json b/landing/locales/ru.json index f57612bf..bde5b30e 100644 --- a/landing/locales/ru.json +++ b/landing/locales/ru.json @@ -83,6 +83,8 @@ "flexAutonomy": "Гибкая автономность", "worktree": "Изоляция Git worktree", "multiAgent": "Мультиагентный бэкенд", + "orgGovernance": "Оргструктура / управление", + "budgetControls": "Бюджетные лимиты", "price": "Цена" } }, diff --git a/landing/locales/zh.json b/landing/locales/zh.json index bd56e97f..712972a3 100644 --- a/landing/locales/zh.json +++ b/landing/locales/zh.json @@ -83,6 +83,8 @@ "flexAutonomy": "灵活自主", "worktree": "Git worktree 隔离", "multiAgent": "多智能体后端", + "orgGovernance": "组织架构 / 治理", + "budgetControls": "预算控制", "price": "价格" } }, diff --git a/runtime.lock.json b/runtime.lock.json index 481af2ff..d7894dfa 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.17", - "sourceRef": "v0.0.17", + "version": "0.0.18", + "sourceRef": "v0.0.18", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.17.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.18.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.17.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.18.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.17.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.18.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.17.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.18.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 21d8d7c7..5cb1e78e 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -40,9 +40,11 @@ vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () = }, })); +const allowConnectedClaudeAccount = + process.env.MEMBER_WORK_SYNC_CLAUDE_ALLOW_CONNECTED_ACCOUNT === '1'; const liveDescribe = process.env.MEMBER_WORK_SYNC_CLAUDE_STOP_HOOK_LIVE === '1' && - Boolean(process.env.ANTHROPIC_API_KEY?.trim()) + (hasLiveAnthropicApiKey() || allowConnectedClaudeAccount) ? describe : describe.skip; @@ -103,9 +105,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-')); tempClaudeRoot = path.join(tempDir, '.claude'); - tempHome = path.join(tempDir, 'home'); await fs.mkdir(tempClaudeRoot, { recursive: true }); - await fs.mkdir(tempHome, { recursive: true }); setClaudeBasePathOverride(tempClaudeRoot); previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; @@ -117,6 +117,12 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { previousHistFile = process.env.HISTFILE; previousUserProfile = process.env.USERPROFILE; + const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey(); + tempHome = shouldUseConnectedAccountHome + ? resolveConnectedClaudeHome(previousHome) + : path.join(tempDir, 'home'); + await fs.mkdir(tempHome, { recursive: true }); + process.env.HOME = tempHome; process.env.HISTFILE = '/dev/null'; process.env.USERPROFILE = tempHome; @@ -531,3 +537,19 @@ async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise { } } } + +function hasLiveAnthropicApiKey(): boolean { + return Boolean(process.env.ANTHROPIC_API_KEY?.trim()); +} + +function resolveConnectedClaudeHome(previousHome: string | undefined): string { + const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim(); + if (explicit) { + return path.resolve(explicit); + } + const previous = previousHome?.trim(); + if (previous) { + return path.resolve(previous); + } + return os.userInfo().homedir; +} diff --git a/test/main/services/team/MemberWorkSyncCodex.live.test.ts b/test/main/services/team/MemberWorkSyncCodex.live.test.ts index 0681b51c..88f7ef26 100644 --- a/test/main/services/team/MemberWorkSyncCodex.live.test.ts +++ b/test/main/services/team/MemberWorkSyncCodex.live.test.ts @@ -56,6 +56,7 @@ liveDescribe('Member work sync Codex live e2e', () => { let previousCliFlavor: string | undefined; let previousControlUrl: string | undefined; let previousCodexHome: string | undefined; + let previousCodexIgnoreUserConfig: string | undefined; let codexHomeDir: string; let ownsCodexHomeDir: boolean; let codexAccountFeature: { @@ -100,6 +101,7 @@ liveDescribe('Member work sync Codex live e2e', () => { previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL; previousCodexHome = process.env.CODEX_HOME; + previousCodexIgnoreUserConfig = process.env.CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG; const shouldUseConnectedAccountHome = allowConnectedChatGptAccount && !hasLiveCodexApiKey(); if (shouldUseConnectedAccountHome) { @@ -117,6 +119,7 @@ liveDescribe('Member work sync Codex live e2e', () => { process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; process.env.CODEX_HOME = codexHomeDir; + process.env.CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG = 'true'; codexAccountFeature = null; providerConnectionService = null; @@ -141,6 +144,7 @@ liveDescribe('Member work sync Codex live e2e', () => { restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl); restoreEnv('CODEX_HOME', previousCodexHome); + restoreEnv('CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG', previousCodexIgnoreUserConfig); setClaudeBasePathOverride(null); if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') { console.info(`[MemberWorkSyncCodex.live] preserved temp dir: ${tempDir}`); @@ -254,7 +258,7 @@ liveDescribe('Member work sync Codex live e2e', () => { prompt: [ 'Keep launch work minimal.', 'If you receive a task, follow task instructions exactly.', - 'Before going idle with unfinished assigned work, call member_work_sync_status and member_work_sync_report.', + 'Do not call member_work_sync_status until a task instruction or member_work_sync_nudge provides exact teamName, memberName, and controlUrl.', ].join(' '), members: [], }, @@ -292,19 +296,16 @@ liveDescribe('Member work sync Codex live e2e', () => { ].join('\n'), }); feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); + + const preRelayStatus = await feature.refreshStatus({ teamName, memberName }); + expect(preRelayStatus.memberName).toBe(memberName); + expect(preRelayStatus.providerId).toBe('codex'); + expect(preRelayStatus.agenda.items.some((item) => item.taskId === task.id)).toBe(true); + expect(preRelayStatus.shadow?.wouldNudge).toBe(true); + const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); expect(relay.relayed).toBeGreaterThan(0); - await waitUntil(async () => { - const status = await feature!.getStatus({ teamName: teamName!, memberName }); - return ( - status.memberName === memberName && - status.providerId === 'codex' && - status.agenda.items.some((item) => item.taskId === task.id) && - status.shadow?.wouldNudge === true - ); - }, 30_000); - await waitUntil(async () => { const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!); if (fatalRuntimeMessage) { From d0012ed4cca24ae0a9e4d7693a483aa8c1be2c78 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 00:08:37 +0300 Subject: [PATCH 7/7] test(member-work-sync): allow preserving claude stop hook temp dirs --- .../services/team/MemberWorkSyncClaudeStopHook.live.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 5cb1e78e..a51381cd 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -165,6 +165,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { }); afterAll(async () => { + if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') { + return; + } await cleanupScopedClaudeStopHookLiveTempDirs(); });