From 44e7ca3cf860cc01e3c1cff56f84c799f815ece4 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 29 Apr 2026 15:01:53 +0300 Subject: [PATCH 01/74] fix: serialize team launch-state persistence --- .../services/team/TeamProvisioningService.ts | 78 ++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1c83fb43..f2e42b1d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4308,6 +4308,7 @@ export class TeamProvisioningService { { expiresAtMs: number; metadata: Map } >(); private readonly launchStateStore = new TeamLaunchStateStore(); + private readonly launchStateStoreQueue = new Map>(); private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -8347,7 +8348,7 @@ export class TeamProvisioningService { }, updatedAt: input.observedAt, }); - await this.launchStateStore.write(input.teamName, snapshot); + await this.writeLaunchStateSnapshot(input.teamName, snapshot); this.agentRuntimeSnapshotCache.delete(input.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName); this.teamChangeEmitter?.({ @@ -10213,6 +10214,12 @@ export class TeamProvisioningService { 'error', error instanceof Error ? error.message : String(error) ); + if (run.isLaunch) { + await this.persistLaunchStateSnapshot( + run, + run.provisioningComplete ? 'finished' : 'active' + ); + } throw error; } } @@ -10242,6 +10249,12 @@ export class TeamProvisioningService { 'error', error instanceof Error ? error.message : String(error) ); + if (run.isLaunch) { + await this.persistLaunchStateSnapshot( + run, + run.provisioningComplete ? 'finished' : 'active' + ); + } throw error; } } @@ -10379,7 +10392,7 @@ export class TeamProvisioningService { members: nextMembers, updatedAt, }); - await this.launchStateStore.write(teamName, nextSnapshot); + await this.writeLaunchStateSnapshot(teamName, nextSnapshot); this.agentRuntimeSnapshotCache.delete(teamName); this.liveTeamAgentRuntimeMetadataCache.delete(teamName); } @@ -13514,7 +13527,7 @@ export class TeamProvisioningService { launchPhase: result.launchPhase, members, }); - await this.launchStateStore.write(input.teamName, snapshot); + await this.writeLaunchStateSnapshot(input.teamName, snapshot); return snapshot; } @@ -16851,10 +16864,48 @@ export class TeamProvisioningService { } private async clearPersistedLaunchState(teamName: string): Promise { + await this.enqueueLaunchStateStoreOperation(teamName, () => + this.clearPersistedLaunchStateNow(teamName) + ); + } + + private async clearPersistedLaunchStateNow(teamName: string): Promise { await this.launchStateStore.clear(teamName); await clearBootstrapState(teamName); } + private async writeLaunchStateSnapshot( + teamName: string, + snapshot: PersistedTeamLaunchSnapshot + ): Promise { + await this.enqueueLaunchStateStoreOperation(teamName, () => + this.writeLaunchStateSnapshotNow(teamName, snapshot) + ); + } + + private async writeLaunchStateSnapshotNow( + teamName: string, + snapshot: PersistedTeamLaunchSnapshot + ): Promise { + await this.launchStateStore.write(teamName, snapshot); + } + + private async enqueueLaunchStateStoreOperation( + teamName: string, + operation: () => Promise + ): Promise { + const previous = this.launchStateStoreQueue.get(teamName); + const queued = (previous ?? Promise.resolve()).catch(() => undefined).then(operation); + this.launchStateStoreQueue.set(teamName, queued); + try { + return await queued; + } finally { + if (this.launchStateStoreQueue.get(teamName) === queued) { + this.launchStateStoreQueue.delete(teamName); + } + } + } + private getFailedSpawnMembers( run: ProvisioningRun ): { name: string; error?: string; updatedAt: string }[] { @@ -17434,11 +17485,20 @@ export class TeamProvisioningService { launchPhase: 'active' | 'finished' | 'reconciled' = run.provisioningComplete ? 'finished' : 'active' + ): Promise { + return this.enqueueLaunchStateStoreOperation(run.teamName, () => + this.persistLaunchStateSnapshotNow(run, launchPhase) + ); + } + + private async persistLaunchStateSnapshotNow( + run: ProvisioningRun, + launchPhase: 'active' | 'finished' | 'reconciled' ): Promise { const snapshot = this.buildLiveLaunchSnapshotForRun(run, launchPhase); if (!snapshot) { if (run.isLaunch) { - await this.clearPersistedLaunchState(run.teamName); + await this.clearPersistedLaunchStateNow(run.teamName); } return null; } @@ -17447,13 +17507,13 @@ export class TeamProvisioningService { const filteredSnapshot = this.filterRemovedMembersFromLaunchSnapshot(snapshot, metaMembers); if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { - await this.clearPersistedLaunchState(run.teamName); + await this.clearPersistedLaunchStateNow(run.teamName); this.agentRuntimeSnapshotCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); return null; } - await this.launchStateStore.write(run.teamName, filteredSnapshot); + await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot); this.agentRuntimeSnapshotCache.delete(run.teamName); this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); return filteredSnapshot; @@ -18009,7 +18069,7 @@ export class TeamProvisioningService { primaryStatuses, secondaryMembers, }); - await this.launchStateStore.write(teamName, recoveredSnapshot); + await this.writeLaunchStateSnapshot(teamName, recoveredSnapshot); return recoveredSnapshot; } @@ -18440,7 +18500,7 @@ export class TeamProvisioningService { return { snapshot: null, statuses: {} }; } - await this.launchStateStore.write(teamName, reconciled); + await this.writeLaunchStateSnapshot(teamName, reconciled); return { snapshot: reconciled, statuses: snapshotToMemberSpawnStatuses(reconciled), @@ -19404,7 +19464,7 @@ export class TeamProvisioningService { previousLaunchState, force: true, }); - await this.launchStateStore.write( + await this.writeLaunchStateSnapshot( teamName, createPersistedLaunchSnapshot({ teamName, From ff333a95e8bc4941d5d5865d5048ee090f1e138c Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 29 Apr 2026 15:39:10 +0300 Subject: [PATCH 02/74] chore: fix ui lint blockers --- src/main/services/team/ReviewApplierService.ts | 2 +- src/renderer/components/team/dialogs/AddMemberDialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 734c5174..95056451 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -1185,7 +1185,7 @@ function mapRejectedHunkIndicesByHashStrict( error: 'Ledger partial reject hunk context is ambiguous; please re-review.', }; } - out.add(candidates[0]!); + out.add(candidates[0]); } return { ok: true, indices: [...out].sort((a, b) => a - b) }; } diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 09a483d4..bde75750 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -8,7 +8,6 @@ import { validateMemberNameInline, } from '@renderer/components/team/members/MembersEditorSection'; import { Button } from '@renderer/components/ui/button'; -import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze'; import { Dialog, DialogContent, @@ -17,6 +16,7 @@ import { DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; +import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze'; import { Loader2 } from 'lucide-react'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; From 27721ec5303916a5a068456c753c1d3f62e3c763 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 29 Apr 2026 18:10:20 +0300 Subject: [PATCH 03/74] chore: update model pricing snapshot --- resources/pricing.json | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/resources/pricing.json b/resources/pricing.json index 534f0611..c7ad688d 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -1963,12 +1963,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 }, - "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "claude-opus-4-7-20260416": { @@ -1997,12 +1997,12 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 }, - "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": true }, "claude-sonnet-4-20250514": { @@ -2523,6 +2523,11 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + }, "supports_service_tier": true }, "gemini-2.5-flash-lite": { @@ -2569,6 +2574,11 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + }, "supports_service_tier": true }, "gemini-2.5-pro": { @@ -2615,6 +2625,11 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, + "search_context_cost_per_query": { + "search_context_size_low": 0.035, + "search_context_size_medium": 0.035, + "search_context_size_high": 0.035 + }, "supports_service_tier": true }, "gmi/anthropic/claude-opus-4.5": { @@ -3036,7 +3051,9 @@ "supported_modalities": [ "text" ], - "supports_tool_choice": false + "supports_tool_choice": false, + "max_input_tokens": 200000, + "max_output_tokens": 1024 }, "gradient_ai/anthropic-claude-3.5-haiku": { "input_cost_per_token": 8e-7, @@ -3050,7 +3067,9 @@ "supported_modalities": [ "text" ], - "supports_tool_choice": false + "supports_tool_choice": false, + "max_input_tokens": 200000, + "max_output_tokens": 1024 }, "gradient_ai/anthropic-claude-3.5-sonnet": { "input_cost_per_token": 0.000003, @@ -3064,7 +3083,9 @@ "supported_modalities": [ "text" ], - "supports_tool_choice": false + "supports_tool_choice": false, + "max_input_tokens": 200000, + "max_output_tokens": 1024 }, "gradient_ai/anthropic-claude-3.7-sonnet": { "input_cost_per_token": 0.000003, @@ -3078,7 +3099,9 @@ "supported_modalities": [ "text" ], - "supports_tool_choice": false + "supports_tool_choice": false, + "max_input_tokens": 200000, + "max_output_tokens": 1024 }, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, @@ -3138,12 +3161,14 @@ "input_cost_per_image": 0.0004, "input_cost_per_token": 2.5e-7, "litellm_provider": "openrouter", - "max_tokens": 200000, + "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.00000125, "supports_function_calling": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "max_input_tokens": 200000, + "max_output_tokens": 4096 }, "openrouter/anthropic/claude-3.5-sonnet": { "input_cost_per_token": 0.000003, From d99cd981538cd6cb96cf2e7c0eadddcf2902eb4d Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 12:26:21 +0300 Subject: [PATCH 04/74] docs: add member work sync control plane plan --- .../member-work-sync-control-plane-plan.md | 111 ++++++++---------- 1 file changed, 52 insertions(+), 59 deletions(-) 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 d0d4c2bf..54d8fc7b 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -1136,7 +1136,7 @@ Validation result contract: ```ts export type MemberWorkSyncReportValidationReason = - | 'feature_disabled' + | 'capability_unavailable' | 'team_inactive' | 'member_inactive' | 'reserved_author' @@ -1866,26 +1866,26 @@ Rules: - In Phase 1, missing `member_work_sync_report` must not block team launch. - If the tool is missing, omit work-sync instructions from `task_briefing`/`member_briefing`. - If the tool exists but app validation bridge is unavailable, return `pending_validation`. -- If app says feature disabled, return `feature_disabled`. -- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tool is optional unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_REQUIRE_MCP_TOOL=true`. +- Do not add a runtime/env flag to require the tool in Phase 1. +- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tools remain optional compatibility capabilities. -Suggested rollout gate: +Important distinction: ```text -CLAUDE_TEAM_MEMBER_WORK_SYNC_REQUIRE_MCP_TOOL=false +capability-gated means "use it if both sides expose it". +feature-flagged means "runtime branch changes behavior based on env/config". ``` -Default `false` until Phase 1 has shipped across both repos. +Phase 1 uses capability gating only. That avoids permanent `new vs legacy` branches while still supporting mixed repo versions during development. Compatibility matrix: | claude_team | orchestrator/controller | Expected behavior | |---|---|---| | no feature | no tool | no work-sync surface | -| feature enabled | no tool | status/reconcile only, no report instruction | -| feature enabled | tool exists, no app bridge | pending intent only | -| feature enabled | tool exists, app bridge live | full report validation | -| feature disabled | tool exists | tool returns `feature_disabled`, no writes | +| app has feature | no tool | status/reconcile only, no report instruction | +| app has feature | tool exists, no app bridge | pending intent only | +| app has feature | tool exists, app bridge live | full report validation | ### 13.1 Current Agenda Read Surface @@ -2869,8 +2869,7 @@ Details dialog can show: - missing `member_work_sync_report` does not fail OpenCode readiness in Phase 1; - work-sync instructions are omitted when the tool is unavailable; - tool available + app bridge unavailable returns `pending_validation`; -- feature disabled returns `feature_disabled` and writes no intents; -- optional require-tool gate can fail readiness when explicitly enabled. +- no runtime flag is needed to require the tool in Phase 1. ### 20.4 Controller Tests @@ -2888,7 +2887,7 @@ In `agent-teams-controller`: - returns structured stale fingerprint response; - returns `pendingValidation` instead of accepted lease when app validator is unavailable; - pending validation intent replay does not update lease until app accepts; -- disabled feature returns `feature_disabled` and does not write intents; +- capability unavailable returns `capability_unavailable` and does not write accepted reports; - exposes current fingerprint through the chosen read surface; - does not write task comments or messages. @@ -3014,55 +3013,49 @@ No accelerator is proof. --- -## 22. Feature Gates +## 22. Runtime Defaults And No Feature Flags -Phase 1: +Phase 1 should ship without feature flags. -```text -CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED=true -CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=true +Reason: + +- Phase 1 has no nudges, no inbox writes, no task mutation, and no runtime restart behavior. +- Adding feature flags for passive status/report validation creates extra branches and makes failures harder to reason about. +- The safe boundary is architectural, not configurational: Phase 1 code simply does not contain the side-effect dispatcher. + +Phase 1 defaults: + +| Behavior | Default | Why | +|---|---:|---| +| agenda/fingerprint/status computation | on | passive, deterministic, app-owned | +| `member_work_sync_status` | on | read-only diagnostics | +| `member_work_sync_report` | on | server-validated, no board mutation | +| pending report intent fallback | on only when identity is not terminally invalid | compatibility with old app/runtime boundaries | +| nudges/outbox/inbox writes | not implemented | avoids hidden flag branches | + +Do not add: + +- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED` +- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY` +- `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED` + +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. + +Phase 2 policy: + +- Phase 2 is a separate implementation, not a disabled code path hidden behind a flag in Phase 1. +- If Phase 2 adds nudges, it must add dispatcher/outbox code in its own cut after metrics review. +- Phase 2 may use constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch. + +Phase 2 runtime constants can be normal typed defaults, not feature gates: + +```ts +const MEMBER_WORK_SYNC_QUIET_WINDOW_MS = 90_000; +const MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS = 10 * 60_000; +const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; ``` -Defaults: - -- enabled can default `true` only if Phase 1 is read/status-only; -- shadow-only must default `true`; -- Phase 2 nudges default `false` until explicitly validated. - -Gate behavior: - -- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED=false` disables queue, reconcile, status writes, and report acceptance. The MCP report tool should return `feature_disabled`. -- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=true` allows reconcile/status/report validation but forbids outbox and inbox writes. -- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=false` is allowed only after Phase 2 implementation and metrics review. -- Report intent recording should also honor `ENABLED=false`; do not write intent files when the feature is explicitly disabled. -- Read surfaces can include `"feature": "disabled"` when disabled, but should not instruct agents to call the report tool. - -Phase 2: - -```text -CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=false -CLAUDE_TEAM_MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR=2 -CLAUDE_TEAM_MEMBER_WORK_SYNC_QUIET_WINDOW_MS=90000 -CLAUDE_TEAM_MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS=600000 -``` - -Recommended defaults by phase: - -| Gate | Phase 1 default | Phase 2 default after metrics | -|---|---:|---:| -| `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED` | `true` | `true` | -| `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY` | `true` | `false` only after manual enable | -| `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED` | `false` | `false` until explicitly flipped | -| report tool enabled | `true` when feature enabled | `true` | -| report intent fallback | `true` when feature enabled | `true` | - -Kill-switch expectations: - -- turning `ENABLED=false` should stop queue processing within one event-loop tick; -- pending outbox items must not dispatch while disabled; -- report tool should return a structured disabled response; -- status read APIs may still return last known status marked stale/disabled; -- no feature flag should change task board state directly. +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. --- @@ -3401,7 +3394,7 @@ Step order: 2. Extend current agenda read surface. - Prefer `task_briefing.workSync`. - Include compact agenda preview, `agendaFingerprint`, state, and `reportToken`. - - Omit report instructions when tool is unavailable or feature disabled. + - Omit report instructions when tool or app validation capability is unavailable. - Keep old `task_briefing` fields unchanged. 3. Implement report validator. From c39167ece74beb027a31666c58528dc532037fa5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 13:06:58 +0300 Subject: [PATCH 05/74] feat: add member work sync control plane --- agent-teams-controller/src/controller.js | 3 + agent-teams-controller/src/internal/tasks.js | 8 + .../src/internal/workSync.js | 142 +++++++++++++ agent-teams-controller/src/mcpToolCatalog.js | 8 + .../test/controller.test.js | 75 +++++++ mcp-server/src/agent-teams-controller.d.ts | 8 + mcp-server/src/controller.ts | 11 +- mcp-server/src/tools/index.ts | 2 + mcp-server/src/tools/workSyncTools.ts | 83 ++++++++ mcp-server/test/tools.test.ts | 87 ++++++++ .../member-work-sync/contracts/index.ts | 2 + .../member-work-sync/contracts/ipc.ts | 2 + .../member-work-sync/contracts/types.ts | 102 +++++++++ .../MemberWorkSyncDiagnosticsReader.ts | 15 ++ .../application/MemberWorkSyncReconciler.ts | 59 ++++++ .../application/MemberWorkSyncReporter.ts | 78 +++++++ .../core/application/index.ts | 4 + .../core/application/ports.ts | 58 ++++++ .../core/domain/ActionableWorkAgenda.ts | 193 ++++++++++++++++++ .../core/domain/AgendaFingerprint.ts | 80 ++++++++ .../domain/MemberWorkSyncReportValidator.ts | 114 +++++++++++ .../core/domain/SyncDecisionPolicy.ts | 52 +++++ .../core/domain/currentReviewCycle.ts | 75 +++++++ .../member-work-sync/core/domain/index.ts | 6 + .../core/domain/memberName.ts | 15 ++ src/features/member-work-sync/index.ts | 1 + .../input/registerMemberWorkSyncIpc.ts | 48 +++++ .../adapters/output/TeamTaskAgendaSource.ts | 125 ++++++++++++ .../createMemberWorkSyncFeature.ts | 59 ++++++ src/features/member-work-sync/main/index.ts | 6 + .../infrastructure/JsonMemberWorkSyncStore.ts | 95 +++++++++ .../MemberWorkSyncStorePaths.ts | 17 ++ .../main/infrastructure/NodeHashAdapter.ts | 9 + .../main/infrastructure/SystemClockAdapter.ts | 7 + .../member-work-sync/preload/index.ts | 22 ++ src/main/http/index.ts | 2 + src/main/http/teams.ts | 95 +++++++++ src/main/index.ts | 21 ++ src/preload/index.ts | 2 + src/renderer/api/httpClient.ts | 16 +- src/shared/types/api.ts | 14 ++ .../core/ActionableWorkAgenda.test.ts | 173 ++++++++++++++++ .../MemberWorkSyncReportValidator.test.ts | 136 ++++++++++++ .../core/MemberWorkSyncUseCases.test.ts | 172 ++++++++++++++++ 44 files changed, 2299 insertions(+), 3 deletions(-) create mode 100644 agent-teams-controller/src/internal/workSync.js create mode 100644 mcp-server/src/tools/workSyncTools.ts create mode 100644 src/features/member-work-sync/contracts/index.ts create mode 100644 src/features/member-work-sync/contracts/ipc.ts create mode 100644 src/features/member-work-sync/contracts/types.ts create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts create mode 100644 src/features/member-work-sync/core/application/index.ts create mode 100644 src/features/member-work-sync/core/application/ports.ts create mode 100644 src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts create mode 100644 src/features/member-work-sync/core/domain/AgendaFingerprint.ts create mode 100644 src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts create mode 100644 src/features/member-work-sync/core/domain/SyncDecisionPolicy.ts create mode 100644 src/features/member-work-sync/core/domain/currentReviewCycle.ts create mode 100644 src/features/member-work-sync/core/domain/index.ts create mode 100644 src/features/member-work-sync/core/domain/memberName.ts create mode 100644 src/features/member-work-sync/index.ts create mode 100644 src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts create mode 100644 src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts create mode 100644 src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts create mode 100644 src/features/member-work-sync/main/index.ts create mode 100644 src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts create mode 100644 src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts create mode 100644 src/features/member-work-sync/main/infrastructure/NodeHashAdapter.ts create mode 100644 src/features/member-work-sync/main/infrastructure/SystemClockAdapter.ts create mode 100644 src/features/member-work-sync/preload/index.ts create mode 100644 test/features/member-work-sync/core/ActionableWorkAgenda.test.ts create mode 100644 test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts create mode 100644 test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index f48f29b7..63a6478e 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -7,6 +7,7 @@ const processes = require('./internal/processes.js'); const maintenance = require('./internal/maintenance.js'); const crossTeam = require('./internal/crossTeam.js'); const runtime = require('./internal/runtime.js'); +const workSync = require('./internal/workSync.js'); const agentBlocks = require('./internal/agentBlocks.js'); function bindModule(context, moduleApi) { @@ -31,6 +32,7 @@ function createController(options) { maintenance: bindModule(context, maintenance), crossTeam: bindModule(context, crossTeam), runtime: bindModule(context, runtime), + workSync: bindModule(context, workSync), }; } @@ -51,4 +53,5 @@ module.exports = { maintenance, crossTeam, runtime, + workSync, }; diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 13dea345..dcb860af 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -781,6 +781,14 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa - If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start. - Then run task_start only when you truly begin. - If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass. +15. MEMBER WORK SYNC REPORTING: + - member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda. They do NOT start, complete, approve, or comment on tasks. + - Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment. + - When you are about to stop, wait, or go idle because you believe your current work queue is handled, first call member_work_sync_status for yourself. + - If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working" and that exact agendaFingerprint. + - If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task. + - If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint. + - Do not report more than once for the same agendaFingerprint unless your state changed. Failure to follow this protocol means the task board will show incorrect status.`); } diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js new file mode 100644 index 00000000..8a39fde9 --- /dev/null +++ b/agent-teams-controller/src/internal/workSync.js @@ -0,0 +1,142 @@ +const fs = require('fs'); +const path = require('path'); +const runtimeHelpers = require('./runtimeHelpers.js'); + +const DEFAULT_WAIT_TIMEOUT_MS = 10000; +const MIN_WAIT_TIMEOUT_MS = 1000; +const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000; +const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json'; + +function normalizeTimeoutMs(rawValue) { + const numeric = + typeof rawValue === 'number' && Number.isFinite(rawValue) + ? Math.floor(rawValue) + : DEFAULT_WAIT_TIMEOUT_MS; + return Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, numeric)); +} + +function readControlApiState(context) { + const filePath = path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE); + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return typeof parsed?.baseUrl === 'string' && parsed.baseUrl.trim() + ? parsed.baseUrl.trim() + : ''; + } catch { + return ''; + } +} + +function resolveControlBaseUrls(context, flags = {}) { + const explicit = + (typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) || + (typeof flags['control-url'] === 'string' && flags['control-url'].trim()) || + ''; + const stateFileUrl = readControlApiState(context); + const envUrl = + typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string' + ? process.env.CLAUDE_TEAM_CONTROL_URL.trim() + : ''; + const candidates = [...new Set([explicit, stateFileUrl, envUrl].filter(Boolean))]; + if (candidates.length === 0) { + throw new Error( + 'Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports.' + ); + } + return candidates; +} + +async function requestJson(baseUrl, pathname, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), normalizeTimeoutMs(options.timeoutMs)); + try { + const response = await fetch(`${baseUrl}${pathname}`, { + method: options.method || 'GET', + headers: { + accept: 'application/json', + ...(options.body ? { 'content-type': 'application/json' } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + signal: controller.signal, + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + const detail = + payload && typeof payload.error === 'string' && payload.error.trim() + ? payload.error.trim() + : `${response.status} ${response.statusText}`.trim(); + throw new Error(detail || 'Team control API request failed'); + } + return payload; + } finally { + clearTimeout(timer); + } +} + +async function requestJsonWithFallback(baseUrls, pathname, options = {}) { + let lastError = null; + for (const baseUrl of baseUrls) { + try { + return await requestJson(baseUrl, pathname, options); + } catch (error) { + lastError = error; + } + } + throw lastError || new Error('Team control API request failed'); +} + +function compactReportBody(context, memberName, flags = {}) { + return { + teamName: context.teamName, + memberName, + state: flags.state, + agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'], + ...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}), + ...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}), + ...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}), + ...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim() + ? { reportedAt: flags.reportedAt.trim() } + : {}), + ...(typeof flags.leaseTtlMs === 'number' ? { leaseTtlMs: flags.leaseTtlMs } : {}), + }; +} + +async function memberWorkSyncStatus(context, flags = {}) { + const memberName = runtimeHelpers.assertExplicitTeamMemberName( + context.paths, + flags.memberName || flags.member || flags.from, + 'member work sync status member' + ); + const baseUrls = resolveControlBaseUrls(context, flags); + return requestJsonWithFallback( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent( + memberName + )}`, + { timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) } + ); +} + +async function memberWorkSyncReport(context, flags = {}) { + const memberName = runtimeHelpers.assertExplicitTeamMemberName( + context.paths, + flags.memberName || flags.member || flags.from, + 'member work sync report member' + ); + const baseUrls = resolveControlBaseUrls(context, flags); + return requestJsonWithFallback( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`, + { + method: 'POST', + body: compactReportBody(context, memberName, flags), + timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']), + } + ); +} + +module.exports = { + memberWorkSyncStatus, + memberWorkSyncReport, +}; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js index ecd74b19..1a5e3b0d 100644 --- a/agent-teams-controller/src/mcpToolCatalog.js +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -63,6 +63,8 @@ const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [ 'runtime_heartbeat', ]; +const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES = ['member_work_sync_status', 'member_work_sync_report']; + const AGENT_TEAMS_MCP_TOOL_GROUPS = [ { id: 'team', @@ -104,6 +106,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [ teammateOperational: false, toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES, }, + { + id: 'workSync', + teammateOperational: true, + toolNames: AGENT_TEAMS_WORK_SYNC_TOOL_NAMES, + }, { id: 'crossTeam', teammateOperational: true, @@ -141,6 +148,7 @@ module.exports = { AGENT_TEAMS_PROCESS_TOOL_NAMES, AGENT_TEAMS_KANBAN_TOOL_NAMES, AGENT_TEAMS_RUNTIME_TOOL_NAMES, + AGENT_TEAMS_WORK_SYNC_TOOL_NAMES, AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES, AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index b0494fd4..4c45e659 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -148,6 +148,7 @@ describe('agent-teams-controller API', () => { expect(briefing).toContain('Task briefing for bob:'); expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.'); expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.'); + expect(briefing).toContain('member_work_sync_status and member_work_sync_report'); expect(briefing).toContain( 'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.' ); @@ -2250,6 +2251,80 @@ describe('agent-teams-controller API', () => { } }); + it('forwards member work sync status and reports to the app validator', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + 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') { + return { + body: { + teamName: 'my-team', + memberName: 'bob', + state: 'needs_sync', + agenda: { + teamName: 'my-team', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [], + diagnostics: [], + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: ['no_current_report'], + }, + }; + } + if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/report') { + return { body: { accepted: true, code: 'accepted', status: body } }; + } + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + const status = await controller.workSync.memberWorkSyncStatus({ + controlUrl: server.baseUrl, + from: 'bob', + }); + const report = await controller.workSync.memberWorkSyncReport({ + controlUrl: server.baseUrl, + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + taskIds: ['task-1'], + note: 'Continuing work', + leaseTtlMs: 120000, + }); + + expect(status.state).toBe('needs_sync'); + 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/report', + body: { + teamName: 'my-team', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + taskIds: ['task-1'], + note: 'Continuing work', + leaseTtlMs: 120000, + }, + }, + ]); + } finally { + await server.close(); + } + }); + it('prefers the published control endpoint over a stale env URL', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 79693520..9f7c9b31 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -102,6 +102,11 @@ declare module 'agent-teams-controller' { runtimeHeartbeat(flags: Record): Promise; } + export interface ControllerWorkSyncApi { + memberWorkSyncStatus(flags: Record): Promise; + memberWorkSyncReport(flags: Record): Promise; + } + export interface AgentTeamsController { tasks: ControllerTaskApi; kanban: ControllerKanbanApi; @@ -111,6 +116,7 @@ declare module 'agent-teams-controller' { maintenance: ControllerMaintenanceApi; crossTeam: ControllerCrossTeamApi; runtime: ControllerRuntimeApi; + workSync: ControllerWorkSyncApi; } export function createController(options: ControllerContextOptions): AgentTeamsController; @@ -143,6 +149,7 @@ declare module 'agent-teams-controller' { | 'message' | 'process' | 'runtime' + | 'workSync' | 'crossTeam'; export interface AgentTeamsMcpToolGroup { @@ -159,6 +166,7 @@ declare module 'agent-teams-controller' { export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[]; export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts index 72c8d6b4..98d725dc 100644 --- a/mcp-server/src/controller.ts +++ b/mcp-server/src/controller.ts @@ -10,10 +10,17 @@ const { createController } = controllerModule; const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR'; +type WorkSyncCapableController = ReturnType & { + workSync: { + memberWorkSyncStatus(flags: Record): Promise; + memberWorkSyncReport(flags: Record): Promise; + }; +}; + /** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */ export const agentBlocks = controllerModule.agentBlocks; -export function getController(teamName: string, claudeDir?: string) { +export function getController(teamName: string, claudeDir?: string): WorkSyncCapableController { const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim(); let resolvedClaudeDir = claudeDir; if (forcedClaudeDir) { @@ -24,5 +31,5 @@ export function getController(teamName: string, claudeDir?: string) { teamName, ...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}), allowUserMessageSender: false, - }); + }) as WorkSyncCapableController; } diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index c9937293..fa1f23d4 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -14,6 +14,7 @@ import { registerReviewTools } from './reviewTools'; import { registerRuntimeTools } from './runtimeTools'; import { registerTaskTools } from './taskTools'; import { registerTeamTools } from './teamTools'; +import { registerWorkSyncTools } from './workSyncTools'; const REGISTRATION_BY_GROUP = { team: registerTeamTools, @@ -24,6 +25,7 @@ const REGISTRATION_BY_GROUP = { message: registerMessageTools, process: registerProcessTools, runtime: registerRuntimeTools, + workSync: registerWorkSyncTools, crossTeam: registerCrossTeamTools, } as const; diff --git a/mcp-server/src/tools/workSyncTools.ts b/mcp-server/src/tools/workSyncTools.ts new file mode 100644 index 00000000..2f085a57 --- /dev/null +++ b/mcp-server/src/tools/workSyncTools.ts @@ -0,0 +1,83 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; + +import { getController } from '../controller'; +import { jsonTextContent } from '../utils/format'; +import { assertConfiguredTeam } from '../utils/teamConfig'; + +const controlContextSchema = { + teamName: z.string().min(1), + claudeDir: z.string().min(1).optional(), + controlUrl: z.string().optional(), + waitTimeoutMs: z.number().int().min(1000).max(600000).optional(), +}; + +const reportStateSchema = z.enum(['still_working', 'blocked', 'caught_up']); + +export function registerWorkSyncTools(server: Pick) { + server.addTool({ + name: 'member_work_sync_status', + description: + 'Read your current actionable-work agenda and agendaFingerprint before reporting whether you are still working, blocked, or caught up.', + parameters: z.object({ + ...controlContextSchema, + memberName: z.string().min(1).optional(), + from: z.string().min(1).optional(), + }), + execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, memberName, from }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( + await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({ + ...(memberName ? { memberName } : {}), + ...(from ? { from } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ); + }, + }); + + server.addTool({ + name: 'member_work_sync_report', + description: + 'Report your validated work-sync state for the current agendaFingerprint. This never completes tasks. Use still_working while actively continuing, blocked only when the board has blocker evidence, and caught_up only when the status agenda is empty.', + parameters: z.object({ + ...controlContextSchema, + memberName: z.string().min(1).optional(), + from: z.string().min(1).optional(), + state: reportStateSchema, + agendaFingerprint: z.string().min(1), + taskIds: z.array(z.string().min(1)).optional(), + note: z.string().optional(), + leaseTtlMs: z.number().int().min(60000).max(3600000).optional(), + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + memberName, + from, + state, + agendaFingerprint, + taskIds, + note, + leaseTtlMs, + }) => { + assertConfiguredTeam(teamName, claudeDir); + return jsonTextContent( + await getController(teamName, claudeDir).workSync.memberWorkSyncReport({ + ...(memberName ? { memberName } : {}), + ...(from ? { from } : {}), + state, + agendaFingerprint, + ...(taskIds ? { taskIds } : {}), + ...(note ? { note } : {}), + ...(leaseTtlMs ? { leaseTtlMs } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + }) + ); + }, + }); +} diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index b9ea1d2f..50929a3f 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -400,6 +400,93 @@ describe('agent-teams-mcp tools', () => { } }); + it('forwards member work sync MCP tools through the app validator bridge', async () => { + const claudeDir = makeClaudeDir(); + writeTeamConfig(claudeDir, 'alpha', { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }); + const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (method === 'GET' && url === '/api/teams/alpha/member-work-sync/alice') { + return { + body: { + teamName: 'alpha', + memberName: 'alice', + state: 'needs_sync', + agenda: { + teamName: 'alpha', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [], + diagnostics: [], + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: ['no_current_report'], + }, + }; + } + if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') { + return { body: { accepted: true, code: 'accepted', status: body } }; + } + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + const status = parseJsonToolResult( + await getTool('member_work_sync_status').execute({ + claudeDir, + teamName: 'alpha', + controlUrl: server.baseUrl, + from: 'alice', + }) + ); + expect(status.state).toBe('needs_sync'); + + const report = parseJsonToolResult( + await getTool('member_work_sync_report').execute({ + claudeDir, + teamName: 'alpha', + controlUrl: server.baseUrl, + memberName: 'alice', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + taskIds: ['task-1'], + note: 'Still working', + leaseTtlMs: 120000, + }) + ); + expect(report.accepted).toBe(true); + + expect(calls).toEqual([ + { + method: 'GET', + url: '/api/teams/alpha/member-work-sync/alice', + body: undefined, + }, + { + method: 'POST', + url: '/api/teams/alpha/member-work-sync/report', + body: { + teamName: 'alpha', + memberName: 'alice', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + taskIds: ['task-1'], + note: 'Still working', + leaseTtlMs: 120000, + }, + }, + ]); + } finally { + await server.close(); + } + }); + it('discovers the control endpoint from the published state file', async () => { const claudeDir = makeClaudeDir(); writeTeamConfig(claudeDir, 'alpha', { diff --git a/src/features/member-work-sync/contracts/index.ts b/src/features/member-work-sync/contracts/index.ts new file mode 100644 index 00000000..14aed138 --- /dev/null +++ b/src/features/member-work-sync/contracts/index.ts @@ -0,0 +1,2 @@ +export * from './ipc'; +export * from './types'; diff --git a/src/features/member-work-sync/contracts/ipc.ts b/src/features/member-work-sync/contracts/ipc.ts new file mode 100644 index 00000000..5a000fe3 --- /dev/null +++ b/src/features/member-work-sync/contracts/ipc.ts @@ -0,0 +1,2 @@ +export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus'; +export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report'; diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts new file mode 100644 index 00000000..46e49c6e --- /dev/null +++ b/src/features/member-work-sync/contracts/types.ts @@ -0,0 +1,102 @@ +export type MemberWorkSyncReportState = 'still_working' | 'blocked' | 'caught_up'; + +export type MemberWorkSyncStatusState = + | 'caught_up' + | 'needs_sync' + | 'still_working' + | 'blocked' + | 'inactive' + | 'unknown'; + +export type MemberWorkSyncActionableWorkKind = + | 'work' + | 'review' + | 'clarification' + | 'blocked_dependency'; + +export type MemberWorkSyncActionableWorkPriority = + | 'normal' + | 'review_requested' + | 'blocked' + | 'needs_clarification'; + +export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; + +export interface MemberWorkSyncActionableWorkItem { + taskId: string; + displayId?: string; + subject: string; + kind: MemberWorkSyncActionableWorkKind; + assignee: string; + priority: MemberWorkSyncActionableWorkPriority; + reason: string; + evidence: { + status: string; + owner?: string; + reviewer?: string; + reviewState?: string; + needsClarification?: 'lead' | 'user'; + blockerTaskIds?: string[]; + blockedByTaskIds?: string[]; + historyEventIds?: string[]; + }; +} + +export interface MemberWorkSyncAgenda { + teamName: string; + memberName: string; + generatedAt: string; + fingerprint: string; + items: MemberWorkSyncActionableWorkItem[]; + diagnostics: string[]; + sourceRevision?: string; +} + +export interface MemberWorkSyncReport { + state: MemberWorkSyncReportState; + agendaFingerprint: string; + memberName: string; + teamName: string; + reportedAt: string; + expiresAt?: string; + taskIds?: string[]; + note?: string; + source?: 'mcp' | 'app' | 'test'; + accepted: boolean; + rejectionCode?: string; +} + +export interface MemberWorkSyncStatus { + teamName: string; + memberName: string; + state: MemberWorkSyncStatusState; + agenda: MemberWorkSyncAgenda; + report?: MemberWorkSyncReport; + evaluatedAt: string; + diagnostics: string[]; + providerId?: MemberWorkSyncProviderId; +} + +export interface MemberWorkSyncReportRequest { + teamName: string; + memberName: string; + state: MemberWorkSyncReportState; + agendaFingerprint: string; + taskIds?: string[]; + note?: string; + reportedAt?: string; + leaseTtlMs?: number; + source?: 'mcp' | 'app' | 'test'; +} + +export interface MemberWorkSyncReportResult { + accepted: boolean; + code: string; + message: string; + status: MemberWorkSyncStatus; +} + +export interface MemberWorkSyncStatusRequest { + teamName: string; + memberName: string; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts new file mode 100644 index 00000000..e907568a --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts @@ -0,0 +1,15 @@ +import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; +import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +export class MemberWorkSyncDiagnosticsReader { + private readonly reconciler: MemberWorkSyncReconciler; + + constructor(deps: MemberWorkSyncUseCaseDeps) { + this.reconciler = new MemberWorkSyncReconciler(deps); + } + + async execute(request: MemberWorkSyncStatusRequest): Promise { + return this.reconciler.execute(request); + } +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts new file mode 100644 index 00000000..29ed8ad3 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -0,0 +1,59 @@ +import { + buildAgendaFingerprintPayload, + canonicalizeAgendaFingerprintPayload, + decideMemberWorkSyncStatus, + formatAgendaFingerprint, +} from '../domain'; +import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; +import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports'; + +export function finalizeMemberWorkSyncAgenda( + deps: MemberWorkSyncUseCaseDeps, + source: MemberWorkSyncAgendaSourceResult +) { + const payload = buildAgendaFingerprintPayload({ + teamName: source.agenda.teamName, + memberName: source.agenda.memberName, + items: source.agenda.items, + sourceRevision: source.agenda.sourceRevision, + }); + const fingerprint = formatAgendaFingerprint( + deps.hash.sha256Hex(canonicalizeAgendaFingerprintPayload(payload)) + ); + return { + ...source.agenda, + fingerprint, + diagnostics: [...source.agenda.diagnostics, ...source.diagnostics], + }; +} + +export class MemberWorkSyncReconciler { + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} + + async execute(request: MemberWorkSyncStatusRequest): Promise { + const source = await this.deps.agendaSource.loadAgenda(request); + const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); + const previous = await this.deps.statusStore.read(request); + const nowIso = this.deps.clock.now().toISOString(); + const decision = decideMemberWorkSyncStatus({ + agenda, + latestAcceptedReport: previous?.report?.accepted ? previous.report : null, + nowIso, + inactive: source.inactive, + }); + + const status: MemberWorkSyncStatus = { + teamName: agenda.teamName, + memberName: agenda.memberName, + state: decision.state, + agenda, + ...(decision.acceptedReport ? { report: decision.acceptedReport } : {}), + evaluatedAt: nowIso, + diagnostics: [...agenda.diagnostics, ...decision.diagnostics], + ...(source.providerId ? { providerId: source.providerId } : {}), + }; + + await this.deps.statusStore.write(status); + return status; + } +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts new file mode 100644 index 00000000..b5e00406 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -0,0 +1,78 @@ +import type { + MemberWorkSyncReport, + MemberWorkSyncReportRequest, + MemberWorkSyncReportResult, +} from '../../contracts'; +import { validateMemberWorkSyncReport } from '../domain'; +import { finalizeMemberWorkSyncAgenda, MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +export class MemberWorkSyncReporter { + private readonly reconciler: MemberWorkSyncReconciler; + + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) { + this.reconciler = new MemberWorkSyncReconciler(deps); + } + + async execute(request: MemberWorkSyncReportRequest): Promise { + const source = await this.deps.agendaSource.loadAgenda(request); + const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); + const nowIso = ( + request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now() + ).toISOString(); + const validation = validateMemberWorkSyncReport({ + request, + agenda, + nowIso, + activeMemberNames: source.activeMemberNames, + }); + + if (!validation.ok) { + const status = await this.reconciler.execute(request); + await this.deps.reportStore?.appendPendingReport?.(request, validation.code); + return { + accepted: false, + code: validation.code, + message: validation.message, + status, + }; + } + + const report: MemberWorkSyncReport = { + teamName: agenda.teamName, + memberName: agenda.memberName, + state: request.state, + agendaFingerprint: agenda.fingerprint, + reportedAt: nowIso, + ...(validation.expiresAt ? { expiresAt: validation.expiresAt } : {}), + ...(request.taskIds ? { taskIds: [...request.taskIds] } : {}), + ...(request.note ? { note: request.note } : {}), + source: request.source ?? 'app', + accepted: true, + }; + + const status = { + teamName: agenda.teamName, + memberName: agenda.memberName, + state: + report.state === 'caught_up' + ? ('caught_up' as const) + : report.state === 'blocked' + ? ('blocked' as const) + : ('still_working' as const), + agenda, + report, + evaluatedAt: nowIso, + diagnostics: [...agenda.diagnostics, 'report_accepted'], + ...(source.providerId ? { providerId: source.providerId } : {}), + }; + + await this.deps.statusStore.write(status); + return { + accepted: true, + code: 'accepted', + message: validation.message, + status, + }; + } +} diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts new file mode 100644 index 00000000..ec75f2b2 --- /dev/null +++ b/src/features/member-work-sync/core/application/index.ts @@ -0,0 +1,4 @@ +export * from './MemberWorkSyncDiagnosticsReader'; +export * from './MemberWorkSyncReconciler'; +export * from './MemberWorkSyncReporter'; +export * from './ports'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts new file mode 100644 index 00000000..fb098c20 --- /dev/null +++ b/src/features/member-work-sync/core/application/ports.ts @@ -0,0 +1,58 @@ +import type { + MemberWorkSyncAgenda, + MemberWorkSyncProviderId, + MemberWorkSyncReport, + MemberWorkSyncReportRequest, + MemberWorkSyncStatus, +} from '../../contracts'; + +export interface MemberWorkSyncClockPort { + now(): Date; +} + +export interface MemberWorkSyncHashPort { + sha256Hex(value: string): string; +} + +export interface MemberWorkSyncLoggerPort { + debug(message: string, metadata?: Record): void; + warn(message: string, metadata?: Record): void; + error(message: string, metadata?: Record): void; +} + +export interface MemberWorkSyncAgendaSourceResult { + agenda: Omit; + activeMemberNames: string[]; + inactive: boolean; + providerId?: MemberWorkSyncProviderId; + diagnostics: string[]; +} + +export interface MemberWorkSyncAgendaSourcePort { + loadAgenda(input: { + teamName: string; + memberName: string; + }): Promise; +} + +export interface MemberWorkSyncStatusStorePort { + read(input: { teamName: string; memberName: string }): Promise; + write(status: MemberWorkSyncStatus): Promise; +} + +export interface MemberWorkSyncReportStorePort { + appendPendingReport?(request: MemberWorkSyncReportRequest, reason: string): Promise; +} + +export interface MemberWorkSyncUseCaseDeps { + clock: MemberWorkSyncClockPort; + hash: MemberWorkSyncHashPort; + agendaSource: MemberWorkSyncAgendaSourcePort; + statusStore: MemberWorkSyncStatusStorePort; + reportStore?: MemberWorkSyncReportStorePort; + logger?: MemberWorkSyncLoggerPort; +} + +export interface LatestAcceptedReportLookup { + latestAcceptedReport?: MemberWorkSyncReport; +} diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts new file mode 100644 index 00000000..4904f6d7 --- /dev/null +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -0,0 +1,193 @@ +import type { + MemberWorkSyncActionableWorkItem, + MemberWorkSyncAgenda, + MemberWorkSyncProviderId, +} from '../../contracts'; +import { + buildAgendaFingerprintPayload, + canonicalizeAgendaFingerprintPayload, + formatAgendaFingerprint, +} from './AgendaFingerprint'; +import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle'; +import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; + +export interface MemberWorkSyncTaskLike { + id: string; + displayId?: string; + subject?: string; + status: string; + owner?: string | null; + reviewState?: string | null; + needsClarification?: 'lead' | 'user' | null; + blockedBy?: string[]; + blocks?: string[]; + deletedAt?: string | null; + historyEvents?: ReviewHistoryEventLike[]; +} + +export interface MemberWorkSyncMemberLike { + name: string; + providerId?: MemberWorkSyncProviderId | string; + model?: string; + agentType?: string; + removedAt?: string | null; +} + +export interface BuildActionableWorkAgendaInput { + teamName: string; + memberName: string; + generatedAt: string; + tasks: MemberWorkSyncTaskLike[]; + members: MemberWorkSyncMemberLike[]; + kanbanReviewersByTaskId?: Record; + sourceRevision?: string; + hash: (canonicalPayload: string) => string; +} + +function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean { + return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt); +} + +function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set { + return new Set( + members + .filter((member) => !member.removedAt) + .map((member) => normalizeMemberName(member.name)) + .filter((name) => name.length > 0 && !isReservedMemberName(name)) + ); +} + +function buildBaseItem( + task: MemberWorkSyncTaskLike, + memberName: string +): Omit { + return { + taskId: task.id, + ...(task.displayId ? { displayId: task.displayId } : {}), + subject: task.subject?.trim() || 'Untitled task', + assignee: memberName, + }; +} + +export function buildActionableWorkAgenda( + input: BuildActionableWorkAgendaInput +): MemberWorkSyncAgenda { + const memberName = normalizeMemberName(input.memberName); + const diagnostics: string[] = []; + const activeMemberNames = getActiveMemberNames(input.members); + + if (!memberName || isReservedMemberName(memberName)) { + diagnostics.push('member_invalid_or_reserved'); + } else if (!activeMemberNames.has(memberName)) { + diagnostics.push('member_not_active'); + } + + const items: MemberWorkSyncActionableWorkItem[] = []; + + if (activeMemberNames.has(memberName)) { + for (const task of input.tasks) { + if (!task.id || isCompletedOrDeleted(task)) { + continue; + } + + const owner = normalizeMemberName(task.owner); + const base = buildBaseItem(task, memberName); + const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort(); + const blocks = [...(task.blocks ?? [])].filter(Boolean).sort(); + + const reviewOwner = resolveCurrentReviewOwner({ + reviewState: task.reviewState, + kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null, + historyEvents: task.historyEvents, + }); + + if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) { + items.push({ + ...base, + kind: 'review', + priority: 'review_requested', + reason: 'current_cycle_review_assigned', + evidence: { + status: task.status, + ...(owner ? { owner } : {}), + reviewer: memberName, + ...(task.reviewState ? { reviewState: task.reviewState } : {}), + ...(reviewOwner.historyEventIds.length > 0 + ? { historyEventIds: reviewOwner.historyEventIds } + : {}), + }, + }); + continue; + } + + if (!sameMemberName(owner, memberName)) { + continue; + } + + 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 } : {}), + }, + }); + continue; + } + + if (task.status === 'pending' || task.status === 'in_progress') { + items.push({ + ...base, + kind: 'work', + priority: 'normal', + reason: task.status === 'pending' ? 'owned_pending_task' : 'owned_in_progress_task', + evidence: { + status: task.status, + owner: memberName, + ...(task.reviewState ? { reviewState: task.reviewState } : {}), + }, + }); + } + } + } + + const payload = buildAgendaFingerprintPayload({ + teamName: input.teamName, + memberName, + items, + sourceRevision: input.sourceRevision, + }); + const canonicalPayload = canonicalizeAgendaFingerprintPayload(payload); + + return { + teamName: input.teamName, + memberName, + generatedAt: input.generatedAt, + fingerprint: formatAgendaFingerprint(input.hash(canonicalPayload)), + items, + diagnostics, + ...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}), + }; +} diff --git a/src/features/member-work-sync/core/domain/AgendaFingerprint.ts b/src/features/member-work-sync/core/domain/AgendaFingerprint.ts new file mode 100644 index 00000000..736aa636 --- /dev/null +++ b/src/features/member-work-sync/core/domain/AgendaFingerprint.ts @@ -0,0 +1,80 @@ +import type { MemberWorkSyncActionableWorkItem } from '../../contracts'; + +export const MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX = 'agenda:v1:'; + +function stableJson(value: unknown): string { + if (value == null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(',')}]`; + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`; +} + +export interface AgendaFingerprintPayload { + version: 1; + teamName: string; + memberName: string; + items: Array<{ + taskId: string; + displayId?: string; + subject: string; + kind: string; + assignee: string; + priority: string; + evidence: MemberWorkSyncActionableWorkItem['evidence']; + }>; + sourceRevision?: string; +} + +export function buildAgendaFingerprintPayload(input: { + teamName: string; + memberName: string; + items: MemberWorkSyncActionableWorkItem[]; + sourceRevision?: string; +}): AgendaFingerprintPayload { + return { + version: 1, + teamName: input.teamName, + memberName: input.memberName, + ...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}), + items: [...input.items] + .sort((left, right) => { + const leftKey = `${left.kind}:${left.taskId}:${left.displayId ?? ''}`; + const rightKey = `${right.kind}:${right.taskId}:${right.displayId ?? ''}`; + return leftKey.localeCompare(rightKey); + }) + .map((item) => ({ + taskId: item.taskId, + ...(item.displayId ? { displayId: item.displayId } : {}), + subject: item.subject, + kind: item.kind, + assignee: item.assignee, + priority: item.priority, + evidence: { + ...item.evidence, + ...(item.evidence.blockerTaskIds + ? { blockerTaskIds: [...item.evidence.blockerTaskIds].sort() } + : {}), + ...(item.evidence.blockedByTaskIds + ? { blockedByTaskIds: [...item.evidence.blockedByTaskIds].sort() } + : {}), + ...(item.evidence.historyEventIds + ? { historyEventIds: [...item.evidence.historyEventIds].sort() } + : {}), + }, + })), + }; +} + +export function canonicalizeAgendaFingerprintPayload(payload: AgendaFingerprintPayload): string { + return stableJson(payload); +} + +export function formatAgendaFingerprint(hashHex: string): string { + return `${MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX}${hashHex}`; +} diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts new file mode 100644 index 00000000..dcf5efc2 --- /dev/null +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts @@ -0,0 +1,114 @@ +import type { + MemberWorkSyncAgenda, + MemberWorkSyncReportRequest, + MemberWorkSyncReportState, +} from '../../contracts'; +import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; + +export interface MemberWorkSyncReportValidation { + ok: boolean; + code: string; + message: string; + expiresAt?: string; +} + +const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000; +const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000; +const MIN_LEASE_MS = 60_000; +const MAX_LEASE_MS = 60 * 60 * 1000; + +function clampLeaseTtlMs( + value: number | undefined, + state: MemberWorkSyncReportState +): number | undefined { + if (state === 'caught_up') { + return undefined; + } + const fallback = state === 'blocked' ? DEFAULT_BLOCKED_LEASE_MS : DEFAULT_STILL_WORKING_LEASE_MS; + const numeric = Number.isFinite(value) ? Math.floor(Number(value)) : fallback; + return Math.min(MAX_LEASE_MS, Math.max(MIN_LEASE_MS, numeric)); +} + +function agendaHasBlockedEvidence( + agenda: MemberWorkSyncAgenda, + taskIds: string[] | undefined +): boolean { + const targetIds = new Set((taskIds ?? []).filter(Boolean)); + return agenda.items.some((item) => { + if (targetIds.size > 0 && !targetIds.has(item.taskId)) { + return false; + } + return item.kind === 'blocked_dependency' || item.priority === 'blocked'; + }); +} + +export function validateMemberWorkSyncReport(input: { + request: MemberWorkSyncReportRequest; + agenda: MemberWorkSyncAgenda; + nowIso: string; + activeMemberNames: string[]; +}): MemberWorkSyncReportValidation { + const memberName = normalizeMemberName(input.request.memberName); + const activeMemberNames = new Set(input.activeMemberNames.map(normalizeMemberName)); + + if (!memberName || isReservedMemberName(memberName)) { + return { ok: false, code: 'reserved_or_invalid_member', message: 'Invalid member identity.' }; + } + if (!sameMemberName(memberName, input.agenda.memberName)) { + return { + ok: false, + code: 'identity_mismatch', + message: 'Report member does not match agenda.', + }; + } + if (!activeMemberNames.has(memberName)) { + return { ok: false, code: 'member_inactive', message: 'Member is not active in this team.' }; + } + if (input.request.agendaFingerprint !== input.agenda.fingerprint) { + return { + ok: false, + code: 'stale_fingerprint', + message: 'Report fingerprint is stale. Read current member work sync status and retry.', + }; + } + + const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId)); + for (const taskId of input.request.taskIds ?? []) { + if (!agendaTaskIds.has(taskId)) { + return { + ok: false, + code: 'foreign_task_id', + message: `Task ${taskId} is not in the current actionable agenda.`, + }; + } + } + + if (input.request.state === 'caught_up' && input.agenda.items.length > 0) { + return { + ok: false, + code: 'caught_up_rejected_actionable_items_exist', + message: 'Cannot report caught_up while actionable work remains.', + }; + } + + if ( + input.request.state === 'blocked' && + !agendaHasBlockedEvidence(input.agenda, input.request.taskIds) + ) { + return { + ok: false, + code: 'blocked_without_evidence', + message: 'Blocked report requires current blocker evidence in the task board.', + }; + } + + const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state); + return { + ok: true, + code: 'accepted', + message: 'Member work sync report accepted.', + ...(leaseTtlMs + ? { expiresAt: new Date(Date.parse(input.nowIso) + leaseTtlMs).toISOString() } + : {}), + }; +} diff --git a/src/features/member-work-sync/core/domain/SyncDecisionPolicy.ts b/src/features/member-work-sync/core/domain/SyncDecisionPolicy.ts new file mode 100644 index 00000000..5524b8b4 --- /dev/null +++ b/src/features/member-work-sync/core/domain/SyncDecisionPolicy.ts @@ -0,0 +1,52 @@ +import type { + MemberWorkSyncAgenda, + MemberWorkSyncReport, + MemberWorkSyncStatusState, +} from '../../contracts'; + +export interface SyncDecision { + state: MemberWorkSyncStatusState; + acceptedReport?: MemberWorkSyncReport; + diagnostics: string[]; +} + +export function decideMemberWorkSyncStatus(input: { + agenda: MemberWorkSyncAgenda; + latestAcceptedReport?: MemberWorkSyncReport | null; + nowIso: string; + inactive?: boolean; +}): SyncDecision { + if (input.inactive) { + return { state: 'inactive', diagnostics: ['member_or_team_inactive'] }; + } + + if (input.agenda.items.length === 0) { + return { + state: 'caught_up', + diagnostics: ['agenda_empty'], + acceptedReport: + input.latestAcceptedReport?.agendaFingerprint === input.agenda.fingerprint + ? input.latestAcceptedReport + : undefined, + }; + } + + const report = input.latestAcceptedReport ?? null; + if (!report) { + return { state: 'needs_sync', diagnostics: ['no_current_report'] }; + } + if (report.agendaFingerprint !== input.agenda.fingerprint) { + return { state: 'needs_sync', diagnostics: ['report_fingerprint_stale'] }; + } + if (report.expiresAt && Date.parse(report.expiresAt) <= Date.parse(input.nowIso)) { + return { state: 'needs_sync', diagnostics: ['report_lease_expired'] }; + } + if (report.state === 'still_working') { + return { state: 'still_working', acceptedReport: report, diagnostics: ['lease_still_working'] }; + } + if (report.state === 'blocked') { + return { state: 'blocked', acceptedReport: report, diagnostics: ['lease_blocked'] }; + } + + return { state: 'needs_sync', diagnostics: ['caught_up_report_not_valid_for_non_empty_agenda'] }; +} diff --git a/src/features/member-work-sync/core/domain/currentReviewCycle.ts b/src/features/member-work-sync/core/domain/currentReviewCycle.ts new file mode 100644 index 00000000..e962649d --- /dev/null +++ b/src/features/member-work-sync/core/domain/currentReviewCycle.ts @@ -0,0 +1,75 @@ +import { normalizeMemberName } from './memberName'; + +export interface ReviewHistoryEventLike { + id?: string; + type: string; + timestamp?: string; + actor?: string; + reviewer?: string; +} + +export interface CurrentReviewOwner { + reviewer: string; + historyEventIds: string[]; +} + +function compareEventsByTimestamp( + left: ReviewHistoryEventLike, + right: ReviewHistoryEventLike +): number { + const leftTime = Date.parse(left.timestamp ?? ''); + const rightTime = Date.parse(right.timestamp ?? ''); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return leftTime - rightTime; + } + return 0; +} + +export function resolveCurrentReviewOwner(input: { + reviewState?: string | null; + kanbanReviewer?: string | null; + historyEvents?: ReviewHistoryEventLike[]; +}): CurrentReviewOwner | null { + if (input.reviewState !== 'review') { + return null; + } + + const historyEvents = [...(input.historyEvents ?? [])] + .filter((event) => + [ + 'review_requested', + 'review_started', + 'review_approved', + 'review_changes_requested', + ].includes(event.type) + ) + .sort(compareEventsByTimestamp); + + const latest = historyEvents.at(-1); + if (latest?.type === 'review_approved' || latest?.type === 'review_changes_requested') { + return null; + } + + const latestStarted = [...historyEvents] + .reverse() + .find((event) => event.type === 'review_started'); + const latestRequested = [...historyEvents] + .reverse() + .find((event) => event.type === 'review_requested'); + + const reviewer = + normalizeMemberName(latestStarted?.actor) || + normalizeMemberName(latestRequested?.reviewer) || + normalizeMemberName(input.kanbanReviewer); + + if (!reviewer) { + return null; + } + + return { + reviewer, + historyEventIds: [latestStarted?.id, latestRequested?.id].filter( + (id): id is string => typeof id === 'string' && id.length > 0 + ), + }; +} diff --git a/src/features/member-work-sync/core/domain/index.ts b/src/features/member-work-sync/core/domain/index.ts new file mode 100644 index 00000000..8e9ccf16 --- /dev/null +++ b/src/features/member-work-sync/core/domain/index.ts @@ -0,0 +1,6 @@ +export * from './ActionableWorkAgenda'; +export * from './AgendaFingerprint'; +export * from './currentReviewCycle'; +export * from './memberName'; +export * from './MemberWorkSyncReportValidator'; +export * from './SyncDecisionPolicy'; diff --git a/src/features/member-work-sync/core/domain/memberName.ts b/src/features/member-work-sync/core/domain/memberName.ts new file mode 100644 index 00000000..4a61540f --- /dev/null +++ b/src/features/member-work-sync/core/domain/memberName.ts @@ -0,0 +1,15 @@ +const RESERVED_MEMBER_NAMES = new Set(['', 'user', 'system']); + +export function normalizeMemberName(value: unknown): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +export function isReservedMemberName(value: unknown): boolean { + return RESERVED_MEMBER_NAMES.has(normalizeMemberName(value)); +} + +export function sameMemberName(left: unknown, right: unknown): boolean { + const normalizedLeft = normalizeMemberName(left); + const normalizedRight = normalizeMemberName(right); + return normalizedLeft.length > 0 && normalizedLeft === normalizedRight; +} diff --git a/src/features/member-work-sync/index.ts b/src/features/member-work-sync/index.ts new file mode 100644 index 00000000..c7041c4c --- /dev/null +++ b/src/features/member-work-sync/index.ts @@ -0,0 +1 @@ +export * from './contracts'; diff --git a/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts new file mode 100644 index 00000000..ea41cf10 --- /dev/null +++ b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts @@ -0,0 +1,48 @@ +import { + MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REPORT, + type MemberWorkSyncReportRequest, + type MemberWorkSyncReportResult, + type MemberWorkSyncStatus, + type MemberWorkSyncStatusRequest, +} from '../../../contracts'; +import { createLogger } from '@shared/utils/logger'; + +import type { MemberWorkSyncFeatureFacade } from '../../composition/createMemberWorkSyncFeature'; +import type { IpcMain } from 'electron'; + +const logger = createLogger('Feature:MemberWorkSync:IPC'); + +export function registerMemberWorkSyncIpc( + ipcMain: IpcMain, + feature: MemberWorkSyncFeatureFacade +): void { + ipcMain.handle( + MEMBER_WORK_SYNC_GET_STATUS, + async (_event, request: MemberWorkSyncStatusRequest): Promise => { + try { + return await feature.getStatus(request); + } catch (error) { + logger.error('Failed to get member work sync status', error); + throw error; + } + } + ); + + ipcMain.handle( + MEMBER_WORK_SYNC_REPORT, + async (_event, request: MemberWorkSyncReportRequest): Promise => { + try { + return await feature.report(request); + } catch (error) { + logger.error('Failed to submit member work sync report', error); + throw error; + } + } + ); +} + +export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS); + ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT); +} diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts new file mode 100644 index 00000000..33aae5bc --- /dev/null +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts @@ -0,0 +1,125 @@ +import { + buildActionableWorkAgenda, + normalizeMemberName, + type MemberWorkSyncMemberLike, +} from '../../../core/domain'; +import type { + MemberWorkSyncAgendaSourcePort, + MemberWorkSyncAgendaSourceResult, + MemberWorkSyncHashPort, +} from '../../../core/application'; +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; + +import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; +import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; +import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; +import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; +import type { TeamMember } from '@shared/types'; + +export interface TeamTaskAgendaSourceDeps { + configReader: TeamConfigReader; + taskReader: TeamTaskReader; + kanbanManager: TeamKanbanManager; + membersMetaStore: TeamMembersMetaStore; + hash: MemberWorkSyncHashPort; + clock: { now(): Date }; +} + +function memberKey(member: Pick): string { + return normalizeMemberName(member.name); +} + +function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] { + const byName = new Map(); + for (const member of configMembers) { + const key = memberKey(member); + if (key) { + byName.set(key, member); + } + } + for (const member of metaMembers) { + const key = memberKey(member); + if (key) { + byName.set(key, { ...byName.get(key), ...member }); + } + } + return [...byName.values()]; +} + +function toMemberLike(member: TeamMember): MemberWorkSyncMemberLike { + const providerId = + normalizeOptionalTeamProviderId(member.providerId) ?? + inferTeamProviderIdFromModel(member.model); + return { + name: member.name, + ...(providerId ? { providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.agentType ? { agentType: member.agentType } : {}), + ...(member.removedAt ? { removedAt: String(member.removedAt) } : {}), + }; +} + +export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort { + constructor(private readonly deps: TeamTaskAgendaSourceDeps) {} + + async loadAgenda(input: { + teamName: string; + memberName: string; + }): Promise { + const config = await this.deps.configReader.getConfig(input.teamName); + if (!config || config.deletedAt) { + const nowIso = this.deps.clock.now().toISOString(); + return { + agenda: { + teamName: input.teamName, + memberName: normalizeMemberName(input.memberName), + generatedAt: nowIso, + items: [], + diagnostics: config?.deletedAt ? ['team_deleted'] : ['team_config_missing'], + }, + activeMemberNames: [], + inactive: true, + diagnostics: [], + }; + } + + const [tasks, kanban, metaMembers] = await Promise.all([ + this.deps.taskReader.getTasks(input.teamName), + this.deps.kanbanManager.getState(input.teamName), + this.deps.membersMetaStore.getMembers(input.teamName), + ]); + const members = mergeMembers(config.members ?? [], metaMembers); + const activeMemberNames = members + .filter((member) => !member.removedAt) + .map((member) => normalizeMemberName(member.name)) + .filter(Boolean); + const normalizedMemberName = normalizeMemberName(input.memberName); + const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName); + const providerId = + normalizeOptionalTeamProviderId(member?.providerId) ?? + inferTeamProviderIdFromModel(member?.model); + + const agenda = buildActionableWorkAgenda({ + teamName: input.teamName, + memberName: input.memberName, + generatedAt: this.deps.clock.now().toISOString(), + tasks, + members: members.map(toMemberLike), + kanbanReviewersByTaskId: Object.fromEntries( + Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null]) + ), + hash: this.deps.hash.sha256Hex.bind(this.deps.hash), + }); + + return { + agenda, + activeMemberNames, + inactive: !activeMemberNames.includes(normalizedMemberName), + ...(providerId ? { providerId } : {}), + diagnostics: [], + }; + } +} diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts new file mode 100644 index 00000000..4c3d6188 --- /dev/null +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -0,0 +1,59 @@ +import type { + MemberWorkSyncReportRequest, + MemberWorkSyncReportResult, + MemberWorkSyncStatus, + MemberWorkSyncStatusRequest, +} from '../../contracts'; +import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncReporter } from '../../core/application'; +import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; +import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; +import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; +import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; +import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; + +import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; +import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; +import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; +import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; +import type { MemberWorkSyncLoggerPort } from '../../core/application'; + +export interface MemberWorkSyncFeatureFacade { + getStatus(request: MemberWorkSyncStatusRequest): Promise; + report(request: MemberWorkSyncReportRequest): Promise; +} + +export function createMemberWorkSyncFeature(deps: { + teamsBasePath: string; + configReader: TeamConfigReader; + taskReader: TeamTaskReader; + kanbanManager: TeamKanbanManager; + membersMetaStore: TeamMembersMetaStore; + logger?: MemberWorkSyncLoggerPort; +}): MemberWorkSyncFeatureFacade { + const clock = new SystemClockAdapter(); + const hash = new NodeHashAdapter(); + const agendaSource = new TeamTaskAgendaSource({ + configReader: deps.configReader, + taskReader: deps.taskReader, + kanbanManager: deps.kanbanManager, + membersMetaStore: deps.membersMetaStore, + hash, + clock, + }); + const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(deps.teamsBasePath)); + const useCaseDeps = { + clock, + hash, + agendaSource, + statusStore: store, + reportStore: store, + logger: deps.logger, + }; + const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps); + const reporter = new MemberWorkSyncReporter(useCaseDeps); + + return { + getStatus: (request) => diagnosticsReader.execute(request), + report: (request) => reporter.execute(request), + }; +} diff --git a/src/features/member-work-sync/main/index.ts b/src/features/member-work-sync/main/index.ts new file mode 100644 index 00000000..e9cd3909 --- /dev/null +++ b/src/features/member-work-sync/main/index.ts @@ -0,0 +1,6 @@ +export { + registerMemberWorkSyncIpc, + removeMemberWorkSyncIpc, +} from './adapters/input/registerMemberWorkSyncIpc'; +export { createMemberWorkSyncFeature } from './composition/createMemberWorkSyncFeature'; +export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature'; diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts new file mode 100644 index 00000000..9fa9954f --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -0,0 +1,95 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { mkdir, readFile, appendFile } from 'fs/promises'; + +import type { MemberWorkSyncReportRequest, MemberWorkSyncStatus } from '../../contracts'; +import type { + MemberWorkSyncReportStorePort, + MemberWorkSyncStatusStorePort, +} from '../../core/application'; +import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; + +interface StoreFile { + schemaVersion: 1; + members: Record; +} + +function normalizeMemberKey(memberName: string): string { + return memberName.trim().toLowerCase(); +} + +function isStoreFile(value: unknown): value is StoreFile { + return ( + value != null && + typeof value === 'object' && + (value as StoreFile).schemaVersion === 1 && + (value as StoreFile).members != null && + typeof (value as StoreFile).members === 'object' && + !Array.isArray((value as StoreFile).members) + ); +} + +export class JsonMemberWorkSyncStore + implements MemberWorkSyncStatusStorePort, MemberWorkSyncReportStorePort +{ + private readonly writeQueues = new Map>(); + + constructor(private readonly paths: MemberWorkSyncStorePaths) {} + + async read(input: { + teamName: string; + memberName: string; + }): Promise { + const file = await this.readFile(input.teamName); + return file.members[normalizeMemberKey(input.memberName)] ?? null; + } + + async write(status: MemberWorkSyncStatus): Promise { + await this.enqueue(status.teamName, async () => { + const existing = await this.readFile(status.teamName); + existing.members[normalizeMemberKey(status.memberName)] = status; + await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true }); + await atomicWriteAsync( + this.paths.getStatusPath(status.teamName), + JSON.stringify(existing, null, 2) + ); + }); + } + + async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { + await mkdir(this.paths.getTeamDir(request.teamName), { recursive: true }); + await appendFile( + this.paths.getPendingReportsPath(request.teamName), + `${JSON.stringify({ schemaVersion: 1, reason, request, recordedAt: new Date().toISOString() })}\n`, + 'utf8' + ); + } + + private async readFile(teamName: string): Promise { + try { + const raw = await readFile(this.paths.getStatusPath(teamName), 'utf8'); + const parsed = JSON.parse(raw); + if (isStoreFile(parsed)) { + return parsed; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + return { schemaVersion: 1, members: {} }; + } + + private async enqueue(teamName: string, operation: () => Promise): Promise { + const previous = this.writeQueues.get(teamName) ?? Promise.resolve(); + const next = previous.then(operation, operation); + this.writeQueues.set( + teamName, + next.finally(() => { + if (this.writeQueues.get(teamName) === next) { + this.writeQueues.delete(teamName); + } + }) + ); + await next; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts new file mode 100644 index 00000000..f82f7b4f --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts @@ -0,0 +1,17 @@ +import { join } from 'path'; + +export class MemberWorkSyncStorePaths { + constructor(private readonly teamsBasePath: string) {} + + getTeamDir(teamName: string): string { + return join(this.teamsBasePath, teamName, '.member-work-sync'); + } + + getStatusPath(teamName: string): string { + return join(this.getTeamDir(teamName), 'status.json'); + } + + getPendingReportsPath(teamName: string): string { + return join(this.getTeamDir(teamName), 'pending-reports.jsonl'); + } +} diff --git a/src/features/member-work-sync/main/infrastructure/NodeHashAdapter.ts b/src/features/member-work-sync/main/infrastructure/NodeHashAdapter.ts new file mode 100644 index 00000000..4aa57958 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/NodeHashAdapter.ts @@ -0,0 +1,9 @@ +import { createHash } from 'crypto'; + +import type { MemberWorkSyncHashPort } from '../../core/application'; + +export class NodeHashAdapter implements MemberWorkSyncHashPort { + sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); + } +} diff --git a/src/features/member-work-sync/main/infrastructure/SystemClockAdapter.ts b/src/features/member-work-sync/main/infrastructure/SystemClockAdapter.ts new file mode 100644 index 00000000..ec588300 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/SystemClockAdapter.ts @@ -0,0 +1,7 @@ +import type { MemberWorkSyncClockPort } from '../../core/application'; + +export class SystemClockAdapter implements MemberWorkSyncClockPort { + now(): Date { + return new Date(); + } +} diff --git a/src/features/member-work-sync/preload/index.ts b/src/features/member-work-sync/preload/index.ts new file mode 100644 index 00000000..c90efb6b --- /dev/null +++ b/src/features/member-work-sync/preload/index.ts @@ -0,0 +1,22 @@ +import { + MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REPORT, + type MemberWorkSyncReportRequest, + type MemberWorkSyncReportResult, + type MemberWorkSyncStatus, + type MemberWorkSyncStatusRequest, +} from '../contracts'; + +import type { IpcRenderer } from 'electron'; + +export interface MemberWorkSyncElectronApi { + getStatus(request: MemberWorkSyncStatusRequest): Promise; + report(request: MemberWorkSyncReportRequest): Promise; +} + +export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi { + return { + getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request), + report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request), + }; +} diff --git a/src/main/http/index.ts b/src/main/http/index.ts index 4b225922..09f7c0c6 100644 --- a/src/main/http/index.ts +++ b/src/main/http/index.ts @@ -9,6 +9,7 @@ import { type RecentProjectsFeatureFacade, registerRecentProjectsHttp, } from '@features/recent-projects/main'; +import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main'; import { createLogger } from '@shared/utils/logger'; import { registerConfigRoutes } from './config'; @@ -46,6 +47,7 @@ export interface HttpServices { chunkBuilder: ChunkBuilder; dataCache: DataCache; recentProjectsFeature?: RecentProjectsFeatureFacade; + memberWorkSyncFeature?: MemberWorkSyncFeatureFacade; updaterService: UpdaterService; sshConnectionManager: SshConnectionManager; teamDataService?: TeamDataService; diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 38047798..4d67535f 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -14,6 +14,7 @@ import { access } from 'fs/promises'; import { isAbsolute, join } from 'path'; import type { HttpServices } from './index'; +import type { MemberWorkSyncReportState } from '@features/member-work-sync/contracts'; import type { EffortLevel, TeamCreateConfigRequest, @@ -31,6 +32,10 @@ type CreateTeamBody = TeamCreateConfigRequest; class HttpBadRequestError extends Error {} class HttpFeatureUnavailableError extends Error {} +function isMemberWorkSyncReportState(value: string): value is MemberWorkSyncReportState { + return value === 'still_working' || value === 'blocked' || value === 'caught_up'; +} + function getTeamProvisioningService( services: HttpServices ): NonNullable { @@ -466,6 +471,15 @@ function withRuntimeTeamName(teamName: string, body: unknown): Record { + if (!services.memberWorkSyncFeature) { + throw new HttpBadRequestError('Member work sync feature is unavailable'); + } + return services.memberWorkSyncFeature; +} + export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void { app.get('/api/teams', async (_request, reply) => { try { @@ -736,4 +750,85 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) } } ); + + app.get<{ Params: { teamName: string; memberName: string } }>( + '/api/teams/:teamName/member-work-sync/:memberName', + 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).getStatus({ + teamName: validatedTeamName.value!, + memberName, + }) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in GET /api/teams/${request.params.teamName}/member-work-sync/${request.params.memberName}:`, + 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) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + const payload = withRuntimeTeamName(validatedTeamName.value!, request.body); + const memberName = typeof payload.memberName === 'string' ? payload.memberName.trim() : ''; + const state = typeof payload.state === 'string' ? payload.state.trim() : ''; + const agendaFingerprint = + typeof payload.agendaFingerprint === 'string' ? payload.agendaFingerprint.trim() : ''; + if (!memberName || !state || !agendaFingerprint) { + return reply.status(400).send({ + error: 'memberName, state, and agendaFingerprint are required', + }); + } + if (!isMemberWorkSyncReportState(state)) { + return reply + .status(400) + .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') + : undefined; + return reply.send( + await getMemberWorkSyncFeature(services).report({ + teamName: validatedTeamName.value!, + memberName, + state, + agendaFingerprint, + ...(taskIds ? { taskIds } : {}), + ...(typeof payload.note === 'string' ? { note: payload.note } : {}), + ...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}), + ...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}), + source: 'mcp', + }) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/member-work-sync/report:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); } diff --git a/src/main/index.ts b/src/main/index.ts index 00434089..f7669d44 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,6 +36,12 @@ import { registerRecentProjectsIpc, removeRecentProjectsIpc, } from '@features/recent-projects/main'; +import { + createMemberWorkSyncFeature, + type MemberWorkSyncFeatureFacade, + registerMemberWorkSyncIpc, + removeMemberWorkSyncIpc, +} from '@features/member-work-sync/main'; import { createRuntimeProviderManagementFeature, registerRuntimeProviderManagementIpc, @@ -178,11 +184,14 @@ import { TeamMemberLogsFinder, TeamProvisioningService, TeamRuntimeAdapterRegistry, + TeamKanbanManager, + TeamMembersMetaStore, TeamTaskStallJournal, TeamTaskStallMonitor, TeamTaskStallNotifier, TeamTaskStallPolicy, TeamTaskStallSnapshotSource, + TeamTaskReader, UpdaterService, } from './services'; @@ -558,6 +567,7 @@ let codexAccountFeature: CodexAccountFeatureFacade | null = null; let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null; let recentProjectsFeature: RecentProjectsFeatureFacade; let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade; +let memberWorkSyncFeature: MemberWorkSyncFeatureFacade; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let cliInstallerService: CliInstallerService; @@ -1215,6 +1225,14 @@ async function initializeServices(): Promise { logger: createLogger('Feature:RecentProjects'), }); runtimeProviderManagementFeature = createRuntimeProviderManagementFeature(); + memberWorkSyncFeature = createMemberWorkSyncFeature({ + teamsBasePath: getTeamsBasePath(), + configReader: new TeamConfigReader(), + taskReader: new TeamTaskReader(), + kanbanManager: new TeamKanbanManager(), + membersMetaStore: new TeamMembersMetaStore(), + logger: createLogger('Feature:MemberWorkSync'), + }); codexAccountFeature = createCodexAccountFeature({ logger: createLogger('Feature:CodexAccount'), configManager, @@ -1282,6 +1300,7 @@ async function initializeServices(): Promise { registerCodexAccountIpc(ipcMain, codexAccountFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature); registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature); + registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature); // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { @@ -1335,6 +1354,7 @@ async function startHttpServer( chunkBuilder: activeContext.chunkBuilder, dataCache: activeContext.dataCache, recentProjectsFeature, + memberWorkSyncFeature, updaterService, sshConnectionManager, teamDataService, @@ -1467,6 +1487,7 @@ async function shutdownServices(): Promise { removeCodexAccountIpc(ipcMain); removeRecentProjectsIpc(ipcMain); removeRuntimeProviderManagementIpc(ipcMain); + removeMemberWorkSyncIpc(ipcMain); }); await runShutdownStep('team backup dispose', () => teamBackupService?.dispose()); diff --git a/src/preload/index.ts b/src/preload/index.ts index 47fbbe23..f175881a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ import { createCodexAccountBridge } from '@features/codex-account/preload'; +import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload'; import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload'; import { createTmuxInstallerBridge } from '@features/tmux-installer/preload'; @@ -471,6 +472,7 @@ const electronAPI: ElectronAPI = { }), ...createRecentProjectsBridge(), runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer), + memberWorkSync: createMemberWorkSyncBridge(ipcRenderer), getAppVersion: () => ipcRenderer.invoke('get-app-version'), getProjects: () => ipcRenderer.invoke('get-projects'), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 451149dd..171cbeaa 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -82,7 +82,7 @@ import type { WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; -import type { AgentConfig } from '@shared/types/api'; +import type { AgentConfig, MemberWorkSyncElectronApi } from '@shared/types/api'; import type { EditorAPI, ProjectAPI } from '@shared/types/editor'; import type { TerminalAPI } from '@shared/types/terminal'; @@ -1289,6 +1289,20 @@ export class HttpAPIClient implements ElectronAPI { }), }; + memberWorkSync: MemberWorkSyncElectronApi = { + getStatus: (request) => + this.get( + `/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/${encodeURIComponent( + request.memberName + )}` + ), + report: (request) => + this.post( + `/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/report`, + request + ), + }; + tmux: TmuxAPI = { getStatus: async (): Promise => ({ platform: 'unknown', diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 7d8ddc32..633430b2 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -97,6 +97,12 @@ import type { WaterfallData } from './visualization'; import type { CodexAccountElectronApi } from '@features/codex-account/contracts'; import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; +import type { + MemberWorkSyncReportRequest, + MemberWorkSyncReportResult, + MemberWorkSyncStatus, + MemberWorkSyncStatusRequest, +} from '@features/member-work-sync/contracts'; import type { ConversationGroup, FileChangeEvent, @@ -604,6 +610,11 @@ export interface TeamsAPI { readFileForToolApproval: (filePath: string) => Promise; } +export interface MemberWorkSyncElectronApi { + getStatus(request: MemberWorkSyncStatusRequest): Promise; + report(request: MemberWorkSyncReportRequest): Promise; +} + // ============================================================================= // Cross-Team Communication API // ============================================================================= @@ -876,6 +887,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec // Runtime nested provider management API runtimeProviderManagement: RuntimeProviderManagementApi; + // Member actionable-work sync diagnostics API + memberWorkSync: MemberWorkSyncElectronApi; + // tmux runtime diagnostics API tmux: TmuxAPI; diff --git a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts new file mode 100644 index 00000000..ae876095 --- /dev/null +++ b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { buildActionableWorkAgenda } from '@features/member-work-sync/core/domain'; + +const hash = (value: string) => `h${value.length}`; + +describe('buildActionableWorkAgenda', () => { + it('includes owned pending and in-progress work but excludes completed tasks', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }], + tasks: [ + { + id: 'task-1', + displayId: '#11111111', + subject: 'Pending', + status: 'pending', + owner: 'bob', + }, + { + id: 'task-2', + displayId: '#22222222', + subject: 'In progress', + status: 'in_progress', + owner: 'Bob', + }, + { + id: 'task-3', + displayId: '#33333333', + subject: 'Done', + status: 'completed', + owner: 'bob', + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([ + ['task-1', 'work', 'owned_pending_task'], + ['task-2', 'work', 'owned_in_progress_task'], + ]); + }); + + it('assigns active review work to the current-cycle reviewer only', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Review me', + status: 'in_progress', + owner: 'bob', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-1', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items).toHaveLength(1); + expect(agenda.items[0]).toMatchObject({ + taskId: 'task-1', + kind: 'review', + assignee: 'alice', + evidence: { reviewer: 'alice' }, + }); + }); + + it('does not resurrect a stale reviewer after review was approved', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'alice', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'alice' }, { name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Old review', + status: 'in_progress', + owner: 'bob', + reviewState: 'review', + historyEvents: [ + { + id: 'evt-1', + type: 'review_requested', + timestamp: '2026-04-29T00:00:00.000Z', + reviewer: 'alice', + }, + { + id: 'evt-2', + type: 'review_approved', + timestamp: '2026-04-29T00:01:00.000Z', + actor: 'alice', + }, + ], + }, + ], + hash, + }); + + expect(agenda.items).toEqual([]); + }); + + it('projects clarification and blocked dependency work for the owner', () => { + const agenda = buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + members: [{ name: 'bob' }], + tasks: [ + { + id: 'task-1', + subject: 'Need user', + status: 'in_progress', + owner: 'bob', + needsClarification: 'user', + }, + { + id: 'task-2', + subject: 'Blocked', + status: 'in_progress', + owner: 'bob', + blockedBy: ['task-3'], + }, + ], + hash, + }); + + expect(agenda.items.map((item) => [item.taskId, item.kind, item.priority])).toEqual([ + ['task-1', 'clarification', 'needs_clarification'], + ['task-2', 'blocked_dependency', 'blocked'], + ]); + }); + + it('keeps fingerprint stable across generatedAt changes and changes it on owner change', () => { + const base = { + teamName: 'team-a', + memberName: 'bob', + members: [{ name: 'bob' }, { name: 'alice' }], + hash, + }; + const first = buildActionableWorkAgenda({ + ...base, + generatedAt: '2026-04-29T00:00:00.000Z', + tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }], + }); + const second = buildActionableWorkAgenda({ + ...base, + generatedAt: '2026-04-29T00:05:00.000Z', + tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }], + }); + const third = buildActionableWorkAgenda({ + ...base, + generatedAt: '2026-04-29T00:05:00.000Z', + tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'alice' }], + }); + + expect(first.fingerprint).toBe(second.fingerprint); + expect(first.fingerprint).not.toBe(third.fingerprint); + }); +}); diff --git a/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts new file mode 100644 index 00000000..8a7a902a --- /dev/null +++ b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildActionableWorkAgenda, + validateMemberWorkSyncReport, +} from '@features/member-work-sync/core/domain'; + +const nowIso = '2026-04-29T00:00:00.000Z'; +const hash = (value: string) => `h${value.length}`; + +function agendaWithWork() { + return buildActionableWorkAgenda({ + teamName: 'team-a', + memberName: 'bob', + generatedAt: nowIso, + members: [{ name: 'bob' }], + tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }], + hash, + }); +} + +describe('validateMemberWorkSyncReport', () => { + it('accepts still_working for the current agenda fingerprint', () => { + const agenda = agendaWithWork(); + const result = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + + expect(result.ok).toBe(true); + expect(result.expiresAt).toBe('2026-04-29T00:15:00.000Z'); + }); + + it('rejects caught_up while actionable work remains', () => { + const agenda = agendaWithWork(); + const result = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'caught_up', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + + expect(result).toMatchObject({ + ok: false, + code: 'caught_up_rejected_actionable_items_exist', + }); + }); + + it('rejects blocked without current blocker evidence', () => { + const agenda = agendaWithWork(); + const result = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'blocked', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + + expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' }); + }); + + it('rejects stale fingerprints and foreign task ids', () => { + const agenda = agendaWithWork(); + const stale = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:old', + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + const foreign = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + taskIds: ['other-task'], + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + + expect(stale.code).toBe('stale_fingerprint'); + expect(foreign.code).toBe('foreign_task_id'); + }); + + it('rejects reserved and inactive member identities', () => { + const agenda = agendaWithWork(); + const reserved = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'user', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + }); + const inactive = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: [], + }); + + expect(reserved.code).toBe('reserved_or_invalid_member'); + expect(inactive.code).toBe('member_inactive'); + }); +}); diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts new file mode 100644 index 00000000..3d41a6f8 --- /dev/null +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; + +import { + MemberWorkSyncDiagnosticsReader, + MemberWorkSyncReporter, + type MemberWorkSyncAgendaSourceResult, + type MemberWorkSyncStatusStorePort, + type MemberWorkSyncUseCaseDeps, +} from '@features/member-work-sync/core/application'; +import type { + MemberWorkSyncActionableWorkItem, + MemberWorkSyncReportRequest, + MemberWorkSyncStatus, +} from '@features/member-work-sync/contracts'; + +const workItem: MemberWorkSyncActionableWorkItem = { + taskId: 'task-1', + displayId: '11111111', + subject: 'Ship sync', + kind: 'work', + assignee: 'bob', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { + status: 'pending', + owner: 'bob', + }, +}; + +class MutableClock { + private current = new Date('2026-04-29T00:00:00.000Z'); + + now(): Date { + return this.current; + } + + set(iso: string): void { + this.current = new Date(iso); + } +} + +class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { + readonly writes: MemberWorkSyncStatus[] = []; + readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = []; + + async read(): Promise { + return this.writes.at(-1) ?? null; + } + + async write(status: MemberWorkSyncStatus): Promise { + this.writes.push(status); + } + + async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { + this.pendingReports.push({ request, reason }); + } +} + +function createDeps(options?: { + items?: MemberWorkSyncActionableWorkItem[]; + activeMemberNames?: string[]; + inactive?: boolean; + providerId?: 'opencode' | 'codex'; +}) { + const clock = new MutableClock(); + const store = new InMemoryStatusStore(); + const source: MemberWorkSyncAgendaSourceResult = { + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + items: options?.items ?? [workItem], + diagnostics: [], + }, + activeMemberNames: options?.activeMemberNames ?? ['bob'], + inactive: options?.inactive ?? false, + ...(options?.providerId ? { providerId: options.providerId } : {}), + diagnostics: [], + }; + const deps: MemberWorkSyncUseCaseDeps = { + clock, + hash: { + sha256Hex: (value) => `hash-${value.length}`, + }, + agendaSource: { + loadAgenda: async () => source, + }, + statusStore: store, + reportStore: store, + }; + return { clock, deps, source, store }; +} + +describe('MemberWorkSync use cases', () => { + it('reconciles actionable work into needs_sync without side effects', async () => { + const { deps, store } = createDeps(); + const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + }); + + expect(status.state).toBe('needs_sync'); + expect(status.agenda.items).toEqual([workItem]); + expect(status.diagnostics).toContain('no_current_report'); + expect(store.pendingReports).toEqual([]); + }); + + it('accepts still_working as a bounded lease for the current fingerprint', async () => { + const { clock, deps } = createDeps(); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + const result = await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + taskIds: ['task-1'], + leaseTtlMs: 120_000, + source: 'test', + }); + + expect(result.accepted).toBe(true); + expect(result.status.state).toBe('still_working'); + + clock.set('2026-04-29T00:01:59.000Z'); + expect((await reader.execute({ teamName: 'team-a', memberName: 'bob' })).state).toBe( + 'still_working' + ); + + clock.set('2026-04-29T00:02:00.000Z'); + const expired = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + expect(expired.state).toBe('needs_sync'); + expect(expired.diagnostics).toContain('report_lease_expired'); + }); + + it('rejects stale or unsafe reports and records pending intent only', async () => { + const { deps, store } = createDeps(); + const result = await new MemberWorkSyncReporter(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'caught_up', + agendaFingerprint: 'agenda:v1:stale', + source: 'test', + }); + + expect(result.accepted).toBe(false); + expect(result.code).toBe('stale_fingerprint'); + expect(result.status.state).toBe('needs_sync'); + expect(store.pendingReports).toHaveLength(1); + expect(store.pendingReports[0].reason).toBe('stale_fingerprint'); + }); + + it('accepts caught_up only when the app-side agenda is empty', async () => { + const { deps } = createDeps({ items: [] }); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + const result = await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'caught_up', + agendaFingerprint: current.agenda.fingerprint, + source: 'test', + }); + + expect(result.accepted).toBe(true); + expect(result.status.state).toBe('caught_up'); + }); +}); From b346a1146cce289f1d8709ac67c441163187390a Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 13:26:01 +0300 Subject: [PATCH 06/74] feat(member-work-sync): require report tokens for reports --- agent-teams-controller/src/internal/tasks.js | 6 +- .../src/internal/workSync.js | 1 + .../test/controller.test.js | 4 + mcp-server/src/tools/workSyncTools.ts | 3 + mcp-server/test/tools.test.ts | 4 + .../member-work-sync/contracts/types.ts | 3 + .../application/MemberWorkSyncReconciler.ts | 27 ++- .../application/MemberWorkSyncReporter.ts | 32 +++- .../core/application/ports.ts | 30 +++ .../domain/MemberWorkSyncReportValidator.ts | 19 ++ .../createMemberWorkSyncFeature.ts | 6 +- .../HmacMemberWorkSyncReportTokenAdapter.ts | 171 ++++++++++++++++++ .../MemberWorkSyncStorePaths.ts | 4 + src/main/http/teams.ts | 3 + .../MemberWorkSyncReportValidator.test.ts | 39 ++++ .../core/MemberWorkSyncUseCases.test.ts | 32 ++++ ...acMemberWorkSyncReportTokenAdapter.test.ts | 79 ++++++++ 17 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts create mode 100644 test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index dcb860af..56551a28 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -785,9 +785,9 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa - member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda. They do NOT start, complete, approve, or comment on tasks. - Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment. - When you are about to stop, wait, or go idle because you believe your current work queue is handled, first call member_work_sync_status for yourself. - - If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working" and that exact agendaFingerprint. - - If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task. - - If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint. + - If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working", that exact agendaFingerprint, and the returned reportToken. + - If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task, and include the returned reportToken. + - If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint and the returned reportToken. - Do not report more than once for the same agendaFingerprint unless your state changed. Failure to follow this protocol means the task board will show incorrect status.`); } diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js index 8a39fde9..43c38c90 100644 --- a/agent-teams-controller/src/internal/workSync.js +++ b/agent-teams-controller/src/internal/workSync.js @@ -92,6 +92,7 @@ function compactReportBody(context, memberName, flags = {}) { 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'] } : {}), ...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}), diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 4c45e659..b3240e15 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -2272,6 +2272,8 @@ describe('agent-teams-controller API', () => { items: [], diagnostics: [], }, + reportToken: 'wrs:v1.test.token', + reportTokenExpiresAt: '2026-04-29T00:15:00.000Z', evaluatedAt: '2026-04-29T00:00:00.000Z', diagnostics: ['no_current_report'], }, @@ -2293,6 +2295,7 @@ describe('agent-teams-controller API', () => { memberName: 'bob', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Continuing work', leaseTtlMs: 120000, @@ -2314,6 +2317,7 @@ describe('agent-teams-controller API', () => { memberName: 'bob', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Continuing work', leaseTtlMs: 120000, diff --git a/mcp-server/src/tools/workSyncTools.ts b/mcp-server/src/tools/workSyncTools.ts index 2f085a57..b3b8d122 100644 --- a/mcp-server/src/tools/workSyncTools.ts +++ b/mcp-server/src/tools/workSyncTools.ts @@ -47,6 +47,7 @@ export function registerWorkSyncTools(server: Pick) { from: z.string().min(1).optional(), state: reportStateSchema, agendaFingerprint: z.string().min(1), + reportToken: z.string().min(1), taskIds: z.array(z.string().min(1)).optional(), note: z.string().optional(), leaseTtlMs: z.number().int().min(60000).max(3600000).optional(), @@ -60,6 +61,7 @@ export function registerWorkSyncTools(server: Pick) { from, state, agendaFingerprint, + reportToken, taskIds, note, leaseTtlMs, @@ -71,6 +73,7 @@ export function registerWorkSyncTools(server: Pick) { ...(from ? { from } : {}), state, agendaFingerprint, + reportToken, ...(taskIds ? { taskIds } : {}), ...(note ? { note } : {}), ...(leaseTtlMs ? { leaseTtlMs } : {}), diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 50929a3f..df98328f 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -425,6 +425,8 @@ describe('agent-teams-mcp tools', () => { items: [], diagnostics: [], }, + reportToken: 'wrs:v1.test.token', + reportTokenExpiresAt: '2026-04-29T00:15:00.000Z', evaluatedAt: '2026-04-29T00:00:00.000Z', diagnostics: ['no_current_report'], }, @@ -455,6 +457,7 @@ describe('agent-teams-mcp tools', () => { memberName: 'alice', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Still working', leaseTtlMs: 120000, @@ -476,6 +479,7 @@ describe('agent-teams-mcp tools', () => { memberName: 'alice', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Still working', leaseTtlMs: 120000, diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index 46e49c6e..8c2528f4 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -72,6 +72,8 @@ export interface MemberWorkSyncStatus { state: MemberWorkSyncStatusState; agenda: MemberWorkSyncAgenda; report?: MemberWorkSyncReport; + reportToken?: string; + reportTokenExpiresAt?: string; evaluatedAt: string; diagnostics: string[]; providerId?: MemberWorkSyncProviderId; @@ -82,6 +84,7 @@ export interface MemberWorkSyncReportRequest { memberName: string; state: MemberWorkSyncReportState; agendaFingerprint: string; + reportToken?: string; taskIds?: string[]; note?: string; reportedAt?: string; diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index 29ed8ad3..9ef7519f 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -42,7 +42,7 @@ export class MemberWorkSyncReconciler { inactive: source.inactive, }); - const status: MemberWorkSyncStatus = { + const status = await attachMemberWorkSyncReportToken(this.deps, { teamName: agenda.teamName, memberName: agenda.memberName, state: decision.state, @@ -51,9 +51,32 @@ export class MemberWorkSyncReconciler { evaluatedAt: nowIso, diagnostics: [...agenda.diagnostics, ...decision.diagnostics], ...(source.providerId ? { providerId: source.providerId } : {}), - }; + }); await this.deps.statusStore.write(status); return status; } } + +export async function attachMemberWorkSyncReportToken( + deps: MemberWorkSyncUseCaseDeps, + status: MemberWorkSyncStatus +): Promise { + if (!deps.reportToken) { + return status; + } + + const issued = await deps.reportToken.create({ + teamName: status.teamName, + memberName: status.memberName, + agendaFingerprint: status.agenda.fingerprint, + issuedAt: status.evaluatedAt, + }); + + return { + ...status, + reportToken: issued.token, + reportTokenExpiresAt: issued.expiresAt, + diagnostics: [...status.diagnostics, 'report_token_issued'], + }; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts index b5e00406..06c733ae 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -4,9 +4,21 @@ import type { MemberWorkSyncReportResult, } from '../../contracts'; import { validateMemberWorkSyncReport } from '../domain'; -import { finalizeMemberWorkSyncAgenda, MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; +import { + attachMemberWorkSyncReportToken, + finalizeMemberWorkSyncAgenda, + MemberWorkSyncReconciler, +} from './MemberWorkSyncReconciler'; import type { MemberWorkSyncUseCaseDeps } from './ports'; +const TERMINAL_REPORT_REJECTION_CODES = new Set([ + 'reserved_or_invalid_member', + 'identity_mismatch', + 'member_inactive', + 'identity_untrusted', + 'invalid_report_token', +]); + export class MemberWorkSyncReporter { private readonly reconciler: MemberWorkSyncReconciler; @@ -20,16 +32,28 @@ export class MemberWorkSyncReporter { const nowIso = ( request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now() ).toISOString(); + const tokenValidation = this.deps.reportToken + ? await this.deps.reportToken.verify({ + token: request.reportToken, + teamName: agenda.teamName, + memberName: agenda.memberName, + agendaFingerprint: agenda.fingerprint, + nowIso, + }) + : ({ ok: false, reason: 'missing' } as const); const validation = validateMemberWorkSyncReport({ request, agenda, nowIso, activeMemberNames: source.activeMemberNames, + tokenValidation, }); if (!validation.ok) { const status = await this.reconciler.execute(request); - await this.deps.reportStore?.appendPendingReport?.(request, validation.code); + if (!TERMINAL_REPORT_REJECTION_CODES.has(validation.code)) { + await this.deps.reportStore?.appendPendingReport?.(request, validation.code); + } return { accepted: false, code: validation.code, @@ -51,7 +75,7 @@ export class MemberWorkSyncReporter { accepted: true, }; - const status = { + const status = await attachMemberWorkSyncReportToken(this.deps, { teamName: agenda.teamName, memberName: agenda.memberName, state: @@ -65,7 +89,7 @@ export class MemberWorkSyncReporter { evaluatedAt: nowIso, diagnostics: [...agenda.diagnostics, 'report_accepted'], ...(source.providerId ? { providerId: source.providerId } : {}), - }; + }); await this.deps.statusStore.write(status); return { diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index fb098c20..1df714db 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -14,6 +14,35 @@ export interface MemberWorkSyncHashPort { sha256Hex(value: string): string; } +export interface MemberWorkSyncReportTokenCreateInput { + teamName: string; + memberName: string; + agendaFingerprint: string; + issuedAt: string; +} + +export interface MemberWorkSyncReportTokenVerifyInput { + token?: string; + teamName: string; + memberName: string; + agendaFingerprint: string; + nowIso: string; +} + +export type MemberWorkSyncReportTokenVerification = + | { ok: true } + | { ok: false; reason: 'missing' | 'expired' | 'invalid' }; + +export interface MemberWorkSyncReportTokenPort { + create(input: MemberWorkSyncReportTokenCreateInput): Promise<{ + token: string; + expiresAt: string; + }>; + verify( + input: MemberWorkSyncReportTokenVerifyInput + ): Promise; +} + export interface MemberWorkSyncLoggerPort { debug(message: string, metadata?: Record): void; warn(message: string, metadata?: Record): void; @@ -50,6 +79,7 @@ export interface MemberWorkSyncUseCaseDeps { agendaSource: MemberWorkSyncAgendaSourcePort; statusStore: MemberWorkSyncStatusStorePort; reportStore?: MemberWorkSyncReportStorePort; + reportToken?: MemberWorkSyncReportTokenPort; logger?: MemberWorkSyncLoggerPort; } diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts index dcf5efc2..29b34c2a 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts @@ -12,6 +12,10 @@ export interface MemberWorkSyncReportValidation { expiresAt?: string; } +export type MemberWorkSyncReportTokenValidation = + | { ok: true } + | { ok: false; reason: 'missing' | 'expired' | 'invalid' }; + const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000; const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000; const MIN_LEASE_MS = 60_000; @@ -47,6 +51,7 @@ export function validateMemberWorkSyncReport(input: { agenda: MemberWorkSyncAgenda; nowIso: string; activeMemberNames: string[]; + tokenValidation: MemberWorkSyncReportTokenValidation; }): MemberWorkSyncReportValidation { const memberName = normalizeMemberName(input.request.memberName); const activeMemberNames = new Set(input.activeMemberNames.map(normalizeMemberName)); @@ -71,6 +76,20 @@ export function validateMemberWorkSyncReport(input: { message: 'Report fingerprint is stale. Read current member work sync status and retry.', }; } + if (!input.tokenValidation.ok) { + return input.tokenValidation.reason === 'missing' + ? { + ok: false, + code: 'identity_untrusted', + message: 'Report token is required. Read current member work sync status and retry.', + } + : { + ok: false, + code: 'invalid_report_token', + message: + 'Report token is invalid or expired. Read current member work sync status and retry.', + }; + } const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId)); for (const taskId of input.request.taskIds ?? []) { diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 4c3d6188..d667cf1b 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -6,6 +6,7 @@ import type { } from '../../contracts'; import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncReporter } from '../../core/application'; import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; +import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; @@ -40,13 +41,16 @@ export function createMemberWorkSyncFeature(deps: { hash, clock, }); - const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(deps.teamsBasePath)); + const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath); + const store = new JsonMemberWorkSyncStore(storePaths); + const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const useCaseDeps = { clock, hash, agendaSource, statusStore: store, reportStore: store, + reportToken, logger: deps.logger, }; const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps); diff --git a/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts b/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts new file mode 100644 index 00000000..ad211162 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts @@ -0,0 +1,171 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { mkdir, readFile } from 'node:fs/promises'; + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; + +import type { + MemberWorkSyncReportTokenCreateInput, + MemberWorkSyncReportTokenPort, + MemberWorkSyncReportTokenVerifyInput, + MemberWorkSyncReportTokenVerification, +} from '../../core/application'; +import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; + +const TOKEN_PREFIX = 'wrs:v1'; +const TOKEN_TTL_MS = 15 * 60 * 1000; + +interface SecretFile { + schemaVersion: 1; + secret: string; +} + +interface TokenPayload { + version: 1; + teamName: string; + memberName: string; + agendaFingerprint: string; + expiresAt: string; +} + +function base64UrlEncode(value: string): string { + return Buffer.from(value, 'utf8').toString('base64url'); +} + +function base64UrlDecode(value: string): string { + return Buffer.from(value, 'base64url').toString('utf8'); +} + +function isSecretFile(value: unknown): value is SecretFile { + return ( + value != null && + typeof value === 'object' && + (value as SecretFile).schemaVersion === 1 && + typeof (value as SecretFile).secret === 'string' && + (value as SecretFile).secret.length >= 32 + ); +} + +function isTokenPayload(value: unknown): value is TokenPayload { + return ( + value != null && + typeof value === 'object' && + (value as TokenPayload).version === 1 && + typeof (value as TokenPayload).teamName === 'string' && + typeof (value as TokenPayload).memberName === 'string' && + typeof (value as TokenPayload).agendaFingerprint === 'string' && + typeof (value as TokenPayload).expiresAt === 'string' + ); +} + +function safeEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left); + const rightBytes = Buffer.from(right); + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); +} + +export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncReportTokenPort { + private readonly secretCache = new Map>(); + + constructor(private readonly paths: MemberWorkSyncStorePaths) {} + + async create(input: MemberWorkSyncReportTokenCreateInput): Promise<{ + token: string; + expiresAt: string; + }> { + const expiresAt = new Date(Date.parse(input.issuedAt) + TOKEN_TTL_MS).toISOString(); + const payload: TokenPayload = { + version: 1, + teamName: input.teamName, + memberName: input.memberName, + agendaFingerprint: input.agendaFingerprint, + expiresAt, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signature = await this.sign(input.teamName, encodedPayload); + return { + token: `${TOKEN_PREFIX}.${encodedPayload}.${signature}`, + expiresAt, + }; + } + + async verify( + input: MemberWorkSyncReportTokenVerifyInput + ): Promise { + if (!input.token) { + return { ok: false, reason: 'missing' }; + } + + const [prefix, encodedPayload, signature, extra] = input.token.split('.'); + if (prefix !== TOKEN_PREFIX || !encodedPayload || !signature || extra) { + return { ok: false, reason: 'invalid' }; + } + + const expectedSignature = await this.sign(input.teamName, encodedPayload); + if (!safeEqual(signature, expectedSignature)) { + return { ok: false, reason: 'invalid' }; + } + + let payload: unknown; + try { + payload = JSON.parse(base64UrlDecode(encodedPayload)); + } catch { + return { ok: false, reason: 'invalid' }; + } + if (!isTokenPayload(payload)) { + return { ok: false, reason: 'invalid' }; + } + if ( + payload.teamName !== input.teamName || + payload.memberName !== input.memberName || + payload.agendaFingerprint !== input.agendaFingerprint + ) { + return { ok: false, reason: 'invalid' }; + } + if (Date.parse(payload.expiresAt) <= Date.parse(input.nowIso)) { + return { ok: false, reason: 'expired' }; + } + + return { ok: true }; + } + + private async sign(teamName: string, encodedPayload: string): Promise { + const secret = await this.getSecret(teamName); + return createHmac('sha256', secret).update(encodedPayload).digest('base64url'); + } + + private async getSecret(teamName: string): Promise { + const existing = this.secretCache.get(teamName); + if (existing) { + return existing; + } + + const next = this.loadOrCreateSecret(teamName); + this.secretCache.set(teamName, next); + return next; + } + + private async loadOrCreateSecret(teamName: string): Promise { + try { + const raw = await readFile(this.paths.getReportTokenSecretPath(teamName), 'utf8'); + const parsed = JSON.parse(raw); + if (isSecretFile(parsed)) { + return parsed.secret; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const secretFile: SecretFile = { + schemaVersion: 1, + secret: randomBytes(32).toString('base64url'), + }; + await mkdir(this.paths.getTeamDir(teamName), { recursive: true }); + await atomicWriteAsync( + this.paths.getReportTokenSecretPath(teamName), + JSON.stringify(secretFile, null, 2) + ); + return secretFile.secret; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts index f82f7b4f..79811045 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts @@ -14,4 +14,8 @@ export class MemberWorkSyncStorePaths { getPendingReportsPath(teamName: string): string { return join(this.getTeamDir(teamName), 'pending-reports.jsonl'); } + + getReportTokenSecretPath(teamName: string): string { + return join(this.getTeamDir(teamName), 'report-token-secret.json'); + } } diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 4d67535f..2a93459c 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -813,6 +813,9 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) memberName, state, agendaFingerprint, + ...(typeof payload.reportToken === 'string' + ? { reportToken: payload.reportToken } + : {}), ...(taskIds ? { taskIds } : {}), ...(typeof payload.note === 'string' ? { note: payload.note } : {}), ...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}), diff --git a/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts index 8a7a902a..8116b071 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts @@ -7,6 +7,7 @@ import { const nowIso = '2026-04-29T00:00:00.000Z'; const hash = (value: string) => `h${value.length}`; +const validToken = { ok: true } as const; function agendaWithWork() { return buildActionableWorkAgenda({ @@ -32,6 +33,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); expect(result.ok).toBe(true); @@ -50,6 +52,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); expect(result).toMatchObject({ @@ -70,6 +73,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' }); @@ -87,6 +91,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); const foreign = validateMemberWorkSyncReport({ request: { @@ -99,6 +104,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); expect(stale.code).toBe('stale_fingerprint'); @@ -117,6 +123,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); const inactive = validateMemberWorkSyncReport({ request: { @@ -128,9 +135,41 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: [], + tokenValidation: validToken, }); expect(reserved.code).toBe('reserved_or_invalid_member'); expect(inactive.code).toBe('member_inactive'); }); + + it('rejects missing or invalid report tokens for otherwise current reports', () => { + const agenda = agendaWithWork(); + const missing = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + tokenValidation: { ok: false, reason: 'missing' }, + }); + const invalid = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + tokenValidation: { ok: false, reason: 'invalid' }, + }); + + expect(missing.code).toBe('identity_untrusted'); + expect(invalid.code).toBe('invalid_report_token'); + }); }); diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 3d41a6f8..afa598e9 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -87,6 +87,16 @@ function createDeps(options?: { }, statusStore: store, reportStore: store, + reportToken: { + create: async (input) => ({ + token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`, + expiresAt: '2026-04-29T00:15:00.000Z', + }), + verify: async (input) => + input.token === `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}` + ? { ok: true } + : { ok: false, reason: input.token ? 'invalid' : 'missing' }, + }, }; return { clock, deps, source, store }; } @@ -116,6 +126,7 @@ describe('MemberWorkSync use cases', () => { memberName: 'bob', state: 'still_working', agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, taskIds: ['task-1'], leaseTtlMs: 120_000, source: 'test', @@ -163,10 +174,31 @@ describe('MemberWorkSync use cases', () => { memberName: 'bob', state: 'caught_up', agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, source: 'test', }); expect(result.accepted).toBe(true); expect(result.status.state).toBe('caught_up'); }); + + it('rejects invalid report tokens without recording replayable intents', async () => { + const { deps, store } = createDeps(); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + const result = await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + reportToken: 'token:team-a:alice:wrong', + source: 'test', + }); + + expect(result.accepted).toBe(false); + expect(result.code).toBe('invalid_report_token'); + expect(store.pendingReports).toHaveLength(0); + }); }); diff --git a/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts b/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts new file mode 100644 index 00000000..443e1a67 --- /dev/null +++ b/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { HmacMemberWorkSyncReportTokenAdapter } from '@features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter'; +import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; + +describe('HmacMemberWorkSyncReportTokenAdapter', () => { + let root: string; + let adapter: HmacMemberWorkSyncReportTokenAdapter; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'member-work-sync-token-')); + adapter = new HmacMemberWorkSyncReportTokenAdapter(new MemberWorkSyncStorePaths(root)); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('creates a token bound to team, member, fingerprint, and expiry', async () => { + const issued = await adapter.create({ + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + issuedAt: '2026-04-29T00:00:00.000Z', + }); + + expect(issued.expiresAt).toBe('2026-04-29T00:15:00.000Z'); + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + nowIso: '2026-04-29T00:14:59.000Z', + }) + ).resolves.toEqual({ ok: true }); + }); + + it('rejects copied, stale, and expired tokens', async () => { + const issued = await adapter.create({ + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + issuedAt: '2026-04-29T00:00:00.000Z', + }); + + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'alice', + agendaFingerprint: 'agenda:v1:abc', + nowIso: '2026-04-29T00:01:00.000Z', + }) + ).resolves.toEqual({ ok: false, reason: 'invalid' }); + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:new', + nowIso: '2026-04-29T00:01:00.000Z', + }) + ).resolves.toEqual({ ok: false, reason: 'invalid' }); + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + nowIso: '2026-04-29T00:15:00.000Z', + }) + ).resolves.toEqual({ ok: false, reason: 'expired' }); + }); +}); From 10e405573ee8089115c835d43dc43beefb73887f Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 13:48:23 +0300 Subject: [PATCH 07/74] feat(member-work-sync): add passive event queue --- .../member-work-sync/contracts/types.ts | 9 + .../application/MemberWorkSyncReconciler.ts | 34 ++- .../application/MemberWorkSyncReporter.ts | 17 ++ .../core/application/ports.ts | 5 + .../input/MemberWorkSyncTeamChangeRouter.ts | 113 ++++++++ .../adapters/output/TeamTaskAgendaSource.ts | 15 ++ .../createMemberWorkSyncFeature.ts | 32 ++- .../MemberWorkSyncEventQueue.ts | 245 ++++++++++++++++++ src/main/index.ts | 21 +- .../core/MemberWorkSyncUseCases.test.ts | 46 ++++ .../main/MemberWorkSyncEventQueue.test.ts | 123 +++++++++ .../MemberWorkSyncTeamChangeRouter.test.ts | 70 +++++ 12 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts create mode 100644 src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts create mode 100644 test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts create mode 100644 test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index 8c2528f4..b3a7da68 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -66,6 +66,14 @@ export interface MemberWorkSyncReport { rejectionCode?: string; } +export interface MemberWorkSyncShadowDiagnostics { + reconciledBy: 'request' | 'queue' | 'report'; + wouldNudge: boolean; + fingerprintChanged: boolean; + previousFingerprint?: string; + triggerReasons?: string[]; +} + export interface MemberWorkSyncStatus { teamName: string; memberName: string; @@ -74,6 +82,7 @@ export interface MemberWorkSyncStatus { report?: MemberWorkSyncReport; reportToken?: string; reportTokenExpiresAt?: string; + shadow?: MemberWorkSyncShadowDiagnostics; evaluatedAt: string; diagnostics: string[]; providerId?: MemberWorkSyncProviderId; diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index 9ef7519f..a98d9cf6 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -7,6 +7,11 @@ import { import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports'; +export interface MemberWorkSyncReconcileContext { + reconciledBy?: 'request' | 'queue'; + triggerReasons?: string[]; +} + export function finalizeMemberWorkSyncAgenda( deps: MemberWorkSyncUseCaseDeps, source: MemberWorkSyncAgendaSourceResult @@ -30,16 +35,22 @@ export function finalizeMemberWorkSyncAgenda( export class MemberWorkSyncReconciler { constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} - async execute(request: MemberWorkSyncStatusRequest): Promise { + async execute( + request: MemberWorkSyncStatusRequest, + context: MemberWorkSyncReconcileContext = {} + ): Promise { const source = await this.deps.agendaSource.loadAgenda(request); const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); const previous = await this.deps.statusStore.read(request); const nowIso = this.deps.clock.now().toISOString(); + const teamActive = this.deps.lifecycle + ? await this.deps.lifecycle.isTeamActive(agenda.teamName) + : true; const decision = decideMemberWorkSyncStatus({ agenda, latestAcceptedReport: previous?.report?.accepted ? previous.report : null, nowIso, - inactive: source.inactive, + inactive: source.inactive || !teamActive, }); const status = await attachMemberWorkSyncReportToken(this.deps, { @@ -48,8 +59,25 @@ export class MemberWorkSyncReconciler { state: decision.state, agenda, ...(decision.acceptedReport ? { report: decision.acceptedReport } : {}), + shadow: { + reconciledBy: context.reconciledBy ?? 'request', + wouldNudge: decision.state === 'needs_sync' && agenda.items.length > 0, + fingerprintChanged: + Boolean(previous?.agenda.fingerprint) && + previous?.agenda.fingerprint !== agenda.fingerprint, + ...(previous?.agenda.fingerprint + ? { previousFingerprint: previous.agenda.fingerprint } + : {}), + ...(context.triggerReasons?.length + ? { triggerReasons: [...new Set(context.triggerReasons)].sort() } + : {}), + }, evaluatedAt: nowIso, - diagnostics: [...agenda.diagnostics, ...decision.diagnostics], + diagnostics: [ + ...agenda.diagnostics, + ...(!teamActive ? ['team_runtime_inactive'] : []), + ...decision.diagnostics, + ], ...(source.providerId ? { providerId: source.providerId } : {}), }); diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts index 06c733ae..f04e370f 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -32,6 +32,18 @@ export class MemberWorkSyncReporter { const nowIso = ( request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now() ).toISOString(); + const teamActive = this.deps.lifecycle + ? await this.deps.lifecycle.isTeamActive(agenda.teamName) + : true; + if (!teamActive) { + const status = await this.reconciler.execute(request); + return { + accepted: false, + code: 'team_runtime_inactive', + message: 'Team runtime is not active. Restart the team before reporting work sync state.', + status, + }; + } const tokenValidation = this.deps.reportToken ? await this.deps.reportToken.verify({ token: request.reportToken, @@ -86,6 +98,11 @@ export class MemberWorkSyncReporter { : ('still_working' as const), agenda, report, + shadow: { + reconciledBy: 'report', + wouldNudge: false, + fingerprintChanged: false, + }, evaluatedAt: nowIso, diagnostics: [...agenda.diagnostics, 'report_accepted'], ...(source.providerId ? { providerId: source.providerId } : {}), diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 1df714db..665fafb1 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -43,6 +43,10 @@ export interface MemberWorkSyncReportTokenPort { ): Promise; } +export interface MemberWorkSyncLifecyclePort { + isTeamActive(teamName: string): Promise | boolean; +} + export interface MemberWorkSyncLoggerPort { debug(message: string, metadata?: Record): void; warn(message: string, metadata?: Record): void; @@ -80,6 +84,7 @@ export interface MemberWorkSyncUseCaseDeps { statusStore: MemberWorkSyncStatusStorePort; reportStore?: MemberWorkSyncReportStorePort; reportToken?: MemberWorkSyncReportTokenPort; + lifecycle?: MemberWorkSyncLifecyclePort; logger?: MemberWorkSyncLoggerPort; } diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts new file mode 100644 index 00000000..24a2a899 --- /dev/null +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts @@ -0,0 +1,113 @@ +import type { + MemberWorkSyncEventQueue, + MemberWorkSyncTriggerReason, +} from '../../infrastructure/MemberWorkSyncEventQueue'; +import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; + +interface MemberWorkSyncRosterSource { + loadActiveMemberNames(teamName: string): Promise; +} + +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', +}; + +function parseInboxRecipient(detail: string | undefined): string | null { + if (!detail) { + return null; + } + const match = /^inboxes\/(.+)\.json$/.exec(detail); + return match?.[1]?.trim() || null; +} + +function parseToolActivity(detail: string | undefined): ToolActivityEventPayload | null { + if (!detail) { + return null; + } + try { + const parsed = JSON.parse(detail) as ToolActivityEventPayload; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +export class MemberWorkSyncTeamChangeRouter { + constructor( + private readonly rosterSource: MemberWorkSyncRosterSource, + private readonly queue: MemberWorkSyncEventQueue + ) {} + + async enqueueStartupScan(teamNames: string[]): Promise { + await Promise.allSettled( + teamNames.map((teamName) => this.enqueueTeam(teamName, 'startup_scan', 30_000)) + ); + } + + noteTeamChange(event: TeamChangeEvent): void { + if (event.type === 'lead-activity' && event.detail === 'offline') { + this.queue.dropTeam(event.teamName); + return; + } + + if (event.type === 'member-spawn') { + const memberName = event.detail?.trim(); + if (memberName) { + this.queue.enqueue({ + teamName: event.teamName, + memberName, + triggerReason: 'member_spawned', + runAfterMs: 30_000, + }); + } else { + void this.enqueueTeam(event.teamName, 'member_spawned', 30_000).catch(() => undefined); + } + return; + } + + if (event.type === 'tool-activity') { + const payload = parseToolActivity(event.detail); + if (payload?.action === 'finish' && payload.memberName) { + this.queue.enqueue({ + teamName: event.teamName, + memberName: payload.memberName, + triggerReason: 'tool_finished', + }); + } + return; + } + + if (event.type === 'inbox' || event.type === 'lead-message') { + const recipient = parseInboxRecipient(event.detail); + if (recipient) { + this.queue.enqueue({ + teamName: event.teamName, + memberName: recipient, + triggerReason: 'inbox_changed', + }); + } + return; + } + + const teamWideReason = TEAM_WIDE_REASONS[event.type]; + if (teamWideReason) { + void this.enqueueTeam(event.teamName, teamWideReason).catch(() => undefined); + } + } + + private async enqueueTeam( + teamName: string, + triggerReason: MemberWorkSyncTriggerReason, + runAfterMs?: number + ): Promise { + const activeMembers = await this.rosterSource.loadActiveMemberNames(teamName); + for (const memberName of activeMembers) { + this.queue.enqueue({ teamName, memberName, triggerReason, runAfterMs }); + } + } +} diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts index 33aae5bc..8293f4cd 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts @@ -1,5 +1,6 @@ import { buildActionableWorkAgenda, + isReservedMemberName, normalizeMemberName, type MemberWorkSyncMemberLike, } from '../../../core/domain'; @@ -65,6 +66,20 @@ function toMemberLike(member: TeamMember): MemberWorkSyncMemberLike { export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort { constructor(private readonly deps: TeamTaskAgendaSourceDeps) {} + async loadActiveMemberNames(teamName: string): Promise { + const config = await this.deps.configReader.getConfig(teamName); + if (!config || config.deletedAt) { + return []; + } + + const metaMembers = await this.deps.membersMetaStore.getMembers(teamName); + return mergeMembers(config.members ?? [], metaMembers) + .filter((member) => !member.removedAt) + .map((member) => normalizeMemberName(member.name)) + .filter((memberName) => memberName.length > 0 && !isReservedMemberName(memberName)) + .sort((left, right) => left.localeCompare(right)); + } + async loadAgenda(input: { teamName: string; memberName: string; diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index d667cf1b..decdd310 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -4,9 +4,19 @@ import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest, } from '../../contracts'; -import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncReporter } from '../../core/application'; +import { + MemberWorkSyncDiagnosticsReader, + MemberWorkSyncReconciler, + MemberWorkSyncReporter, + type MemberWorkSyncReconcileContext, +} from '../../core/application'; +import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; +import { + MemberWorkSyncEventQueue, + type MemberWorkSyncQueueDiagnostics, +} from '../infrastructure/MemberWorkSyncEventQueue'; import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; @@ -16,11 +26,16 @@ import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; +import type { TeamChangeEvent } from '@shared/types'; import type { MemberWorkSyncLoggerPort } from '../../core/application'; export interface MemberWorkSyncFeatureFacade { getStatus(request: MemberWorkSyncStatusRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; + noteTeamChange(event: TeamChangeEvent): void; + enqueueStartupScan(teamNames: string[]): Promise; + getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics; + dispose(): Promise; } export function createMemberWorkSyncFeature(deps: { @@ -29,6 +44,7 @@ export function createMemberWorkSyncFeature(deps: { taskReader: TeamTaskReader; kanbanManager: TeamKanbanManager; membersMetaStore: TeamMembersMetaStore; + isTeamActive?: (teamName: string) => Promise | boolean; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -51,13 +67,27 @@ export function createMemberWorkSyncFeature(deps: { statusStore: store, reportStore: store, reportToken, + ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), logger: deps.logger, }; const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps); const reporter = new MemberWorkSyncReporter(useCaseDeps); + const reconciler = new MemberWorkSyncReconciler(useCaseDeps); + const queue = new MemberWorkSyncEventQueue({ + reconcile: async (request, context: MemberWorkSyncReconcileContext) => { + await reconciler.execute(request, context); + }, + isTeamActive: deps.isTeamActive ?? (() => true), + logger: deps.logger, + }); + const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue); return { getStatus: (request) => diagnosticsReader.execute(request), report: (request) => reporter.execute(request), + noteTeamChange: (event) => router.noteTeamChange(event), + enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames), + getQueueDiagnostics: () => queue.getDiagnostics(), + dispose: () => queue.stop(), }; } diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts new file mode 100644 index 00000000..6c50a694 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts @@ -0,0 +1,245 @@ +import type { MemberWorkSyncReconcileContext } from '../../core/application/MemberWorkSyncReconciler'; +import type { MemberWorkSyncLoggerPort } from '../../core/application'; + +export type MemberWorkSyncTriggerReason = + | 'startup_scan' + | 'config_changed' + | 'task_changed' + | 'inbox_changed' + | 'member_spawned' + | 'tool_finished' + | 'runtime_activity' + | 'manual_refresh'; + +export interface MemberWorkSyncQueueDiagnostics { + queued: number; + running: number; + enqueued: number; + coalesced: number; + reconciled: number; + dropped: number; + failed: number; +} + +interface QueueItem { + teamName: string; + memberName: string; + runAt: number; + triggerReasons: Set; +} + +interface RunningItem { + rerunRequested: boolean; + triggerReasons: Set; +} + +export interface MemberWorkSyncEventQueueDeps { + reconcile( + input: { teamName: string; memberName: string }, + context: MemberWorkSyncReconcileContext + ): Promise; + isTeamActive(teamName: string): Promise | boolean; + quietWindowMs?: number; + concurrency?: number; + now?: () => number; + logger?: MemberWorkSyncLoggerPort; +} + +function keyOf(teamName: string, memberName: string): string { + return `${teamName}\0${memberName.trim().toLowerCase()}`; +} + +function unrefTimer(timer: ReturnType): void { + timer.unref?.(); +} + +export class MemberWorkSyncEventQueue { + private readonly items = new Map(); + private readonly running = new Map(); + private readonly inFlight = new Set>(); + private readonly quietWindowMs: number; + private readonly concurrency: number; + private readonly now: () => number; + private timer: ReturnType | null = null; + private stopped = false; + private counters = { + enqueued: 0, + coalesced: 0, + reconciled: 0, + dropped: 0, + failed: 0, + }; + + constructor(private readonly deps: MemberWorkSyncEventQueueDeps) { + this.quietWindowMs = deps.quietWindowMs ?? 90_000; + this.concurrency = Math.max(1, deps.concurrency ?? 2); + this.now = deps.now ?? Date.now; + } + + enqueue(input: { + teamName: string; + memberName: string; + triggerReason: MemberWorkSyncTriggerReason; + runAfterMs?: number; + }): void { + if (this.stopped) { + return; + } + + const memberName = input.memberName.trim(); + if (!input.teamName.trim() || !memberName) { + this.counters.dropped += 1; + return; + } + + const key = keyOf(input.teamName, memberName); + const runAt = this.now() + (input.runAfterMs ?? this.quietWindowMs); + const running = this.running.get(key); + if (running) { + running.rerunRequested = true; + running.triggerReasons.add(input.triggerReason); + this.counters.coalesced += 1; + return; + } + + const existing = this.items.get(key); + if (existing) { + existing.triggerReasons.add(input.triggerReason); + existing.runAt = Math.max(existing.runAt, runAt); + this.counters.coalesced += 1; + this.schedule(); + return; + } + + this.items.set(key, { + teamName: input.teamName, + memberName, + runAt, + triggerReasons: new Set([input.triggerReason]), + }); + this.counters.enqueued += 1; + this.schedule(); + } + + dropTeam(teamName: string): void { + for (const [key, item] of this.items) { + if (item.teamName === teamName) { + this.items.delete(key); + this.counters.dropped += 1; + } + } + this.schedule(); + } + + getDiagnostics(): MemberWorkSyncQueueDiagnostics { + return { + queued: this.items.size, + running: this.running.size, + ...this.counters, + }; + } + + async stop(): Promise { + this.stopped = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.items.clear(); + await Promise.allSettled([...this.inFlight]); + } + + private schedule(): void { + if (this.stopped) { + return; + } + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (this.items.size === 0) { + return; + } + if (this.running.size >= this.concurrency) { + return; + } + + const nextRunAt = Math.min(...[...this.items.values()].map((item) => item.runAt)); + const delayMs = Math.max(0, nextRunAt - this.now()); + this.timer = setTimeout(() => { + this.timer = null; + this.pump(); + }, delayMs); + unrefTimer(this.timer); + } + + private pump(): void { + if (this.stopped) { + return; + } + + const due = [...this.items.entries()] + .filter(([, item]) => item.runAt <= this.now()) + .sort((left, right) => left[1].runAt - right[1].runAt); + + for (const [key, item] of due) { + if (this.running.size >= this.concurrency) { + break; + } + this.items.delete(key); + this.runItem(key, item); + } + + this.schedule(); + } + + private runItem(key: string, item: QueueItem): void { + const running: RunningItem = { + rerunRequested: false, + triggerReasons: new Set(item.triggerReasons), + }; + this.running.set(key, running); + + const promise = this.executeItem(key, item, running) + .catch((error: unknown) => { + this.counters.failed += 1; + this.deps.logger?.warn('member work sync queue reconcile failed', { + teamName: item.teamName, + memberName: item.memberName, + error: String(error), + }); + }) + .finally(() => { + 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.pump(); + }); + + this.inFlight.add(promise); + } + + private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise { + if (!(await this.deps.isTeamActive(item.teamName))) { + this.counters.dropped += 1; + return; + } + + await this.deps.reconcile( + { teamName: item.teamName, memberName: item.memberName }, + { + reconciledBy: 'queue', + triggerReasons: [...running.triggerReasons].sort(), + } + ); + this.counters.reconciled += 1; + } +} diff --git a/src/main/index.ts b/src/main/index.ts index f7669d44..399c8f2c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -567,7 +567,7 @@ let codexAccountFeature: CodexAccountFeatureFacade | null = null; let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null; let recentProjectsFeature: RecentProjectsFeatureFacade; let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade; -let memberWorkSyncFeature: MemberWorkSyncFeatureFacade; +let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let cliInstallerService: CliInstallerService; @@ -787,6 +787,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return; const teamName = row.teamName.trim(); const detail = typeof row.detail === 'string' ? row.detail : ''; + memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); if ( teamDataService && @@ -1182,6 +1183,7 @@ async function initializeServices(): Promise { const teamChangeEmitter = (event: TeamChangeEvent): void => { forwardTeamChange(event); teamTaskStallMonitor?.noteTeamChange(event); + memberWorkSyncFeature?.noteTeamChange(event); if (event.type === 'lead-activity' && event.detail === 'offline') { teammateToolTracker?.handleTeamOffline(event.teamName); } @@ -1231,8 +1233,21 @@ async function initializeServices(): Promise { taskReader: new TeamTaskReader(), kanbanManager: new TeamKanbanManager(), membersMetaStore: new TeamMembersMetaStore(), + isTeamActive: (teamName) => + teamProvisioningService.isTeamAlive(teamName) || + teamProvisioningService.hasProvisioningRun(teamName), logger: createLogger('Feature:MemberWorkSync'), }); + void teamDataService + .listTeams() + .then((teams) => + memberWorkSyncFeature?.enqueueStartupScan( + teams.filter((team) => !team.deletedAt).map((team) => team.teamName) + ) + ) + .catch((error: unknown) => + logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`) + ); codexAccountFeature = createCodexAccountFeature({ logger: createLogger('Feature:CodexAccount'), configManager, @@ -1354,7 +1369,7 @@ async function startHttpServer( chunkBuilder: activeContext.chunkBuilder, dataCache: activeContext.dataCache, recentProjectsFeature, - memberWorkSyncFeature, + memberWorkSyncFeature: memberWorkSyncFeature ?? undefined, updaterService, sshConnectionManager, teamDataService, @@ -1477,6 +1492,8 @@ async function shutdownServices(): Promise { codexModelCatalogFeature = null; await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose()); codexAccountFeature = null; + await runShutdownStep('member work sync dispose', () => memberWorkSyncFeature?.dispose()); + memberWorkSyncFeature = null; if (ptyTerminalService) { await runShutdownStep('PTY terminals kill', () => ptyTerminalService.killAll()); diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index afa598e9..2999fc71 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -60,6 +60,7 @@ function createDeps(options?: { items?: MemberWorkSyncActionableWorkItem[]; activeMemberNames?: string[]; inactive?: boolean; + teamActive?: boolean; providerId?: 'opencode' | 'codex'; }) { const clock = new MutableClock(); @@ -97,6 +98,9 @@ function createDeps(options?: { ? { ok: true } : { ok: false, reason: input.token ? 'invalid' : 'missing' }, }, + lifecycle: { + isTeamActive: () => options?.teamActive ?? true, + }, }; return { clock, deps, source, store }; } @@ -112,6 +116,12 @@ describe('MemberWorkSync use cases', () => { expect(status.state).toBe('needs_sync'); expect(status.agenda.items).toEqual([workItem]); expect(status.diagnostics).toContain('no_current_report'); + expect(status.reportToken).toBe(`token:team-a:bob:${status.agenda.fingerprint}`); + expect(status.shadow).toMatchObject({ + reconciledBy: 'request', + wouldNudge: true, + fingerprintChanged: false, + }); expect(store.pendingReports).toEqual([]); }); @@ -134,6 +144,7 @@ describe('MemberWorkSync use cases', () => { expect(result.accepted).toBe(true); expect(result.status.state).toBe('still_working'); + expect(result.status.shadow).toMatchObject({ reconciledBy: 'report', wouldNudge: false }); clock.set('2026-04-29T00:01:59.000Z'); expect((await reader.execute({ teamName: 'team-a', memberName: 'bob' })).state).toBe( @@ -182,6 +193,41 @@ describe('MemberWorkSync use cases', () => { expect(result.status.state).toBe('caught_up'); }); + it('marks status inactive when the team runtime is not active', async () => { + const { deps } = createDeps({ teamActive: false }); + const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + }); + + expect(status.state).toBe('inactive'); + expect(status.diagnostics).toContain('team_runtime_inactive'); + expect(status.shadow?.wouldNudge).toBe(false); + }); + + it('records fingerprint transitions without treating them as progress proof', async () => { + const { deps, source } = createDeps(); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + source.agenda.items = [ + { + ...workItem, + taskId: 'task-2', + displayId: '22222222', + subject: 'New work', + }, + ]; + const changed = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + expect(changed.shadow).toMatchObject({ + fingerprintChanged: true, + wouldNudge: true, + }); + expect(changed.shadow?.previousFingerprint).toMatch(/^agenda:v1:/); + expect(changed.state).toBe('needs_sync'); + }); + it('rejects invalid report tokens without recording replayable intents', async () => { const { deps, store } = createDeps(); const reader = new MemberWorkSyncDiagnosticsReader(deps); diff --git a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts new file mode 100644 index 00000000..dce25bb0 --- /dev/null +++ b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MemberWorkSyncEventQueue } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue'; + +describe('MemberWorkSyncEventQueue', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('coalesces duplicate member events into one queue reconcile', async () => { + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + quietWindowMs: 100, + reconcile: async (request, context) => { + reconciles.push({ request, context }); + }, + isTeamActive: () => true, + }); + + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'inbox_changed' }); + + await vi.advanceTimersByTimeAsync(100); + + expect(reconciles).toHaveLength(1); + expect(reconciles[0]).toMatchObject({ + request: { teamName: 'team-a', memberName: 'bob' }, + context: { + reconciledBy: 'queue', + triggerReasons: ['inbox_changed', 'task_changed'], + }, + }); + expect(queue.getDiagnostics()).toMatchObject({ reconciled: 1, coalesced: 1 }); + await queue.stop(); + }); + + it('drops queued work for inactive teams without reconciling', async () => { + const reconcile = vi.fn(); + const queue = new MemberWorkSyncEventQueue({ + quietWindowMs: 1, + reconcile, + isTeamActive: () => false, + }); + + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); + await vi.advanceTimersByTimeAsync(1); + + expect(reconcile).not.toHaveBeenCalled(); + expect(queue.getDiagnostics()).toMatchObject({ dropped: 1, reconciled: 0 }); + await queue.stop(); + }); + + it('runs one follow-up pass when events arrive during an active reconcile', async () => { + let release: () => void = () => { + throw new Error('reconcile did not start'); + }; + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + quietWindowMs: 1, + 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: 'task_changed' }); + await vi.advanceTimersByTimeAsync(1); + queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'tool_finished' }); + + release(); + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(2); + expect(reconciles[1]).toMatchObject({ + context: { reconciledBy: 'queue', triggerReasons: ['task_changed', 'tool_finished'] }, + }); + await queue.stop(); + }); + + it('does not spin timers while concurrency is saturated', async () => { + let release: () => void = () => { + throw new Error('reconcile did not start'); + }; + const reconciles: unknown[] = []; + const queue = new MemberWorkSyncEventQueue({ + quietWindowMs: 1, + concurrency: 1, + reconcile: async (request) => { + reconciles.push(request); + 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(1); + await vi.advanceTimersByTimeAsync(1_000); + + expect(reconciles).toHaveLength(1); + expect(queue.getDiagnostics()).toMatchObject({ queued: 1, running: 1 }); + + release(); + await vi.advanceTimersByTimeAsync(1); + + expect(reconciles).toHaveLength(2); + await queue.stop(); + }); +}); diff --git a/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts new file mode 100644 index 00000000..f1b3421e --- /dev/null +++ b/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MemberWorkSyncTeamChangeRouter } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter'; + +function createRouter(activeMembers: string[] = ['alice', 'bob']) { + const queue = { + enqueue: vi.fn(), + dropTeam: vi.fn(), + }; + const router = new MemberWorkSyncTeamChangeRouter( + { + loadActiveMemberNames: async () => activeMembers, + }, + queue as never + ); + return { queue, router }; +} + +describe('MemberWorkSyncTeamChangeRouter', () => { + it('routes task and config events to all active members', async () => { + const { queue, router } = createRouter(); + + router.noteTeamChange({ type: 'task', teamName: 'team-a', detail: 'task-1.json' }); + await Promise.resolve(); + + expect(queue.enqueue).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'alice', + triggerReason: 'task_changed', + runAfterMs: undefined, + }); + expect(queue.enqueue).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'bob', + triggerReason: 'task_changed', + runAfterMs: undefined, + }); + }); + + it('routes inbox and tool-finish events to the addressed member only', () => { + const { queue, router } = createRouter(); + + router.noteTeamChange({ type: 'inbox', teamName: 'team-a', detail: 'inboxes/bob.json' }); + router.noteTeamChange({ + type: 'tool-activity', + teamName: 'team-a', + detail: JSON.stringify({ action: 'finish', memberName: 'alice', toolUseId: 'tool-1' }), + }); + + expect(queue.enqueue).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'bob', + triggerReason: 'inbox_changed', + }); + expect(queue.enqueue).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'alice', + triggerReason: 'tool_finished', + }); + }); + + it('drops queued work when the team goes offline', () => { + const { queue, router } = createRouter(); + + router.noteTeamChange({ type: 'lead-activity', teamName: 'team-a', detail: 'offline' }); + + expect(queue.dropTeam).toHaveBeenCalledWith('team-a'); + expect(queue.enqueue).not.toHaveBeenCalled(); + }); +}); From 253ddf293db73afa4aeedc4a203ad055f659ce5a Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:02:49 +0300 Subject: [PATCH 08/74] feat(member-work-sync): replay pending report intents --- .../src/internal/workSync.js | 146 +++++++++++++++-- .../test/controller.test.js | 68 ++++++++ .../member-work-sync/contracts/types.ts | 14 ++ ...mberWorkSyncPendingReportIntentReplayer.ts | 81 ++++++++++ .../application/MemberWorkSyncReporter.ts | 15 +- .../core/application/index.ts | 1 + .../core/application/ports.ts | 8 + .../createMemberWorkSyncFeature.ts | 22 +++ .../infrastructure/JsonMemberWorkSyncStore.ts | 152 ++++++++++++++++-- .../MemberWorkSyncStorePaths.ts | 2 +- src/main/index.ts | 10 +- .../core/MemberWorkSyncUseCases.test.ts | 82 +++++++++- .../main/JsonMemberWorkSyncStore.test.ts | 72 +++++++++ 13 files changed, 630 insertions(+), 43 deletions(-) create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncPendingReportIntentReplayer.ts create mode 100644 test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js index 43c38c90..63036fc6 100644 --- a/agent-teams-controller/src/internal/workSync.js +++ b/agent-teams-controller/src/internal/workSync.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const runtimeHelpers = require('./runtimeHelpers.js'); const DEFAULT_WAIT_TIMEOUT_MS = 10000; @@ -66,7 +67,9 @@ async function requestJson(baseUrl, pathname, options = {}) { payload && typeof payload.error === 'string' && payload.error.trim() ? payload.error.trim() : `${response.status} ${response.statusText}`.trim(); - throw new Error(detail || 'Team control API request failed'); + const error = new Error(detail || 'Team control API request failed'); + error.controlApiStatus = response.status; + throw error; } return payload; } finally { @@ -80,6 +83,9 @@ async function requestJsonWithFallback(baseUrls, pathname, options = {}) { try { return await requestJson(baseUrl, pathname, options); } catch (error) { + if (error && error.controlApiStatus) { + throw error; + } lastError = error; } } @@ -103,6 +109,111 @@ function compactReportBody(context, memberName, flags = {}) { }; } +function stableStringify(value) { + if (value == null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; +} + +function buildPendingIntentId(body) { + const taskIds = Array.isArray(body.taskIds) + ? Array.from(new Set(body.taskIds.map((taskId) => String(taskId)).filter(Boolean))).sort() + : []; + const payload = { + teamName: body.teamName, + memberName: String(body.memberName || '').trim().toLowerCase(), + state: body.state, + agendaFingerprint: body.agendaFingerprint, + reportToken: body.reportToken || '', + ...(taskIds.length > 0 ? { taskIds } : {}), + ...(body.note ? { note: body.note } : {}), + ...(body.leaseTtlMs ? { leaseTtlMs: body.leaseTtlMs } : {}), + ...(body.source ? { source: body.source } : {}), + }; + return `member-work-sync-intent:${crypto + .createHash('sha256') + .update(stableStringify(payload)) + .digest('hex')}`; +} + +function readPendingReportFile(filePath) { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if ( + parsed && + typeof parsed === 'object' && + parsed.schemaVersion === 1 && + parsed.intents && + typeof parsed.intents === 'object' && + !Array.isArray(parsed.intents) + ) { + return parsed; + } + } catch (error) { + if (!error || error.code !== 'ENOENT') { + throw error; + } + } + return { schemaVersion: 1, intents: {} }; +} + +function writePendingReportFile(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); + fs.renameSync(tempPath, filePath); +} + +function appendPendingReportIntent(context, body, reason) { + const filePath = path.join(context.paths.teamDir, '.member-work-sync', 'pending-reports.json'); + const data = readPendingReportFile(filePath); + const request = { + ...body, + source: 'mcp', + }; + const id = buildPendingIntentId(request); + const current = data.intents[id]; + if (!current || current.status === 'pending') { + data.intents[id] = { + id, + teamName: body.teamName, + memberName: body.memberName, + request, + reason, + status: 'pending', + recordedAt: current && current.recordedAt ? current.recordedAt : new Date().toISOString(), + }; + writePendingReportFile(filePath, data); + } + return { + accepted: false, + pendingValidation: true, + code: 'pending_validation', + message: + 'Member work sync report was recorded for app validation. Continue concrete task work; do not treat this as a confirmed lease yet.', + intentId: id, + }; +} + +function assertReportBody(body) { + if (!body.state || !['still_working', 'blocked', 'caught_up'].includes(body.state)) { + throw new Error('state must be still_working, blocked, or caught_up'); + } + if (!body.agendaFingerprint) { + throw new Error('agendaFingerprint is required'); + } + if (!body.reportToken) { + throw new Error('reportToken is required'); + } +} + async function memberWorkSyncStatus(context, flags = {}) { const memberName = runtimeHelpers.assertExplicitTeamMemberName( context.paths, @@ -125,16 +236,31 @@ async function memberWorkSyncReport(context, flags = {}) { flags.memberName || flags.member || flags.from, 'member work sync report member' ); - const baseUrls = resolveControlBaseUrls(context, flags); - return requestJsonWithFallback( - baseUrls, - `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`, - { - method: 'POST', - body: compactReportBody(context, memberName, flags), - timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']), + const body = compactReportBody(context, memberName, flags); + assertReportBody(body); + + const pathname = `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`; + const options = { + method: 'POST', + body, + timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']), + }; + + let baseUrls; + try { + baseUrls = resolveControlBaseUrls(context, flags); + } catch { + return appendPendingReportIntent(context, body, 'control_api_unavailable'); + } + + try { + return await requestJsonWithFallback(baseUrls, pathname, options); + } catch (error) { + if (error && error.controlApiStatus) { + throw error; } - ); + return appendPendingReportIntent(context, body, 'control_api_unavailable'); + } } module.exports = { diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index b3240e15..6a6a32b7 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -2329,6 +2329,74 @@ describe('agent-teams-controller API', () => { } }); + it('records member work sync report intents only when the app validator is unavailable', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + const pending = await controller.workSync.memberWorkSyncReport({ + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', + taskIds: ['task-1'], + }); + + expect(pending.pendingValidation).toBe(true); + expect(pending.accepted).toBe(false); + + const intentFile = path.join( + claudeDir, + 'teams', + 'my-team', + '.member-work-sync', + 'pending-reports.json' + ); + const intents = JSON.parse(fs.readFileSync(intentFile, 'utf8')); + expect(Object.values(intents.intents)).toEqual([ + expect.objectContaining({ + teamName: 'my-team', + memberName: 'bob', + reason: 'control_api_unavailable', + status: 'pending', + request: expect.objectContaining({ + memberName: 'bob', + source: 'mcp', + reportToken: 'wrs:v1.test.token', + }), + }), + ]); + }); + + it('does not record pending work sync intents for app-side validation rejections', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + + const server = await startControlServer(async () => ({ + statusCode: 400, + body: { error: 'stale_fingerprint' }, + })); + + try { + await expect( + controller.workSync.memberWorkSyncReport({ + controlUrl: server.baseUrl, + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:stale', + reportToken: 'wrs:v1.test.token', + }) + ).rejects.toThrow('stale_fingerprint'); + + expect( + fs.existsSync( + path.join(claudeDir, 'teams', 'my-team', '.member-work-sync', 'pending-reports.json') + ) + ).toBe(false); + } finally { + await server.close(); + } + }); + it('prefers the published control endpoint over a stale env URL', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index b3a7da68..e98fbe92 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -66,6 +66,20 @@ export interface MemberWorkSyncReport { rejectionCode?: string; } +export type MemberWorkSyncReportIntentStatus = 'pending' | 'accepted' | 'rejected' | 'superseded'; + +export interface MemberWorkSyncReportIntent { + id: string; + teamName: string; + memberName: string; + request: MemberWorkSyncReportRequest; + reason: string; + status: MemberWorkSyncReportIntentStatus; + recordedAt: string; + processedAt?: string; + resultCode?: string; +} + export interface MemberWorkSyncShadowDiagnostics { reconciledBy: 'request' | 'queue' | 'report'; wouldNudge: boolean; diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncPendingReportIntentReplayer.ts b/src/features/member-work-sync/core/application/MemberWorkSyncPendingReportIntentReplayer.ts new file mode 100644 index 00000000..bf6ae37c --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncPendingReportIntentReplayer.ts @@ -0,0 +1,81 @@ +import type { MemberWorkSyncReportIntentStatus } from '../../contracts'; +import { MemberWorkSyncReporter } from './MemberWorkSyncReporter'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +export interface MemberWorkSyncPendingReportReplaySummary { + processed: number; + accepted: number; + rejected: number; + superseded: number; +} + +function statusForResult(input: { + accepted: boolean; + code: string; +}): MemberWorkSyncReportIntentStatus { + if (input.accepted) { + return 'accepted'; + } + if (input.code === 'member_inactive' || input.code === 'team_runtime_inactive') { + return 'superseded'; + } + return 'rejected'; +} + +export class MemberWorkSyncPendingReportIntentReplayer { + private readonly reporter: MemberWorkSyncReporter; + + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) { + this.reporter = new MemberWorkSyncReporter(deps); + } + + async replayTeam(teamName: string): Promise { + const store = this.deps.reportStore; + if (!store?.listPendingReports || !store.markPendingReportProcessed) { + return { processed: 0, accepted: 0, rejected: 0, superseded: 0 }; + } + + const intents = await store.listPendingReports(teamName); + const summary: MemberWorkSyncPendingReportReplaySummary = { + processed: 0, + accepted: 0, + rejected: 0, + superseded: 0, + }; + + for (const intent of intents) { + let status: MemberWorkSyncReportIntentStatus = 'rejected'; + let resultCode = 'replay_failed'; + try { + const result = await this.reporter.execute({ + ...intent.request, + source: intent.request.source ?? 'mcp', + }); + status = statusForResult(result); + resultCode = result.code; + } catch (error) { + this.deps.logger?.warn('member work sync pending report replay failed', { + teamName, + intentId: intent.id, + error: String(error), + }); + continue; + } + summary.processed += 1; + if (status === 'accepted') { + summary.accepted += 1; + } else if (status === 'superseded') { + summary.superseded += 1; + } else { + summary.rejected += 1; + } + await store.markPendingReportProcessed(teamName, intent.id, { + status, + resultCode, + processedAt: this.deps.clock.now().toISOString(), + }); + } + + return summary; + } +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts index f04e370f..3e401d5a 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -11,14 +11,6 @@ import { } from './MemberWorkSyncReconciler'; import type { MemberWorkSyncUseCaseDeps } from './ports'; -const TERMINAL_REPORT_REJECTION_CODES = new Set([ - 'reserved_or_invalid_member', - 'identity_mismatch', - 'member_inactive', - 'identity_untrusted', - 'invalid_report_token', -]); - export class MemberWorkSyncReporter { private readonly reconciler: MemberWorkSyncReconciler; @@ -29,9 +21,7 @@ export class MemberWorkSyncReporter { async execute(request: MemberWorkSyncReportRequest): Promise { const source = await this.deps.agendaSource.loadAgenda(request); const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); - const nowIso = ( - request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now() - ).toISOString(); + const nowIso = this.deps.clock.now().toISOString(); const teamActive = this.deps.lifecycle ? await this.deps.lifecycle.isTeamActive(agenda.teamName) : true; @@ -63,9 +53,6 @@ export class MemberWorkSyncReporter { if (!validation.ok) { const status = await this.reconciler.execute(request); - if (!TERMINAL_REPORT_REJECTION_CODES.has(validation.code)) { - await this.deps.reportStore?.appendPendingReport?.(request, validation.code); - } return { accepted: false, code: validation.code, diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index ec75f2b2..6e5a81aa 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -1,4 +1,5 @@ export * from './MemberWorkSyncDiagnosticsReader'; +export * from './MemberWorkSyncPendingReportIntentReplayer'; export * from './MemberWorkSyncReconciler'; export * from './MemberWorkSyncReporter'; export * from './ports'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 665fafb1..32aacc1f 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -2,6 +2,8 @@ import type { MemberWorkSyncAgenda, MemberWorkSyncProviderId, MemberWorkSyncReport, + MemberWorkSyncReportIntent, + MemberWorkSyncReportIntentStatus, MemberWorkSyncReportRequest, MemberWorkSyncStatus, } from '../../contracts'; @@ -75,6 +77,12 @@ export interface MemberWorkSyncStatusStorePort { export interface MemberWorkSyncReportStorePort { appendPendingReport?(request: MemberWorkSyncReportRequest, reason: string): Promise; + listPendingReports?(teamName: string): Promise; + markPendingReportProcessed?( + teamName: string, + id: string, + result: { status: MemberWorkSyncReportIntentStatus; resultCode: string; processedAt: string } + ): Promise; } export interface MemberWorkSyncUseCaseDeps { diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index decdd310..ffdbf949 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -6,6 +6,8 @@ import type { } from '../../contracts'; import { MemberWorkSyncDiagnosticsReader, + MemberWorkSyncPendingReportIntentReplayer, + type MemberWorkSyncPendingReportReplaySummary, MemberWorkSyncReconciler, MemberWorkSyncReporter, type MemberWorkSyncReconcileContext, @@ -34,6 +36,7 @@ export interface MemberWorkSyncFeatureFacade { report(request: MemberWorkSyncReportRequest): Promise; noteTeamChange(event: TeamChangeEvent): void; enqueueStartupScan(teamNames: string[]): Promise; + replayPendingReports(teamNames: string[]): Promise; getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics; dispose(): Promise; } @@ -73,6 +76,7 @@ export function createMemberWorkSyncFeature(deps: { const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps); const reporter = new MemberWorkSyncReporter(useCaseDeps); const reconciler = new MemberWorkSyncReconciler(useCaseDeps); + const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps); const queue = new MemberWorkSyncEventQueue({ reconcile: async (request, context: MemberWorkSyncReconcileContext) => { await reconciler.execute(request, context); @@ -87,6 +91,24 @@ export function createMemberWorkSyncFeature(deps: { report: (request) => reporter.execute(request), noteTeamChange: (event) => router.noteTeamChange(event), enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames), + replayPendingReports: async (teamNames) => { + const summaries = await Promise.allSettled( + teamNames.map((teamName) => pendingReportReplayer.replayTeam(teamName)) + ); + return summaries.reduce( + (accumulator, summary) => { + if (summary.status !== 'fulfilled') { + return accumulator; + } + accumulator.processed += summary.value.processed; + accumulator.accepted += summary.value.accepted; + accumulator.rejected += summary.value.rejected; + accumulator.superseded += summary.value.superseded; + return accumulator; + }, + { processed: 0, accepted: 0, rejected: 0, superseded: 0 } + ); + }, getQueueDiagnostics: () => queue.getDiagnostics(), dispose: () => queue.stop(), }; diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index 9fa9954f..f5329e4d 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -1,7 +1,12 @@ import { atomicWriteAsync } from '@main/utils/atomicWrite'; -import { mkdir, readFile, appendFile } from 'fs/promises'; +import { createHash } from 'crypto'; +import { mkdir, readFile, rename } from 'fs/promises'; -import type { MemberWorkSyncReportRequest, MemberWorkSyncStatus } from '../../contracts'; +import type { + MemberWorkSyncReportIntent, + MemberWorkSyncReportRequest, + MemberWorkSyncStatus, +} from '../../contracts'; import type { MemberWorkSyncReportStorePort, MemberWorkSyncStatusStorePort, @@ -13,6 +18,11 @@ interface StoreFile { members: Record; } +interface PendingReportFile { + schemaVersion: 1; + intents: Record; +} + function normalizeMemberKey(memberName: string): string { return memberName.trim().toLowerCase(); } @@ -28,6 +38,57 @@ function isStoreFile(value: unknown): value is StoreFile { ); } +function isPendingReportFile(value: unknown): value is PendingReportFile { + return ( + value != null && + typeof value === 'object' && + (value as PendingReportFile).schemaVersion === 1 && + (value as PendingReportFile).intents != null && + typeof (value as PendingReportFile).intents === 'object' && + !Array.isArray((value as PendingReportFile).intents) + ); +} + +function stableStringify(value: unknown): string { + if (value == null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}`; +} + +function buildPendingReportIntentId(request: MemberWorkSyncReportRequest): string { + const taskIds = [...new Set(request.taskIds ?? [])].sort(); + const payload = { + teamName: request.teamName, + memberName: normalizeMemberKey(request.memberName), + state: request.state, + agendaFingerprint: request.agendaFingerprint, + reportToken: request.reportToken ?? '', + ...(taskIds.length > 0 ? { taskIds } : {}), + ...(request.note ? { note: request.note } : {}), + ...(request.leaseTtlMs ? { leaseTtlMs: request.leaseTtlMs } : {}), + ...(request.source ? { source: request.source } : {}), + }; + return `member-work-sync-intent:${createHash('sha256') + .update(stableStringify(payload)) + .digest('hex')}`; +} + +async function quarantineFile(filePath: string): Promise { + try { + await rename(filePath, `${filePath}.invalid.${Date.now()}`); + } catch { + // If quarantine fails, keep the feature degraded but do not block team operation. + } +} + export class JsonMemberWorkSyncStore implements MemberWorkSyncStatusStorePort, MemberWorkSyncReportStorePort { @@ -56,29 +117,100 @@ export class JsonMemberWorkSyncStore } async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { - await mkdir(this.paths.getTeamDir(request.teamName), { recursive: true }); - await appendFile( - this.paths.getPendingReportsPath(request.teamName), - `${JSON.stringify({ schemaVersion: 1, reason, request, recordedAt: new Date().toISOString() })}\n`, - 'utf8' - ); + const id = buildPendingReportIntentId(request); + await this.enqueue(request.teamName, async () => { + const existing = await this.readPendingFile(request.teamName); + const current = existing.intents[id]; + if (current && current.status !== 'pending') { + return; + } + existing.intents[id] = { + id, + teamName: request.teamName, + memberName: request.memberName, + request, + reason: current?.reason ?? reason, + status: 'pending', + recordedAt: current?.recordedAt ?? new Date().toISOString(), + }; + await this.writePendingFile(request.teamName, existing); + }); + } + + async listPendingReports(teamName: string): Promise { + const file = await this.readPendingFile(teamName); + return Object.values(file.intents) + .filter((intent) => intent.status === 'pending') + .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); + } + + async markPendingReportProcessed( + teamName: string, + id: string, + result: { + status: MemberWorkSyncReportIntent['status']; + resultCode: string; + processedAt: string; + } + ): Promise { + await this.enqueue(teamName, async () => { + const existing = await this.readPendingFile(teamName); + const current = existing.intents[id]; + if (!current || current.status !== 'pending') { + return; + } + existing.intents[id] = { + ...current, + status: result.status, + resultCode: result.resultCode, + processedAt: result.processedAt, + }; + await this.writePendingFile(teamName, existing); + }); } private async readFile(teamName: string): Promise { + const filePath = this.paths.getStatusPath(teamName); try { - const raw = await readFile(this.paths.getStatusPath(teamName), 'utf8'); + const raw = await readFile(filePath, 'utf8'); const parsed = JSON.parse(raw); if (isStoreFile(parsed)) { return parsed; } + await quarantineFile(filePath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; + await quarantineFile(filePath); } } return { schemaVersion: 1, members: {} }; } + private async readPendingFile(teamName: string): Promise { + const filePath = this.paths.getPendingReportsPath(teamName); + try { + const raw = await readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw); + if (isPendingReportFile(parsed)) { + return parsed; + } + await quarantineFile(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + await quarantineFile(filePath); + } + } + return { schemaVersion: 1, intents: {} }; + } + + private async writePendingFile(teamName: string, file: PendingReportFile): Promise { + await mkdir(this.paths.getTeamDir(teamName), { recursive: true }); + await atomicWriteAsync( + this.paths.getPendingReportsPath(teamName), + JSON.stringify(file, null, 2) + ); + } + private async enqueue(teamName: string, operation: () => Promise): Promise { const previous = this.writeQueues.get(teamName) ?? Promise.resolve(); const next = previous.then(operation, operation); diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts index 79811045..6d72b670 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts @@ -12,7 +12,7 @@ export class MemberWorkSyncStorePaths { } getPendingReportsPath(teamName: string): string { - return join(this.getTeamDir(teamName), 'pending-reports.jsonl'); + return join(this.getTeamDir(teamName), 'pending-reports.json'); } getReportTokenSecretPath(teamName: string): string { diff --git a/src/main/index.ts b/src/main/index.ts index 399c8f2c..eb8f057f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1240,11 +1240,11 @@ async function initializeServices(): Promise { }); void teamDataService .listTeams() - .then((teams) => - memberWorkSyncFeature?.enqueueStartupScan( - teams.filter((team) => !team.deletedAt).map((team) => team.teamName) - ) - ) + .then(async (teams) => { + const activeTeamNames = teams.filter((team) => !team.deletedAt).map((team) => team.teamName); + await memberWorkSyncFeature?.replayPendingReports(activeTeamNames); + await memberWorkSyncFeature?.enqueueStartupScan(activeTeamNames); + }) .catch((error: unknown) => logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`) ); diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 2999fc71..45e2d2cb 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { MemberWorkSyncDiagnosticsReader, + MemberWorkSyncPendingReportIntentReplayer, MemberWorkSyncReporter, type MemberWorkSyncAgendaSourceResult, type MemberWorkSyncStatusStorePort, @@ -9,6 +10,7 @@ import { } from '@features/member-work-sync/core/application'; import type { MemberWorkSyncActionableWorkItem, + MemberWorkSyncReportIntent, MemberWorkSyncReportRequest, MemberWorkSyncStatus, } from '@features/member-work-sync/contracts'; @@ -42,6 +44,7 @@ class MutableClock { class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { readonly writes: MemberWorkSyncStatus[] = []; readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = []; + readonly pendingIntents = new Map(); async read(): Promise { return this.writes.at(-1) ?? null; @@ -54,6 +57,25 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { this.pendingReports.push({ request, reason }); } + + async listPendingReports(): Promise { + return [...this.pendingIntents.values()].filter((intent) => intent.status === 'pending'); + } + + async markPendingReportProcessed( + _teamName: string, + id: string, + result: { + status: MemberWorkSyncReportIntent['status']; + resultCode: string; + processedAt: string; + } + ): Promise { + const current = this.pendingIntents.get(id); + if (current) { + this.pendingIntents.set(id, { ...current, ...result }); + } + } } function createDeps(options?: { @@ -157,7 +179,29 @@ describe('MemberWorkSync use cases', () => { expect(expired.diagnostics).toContain('report_lease_expired'); }); - it('rejects stale or unsafe reports and records pending intent only', async () => { + it('uses app clock instead of model supplied reportedAt for lease timing', async () => { + const { deps } = createDeps(); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + const result = await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, + reportedAt: '2099-01-01T00:00:00.000Z', + leaseTtlMs: 120_000, + source: 'test', + }); + + expect(result.accepted).toBe(true); + expect(result.status.report?.reportedAt).toBe('2026-04-29T00:00:00.000Z'); + expect(result.status.report?.expiresAt).toBe('2026-04-29T00:02:00.000Z'); + }); + + it('rejects stale reports without turning app-side validation failures into pending intents', async () => { const { deps, store } = createDeps(); const result = await new MemberWorkSyncReporter(deps).execute({ teamName: 'team-a', @@ -170,8 +214,7 @@ describe('MemberWorkSync use cases', () => { expect(result.accepted).toBe(false); expect(result.code).toBe('stale_fingerprint'); expect(result.status.state).toBe('needs_sync'); - expect(store.pendingReports).toHaveLength(1); - expect(store.pendingReports[0].reason).toBe('stale_fingerprint'); + expect(store.pendingReports).toHaveLength(0); }); it('accepts caught_up only when the app-side agenda is empty', async () => { @@ -247,4 +290,37 @@ describe('MemberWorkSync use cases', () => { expect(result.code).toBe('invalid_report_token'); expect(store.pendingReports).toHaveLength(0); }); + + it('replays pending controller intents through the same app validator', async () => { + const { deps, store } = createDeps(); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + store.pendingIntents.set('intent-1', { + id: 'intent-1', + teamName: 'team-a', + memberName: 'bob', + status: 'pending', + reason: 'control_api_unavailable', + recordedAt: '2026-04-29T00:00:01.000Z', + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, + leaseTtlMs: 120_000, + source: 'mcp', + }, + }); + + const summary = await new MemberWorkSyncPendingReportIntentReplayer(deps).replayTeam('team-a'); + + expect(summary).toEqual({ processed: 1, accepted: 1, rejected: 0, superseded: 0 }); + expect(store.pendingIntents.get('intent-1')).toMatchObject({ + status: 'accepted', + resultCode: 'accepted', + processedAt: '2026-04-29T00:00:00.000Z', + }); + expect(store.writes.at(-1)?.state).toBe('still_working'); + }); }); diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts new file mode 100644 index 00000000..847766fc --- /dev/null +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -0,0 +1,72 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore'; +import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; + +describe('JsonMemberWorkSyncStore', () => { + let root: string; + let store: JsonMemberWorkSyncStore; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'member-work-sync-store-')); + store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(root)); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('quarantines invalid status JSON and returns empty state', async () => { + const statusPath = join(root, 'team-a', '.member-work-sync', 'status.json'); + await mkdir(join(root, 'team-a', '.member-work-sync'), { recursive: true }); + await writeFile(statusPath, '{bad json', 'utf8'); + + await expect(store.read({ teamName: 'team-a', memberName: 'bob' })).resolves.toBeNull(); + + const teamDir = join(root, 'team-a', '.member-work-sync'); + const entries = await readdir(teamDir); + expect(entries.some((entry) => entry.startsWith('status.json.invalid.'))).toBe(true); + }); + + it('deduplicates pending report intents and marks them processed', async () => { + const request = { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working' as const, + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test', + taskIds: ['task-2', 'task-1', 'task-1'], + source: 'mcp' as const, + }; + + await store.appendPendingReport(request, 'control_api_unavailable'); + await store.appendPendingReport({ ...request, taskIds: ['task-1', 'task-2'] }, 'duplicate'); + + const pending = await store.listPendingReports('team-a'); + expect(pending).toHaveLength(1); + expect(pending[0]).toMatchObject({ + teamName: 'team-a', + memberName: 'bob', + reason: 'control_api_unavailable', + status: 'pending', + }); + + await store.markPendingReportProcessed('team-a', pending[0].id, { + status: 'accepted', + resultCode: 'accepted', + processedAt: '2026-04-29T00:00:00.000Z', + }); + + expect(await store.listPendingReports('team-a')).toEqual([]); + const file = JSON.parse( + await readFile(join(root, 'team-a', '.member-work-sync', 'pending-reports.json'), 'utf8') + ); + expect(file.intents[pending[0].id]).toMatchObject({ + status: 'accepted', + resultCode: 'accepted', + }); + }); +}); From 99b027984138b31c1431772f4271e8b8204bceb5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:09:14 +0300 Subject: [PATCH 09/74] feat(member-work-sync): add renderer status view model --- .../adapters/memberWorkSyncStatusViewModel.ts | 91 +++++++++++++++++++ .../member-work-sync/renderer/index.ts | 1 + .../memberWorkSyncStatusViewModel.test.ts | 90 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 src/features/member-work-sync/renderer/adapters/memberWorkSyncStatusViewModel.ts create mode 100644 src/features/member-work-sync/renderer/index.ts create mode 100644 test/features/member-work-sync/renderer/memberWorkSyncStatusViewModel.test.ts diff --git a/src/features/member-work-sync/renderer/adapters/memberWorkSyncStatusViewModel.ts b/src/features/member-work-sync/renderer/adapters/memberWorkSyncStatusViewModel.ts new file mode 100644 index 00000000..1d296a43 --- /dev/null +++ b/src/features/member-work-sync/renderer/adapters/memberWorkSyncStatusViewModel.ts @@ -0,0 +1,91 @@ +import type { MemberWorkSyncStatus } from '../../contracts'; + +export type MemberWorkSyncViewTone = 'neutral' | 'success' | 'working' | 'attention' | 'blocked'; + +export interface MemberWorkSyncStatusViewModel { + label: 'Synced' | 'Working' | 'Needs sync' | 'Blocked' | 'Unknown'; + tone: MemberWorkSyncViewTone; + actionableCount: number; + tooltip: string; + fingerprint?: string; + leaseExpiresAt?: string; + reportState?: string; + wouldNudge?: boolean; +} + +function describeAgenda(count: number): string { + if (count === 0) { + return 'No actionable work items.'; + } + if (count === 1) { + return '1 actionable work item.'; + } + return `${count} actionable work items.`; +} + +export function toMemberWorkSyncStatusViewModel( + status: MemberWorkSyncStatus | null | undefined +): MemberWorkSyncStatusViewModel { + if (!status) { + return { + label: 'Unknown', + tone: 'neutral', + actionableCount: 0, + tooltip: 'Member work sync status has not been evaluated yet.', + }; + } + + const actionableCount = status.agenda.items.length; + const base = { + actionableCount, + fingerprint: status.agenda.fingerprint, + ...(status.report?.expiresAt ? { leaseExpiresAt: status.report.expiresAt } : {}), + ...(status.report?.state ? { reportState: status.report.state } : {}), + ...(status.shadow ? { wouldNudge: status.shadow.wouldNudge } : {}), + }; + + if (status.state === 'caught_up') { + return { + ...base, + label: 'Synced', + tone: 'success', + tooltip: `Synced with current work agenda. ${describeAgenda(actionableCount)}`, + }; + } + + if (status.state === 'still_working') { + return { + ...base, + label: 'Working', + tone: 'working', + tooltip: `Member reported still working on current agenda. ${describeAgenda(actionableCount)}`, + }; + } + + if (status.state === 'blocked') { + return { + ...base, + label: 'Blocked', + tone: 'blocked', + tooltip: `Member reported blocked on current agenda. ${describeAgenda(actionableCount)}`, + }; + } + + if (status.state === 'needs_sync') { + return { + ...base, + label: 'Needs sync', + tone: 'attention', + tooltip: `Shadow status only: current agenda has no valid member report. ${describeAgenda( + actionableCount + )}`, + }; + } + + return { + ...base, + label: 'Unknown', + tone: 'neutral', + tooltip: `Member work sync is not active for this member. ${describeAgenda(actionableCount)}`, + }; +} diff --git a/src/features/member-work-sync/renderer/index.ts b/src/features/member-work-sync/renderer/index.ts new file mode 100644 index 00000000..118ec417 --- /dev/null +++ b/src/features/member-work-sync/renderer/index.ts @@ -0,0 +1 @@ +export * from './adapters/memberWorkSyncStatusViewModel'; diff --git a/test/features/member-work-sync/renderer/memberWorkSyncStatusViewModel.test.ts b/test/features/member-work-sync/renderer/memberWorkSyncStatusViewModel.test.ts new file mode 100644 index 00000000..1e902e10 --- /dev/null +++ b/test/features/member-work-sync/renderer/memberWorkSyncStatusViewModel.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { toMemberWorkSyncStatusViewModel } from '@features/member-work-sync/renderer'; + +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; + +function makeStatus(overrides: Partial): MemberWorkSyncStatus { + return { + teamName: 'team-a', + memberName: 'bob', + state: 'needs_sync', + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [ + { + taskId: 'task-1', + displayId: '11111111', + subject: 'Ship UI', + kind: 'work', + assignee: 'bob', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { status: 'pending', owner: 'bob' }, + }, + ], + diagnostics: [], + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: [], + ...overrides, + }; +} + +describe('memberWorkSyncStatusViewModel', () => { + it('maps shadow needs-sync to a neutral diagnostic tooltip without warning copy', () => { + const viewModel = toMemberWorkSyncStatusViewModel( + makeStatus({ shadow: { reconciledBy: 'queue', wouldNudge: true, fingerprintChanged: false } }) + ); + + expect(viewModel).toMatchObject({ + label: 'Needs sync', + tone: 'attention', + actionableCount: 1, + wouldNudge: true, + }); + expect(viewModel.tooltip).toContain('Shadow status only'); + }); + + it('maps valid leases and caught-up states without exposing raw diagnostics', () => { + expect( + toMemberWorkSyncStatusViewModel( + makeStatus({ + state: 'still_working', + report: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportedAt: '2026-04-29T00:00:00.000Z', + expiresAt: '2026-04-29T00:10:00.000Z', + accepted: true, + }, + }) + ) + ).toMatchObject({ + label: 'Working', + tone: 'working', + leaseExpiresAt: '2026-04-29T00:10:00.000Z', + }); + + expect( + toMemberWorkSyncStatusViewModel( + makeStatus({ + state: 'caught_up', + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:empty', + items: [], + diagnostics: [], + }, + }) + ) + ).toMatchObject({ label: 'Synced', tone: 'success', actionableCount: 0 }); + }); +}); From cd70fc09ccccf0ea5201de8f1482add219c16c4f Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:12:07 +0300 Subject: [PATCH 10/74] docs(member-work-sync): mark phase one implemented --- .../member-work-sync-control-plane-plan.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 54d8fc7b..da99ee0a 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:** Proposed +**Status:** Phase 1 implemented, Phase 2 deferred until shadow metrics are reviewed **Scope:** Team management, task work synchronization, agent work coordination **Primary repo:** `claude_team` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` @@ -29,6 +29,12 @@ Phase 1 does not send nudges. It computes agenda/fingerprint/status, validates ` Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint churn and false positives are low. +Current implementation note: + +- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, and read-only renderer view models. +- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. +- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. + Patterns used: - Kubernetes-style level-triggered reconcile: recompute from current desired/current state instead of trusting events. From bf1f3b6b0274dbd35c2cadbc208a92e0bf567047 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:16:10 +0300 Subject: [PATCH 11/74] fix(member-work-sync): lock pending report writes --- .../src/internal/workSync.js | 40 +++++----- .../infrastructure/JsonMemberWorkSyncStore.ts | 75 ++++++++++--------- 2 files changed, 63 insertions(+), 52 deletions(-) diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js index 63036fc6..5ac48411 100644 --- a/agent-teams-controller/src/internal/workSync.js +++ b/agent-teams-controller/src/internal/workSync.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const runtimeHelpers = require('./runtimeHelpers.js'); +const { withFileLockSync } = require('./fileLock.js'); const DEFAULT_WAIT_TIMEOUT_MS = 10000; const MIN_WAIT_TIMEOUT_MS = 1000; @@ -173,25 +174,28 @@ function writePendingReportFile(filePath, data) { function appendPendingReportIntent(context, body, reason) { const filePath = path.join(context.paths.teamDir, '.member-work-sync', 'pending-reports.json'); - const data = readPendingReportFile(filePath); - const request = { - ...body, - source: 'mcp', - }; - const id = buildPendingIntentId(request); - const current = data.intents[id]; - if (!current || current.status === 'pending') { - data.intents[id] = { - id, - teamName: body.teamName, - memberName: body.memberName, - request, - reason, - status: 'pending', - recordedAt: current && current.recordedAt ? current.recordedAt : new Date().toISOString(), + const { id } = withFileLockSync(filePath, () => { + const data = readPendingReportFile(filePath); + const request = { + ...body, + source: 'mcp', }; - writePendingReportFile(filePath, data); - } + const intentId = buildPendingIntentId(request); + const current = data.intents[intentId]; + if (!current || current.status === 'pending') { + data.intents[intentId] = { + id: intentId, + teamName: body.teamName, + memberName: body.memberName, + request, + reason, + status: 'pending', + recordedAt: current && current.recordedAt ? current.recordedAt : new Date().toISOString(), + }; + writePendingReportFile(filePath, data); + } + return { id: intentId }; + }); return { accepted: false, pendingValidation: true, diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index f5329e4d..ec2df82e 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -2,6 +2,7 @@ import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { createHash } from 'crypto'; import { mkdir, readFile, rename } from 'fs/promises'; +import { withFileLock } from '@main/services/team/fileLock'; import type { MemberWorkSyncReportIntent, MemberWorkSyncReportRequest, @@ -106,34 +107,38 @@ export class JsonMemberWorkSyncStore async write(status: MemberWorkSyncStatus): Promise { await this.enqueue(status.teamName, async () => { - const existing = await this.readFile(status.teamName); - existing.members[normalizeMemberKey(status.memberName)] = status; - await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true }); - await atomicWriteAsync( - this.paths.getStatusPath(status.teamName), - JSON.stringify(existing, null, 2) - ); + await withFileLock(this.paths.getStatusPath(status.teamName), async () => { + const existing = await this.readFile(status.teamName); + existing.members[normalizeMemberKey(status.memberName)] = status; + await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true }); + await atomicWriteAsync( + this.paths.getStatusPath(status.teamName), + JSON.stringify(existing, null, 2) + ); + }); }); } async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { const id = buildPendingReportIntentId(request); await this.enqueue(request.teamName, async () => { - const existing = await this.readPendingFile(request.teamName); - const current = existing.intents[id]; - if (current && current.status !== 'pending') { - return; - } - existing.intents[id] = { - id, - teamName: request.teamName, - memberName: request.memberName, - request, - reason: current?.reason ?? reason, - status: 'pending', - recordedAt: current?.recordedAt ?? new Date().toISOString(), - }; - await this.writePendingFile(request.teamName, existing); + await withFileLock(this.paths.getPendingReportsPath(request.teamName), async () => { + const existing = await this.readPendingFile(request.teamName); + const current = existing.intents[id]; + if (current && current.status !== 'pending') { + return; + } + existing.intents[id] = { + id, + teamName: request.teamName, + memberName: request.memberName, + request, + reason: current?.reason ?? reason, + status: 'pending', + recordedAt: current?.recordedAt ?? new Date().toISOString(), + }; + await this.writePendingFile(request.teamName, existing); + }); }); } @@ -154,18 +159,20 @@ export class JsonMemberWorkSyncStore } ): Promise { await this.enqueue(teamName, async () => { - const existing = await this.readPendingFile(teamName); - const current = existing.intents[id]; - if (!current || current.status !== 'pending') { - return; - } - existing.intents[id] = { - ...current, - status: result.status, - resultCode: result.resultCode, - processedAt: result.processedAt, - }; - await this.writePendingFile(teamName, existing); + await withFileLock(this.paths.getPendingReportsPath(teamName), async () => { + const existing = await this.readPendingFile(teamName); + const current = existing.intents[id]; + if (!current || current.status !== 'pending') { + return; + } + existing.intents[id] = { + ...current, + status: result.status, + resultCode: result.resultCode, + processedAt: result.processedAt, + }; + await this.writePendingFile(teamName, existing); + }); }); } From 246f31bbdbeba4956435e268612b637068aa6141 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:27:03 +0300 Subject: [PATCH 12/74] feat(member-work-sync): expose shadow metrics --- .../member-work-sync-control-plane-plan.md | 4 +- .../member-work-sync/contracts/ipc.ts | 1 + .../member-work-sync/contracts/types.ts | 40 ++++++ .../MemberWorkSyncMetricsReader.ts | 35 +++++ .../application/MemberWorkSyncReporter.ts | 32 ++++- .../core/application/index.ts | 1 + .../core/application/ports.ts | 2 + .../input/registerMemberWorkSyncIpc.ts | 16 +++ .../createMemberWorkSyncFeature.ts | 6 + .../infrastructure/JsonMemberWorkSyncStore.ts | 135 +++++++++++++++++- .../member-work-sync/preload/index.ts | 5 + src/main/http/teams.ts | 25 ++++ src/renderer/api/httpClient.ts | 2 + src/shared/types/api.ts | 3 + .../core/MemberWorkSyncUseCases.test.ts | 10 ++ .../main/JsonMemberWorkSyncStore.test.ts | 76 ++++++++++ 16 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts 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 da99ee0a..eae41bbe 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 implemented, Phase 2 deferred until shadow metrics are reviewed +**Status:** Phase 1 and Phase 1.5 observability implemented, Phase 2 deferred until shadow metrics are reviewed **Scope:** Team management, task work synchronization, agent work coordination **Primary repo:** `claude_team` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` @@ -31,7 +31,7 @@ Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint ch Current implementation note: -- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, and read-only renderer view models. +- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and read-only renderer view models. - Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. diff --git a/src/features/member-work-sync/contracts/ipc.ts b/src/features/member-work-sync/contracts/ipc.ts index 5a000fe3..5b4e070f 100644 --- a/src/features/member-work-sync/contracts/ipc.ts +++ b/src/features/member-work-sync/contracts/ipc.ts @@ -1,2 +1,3 @@ export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus'; +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/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index e98fbe92..3139a921 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -102,6 +102,42 @@ export interface MemberWorkSyncStatus { providerId?: MemberWorkSyncProviderId; } +export type MemberWorkSyncMetricEventKind = + | 'status_evaluated' + | 'would_nudge' + | 'fingerprint_changed' + | 'report_accepted' + | 'report_rejected'; + +export interface MemberWorkSyncMetricEvent { + id: string; + teamName: string; + memberName: string; + kind: MemberWorkSyncMetricEventKind; + state: MemberWorkSyncStatusState; + agendaFingerprint: string; + recordedAt: string; + actionableCount: number; + providerId?: MemberWorkSyncProviderId; + previousFingerprint?: string; + triggerReasons?: string[]; + reportState?: MemberWorkSyncReportState; + rejectionCode?: string; +} + +export interface MemberWorkSyncTeamMetrics { + teamName: string; + generatedAt: string; + memberCount: number; + stateCounts: Record; + actionableItemCount: number; + wouldNudgeCount: number; + fingerprintChangeCount: number; + reportAcceptedCount: number; + reportRejectedCount: number; + recentEvents: MemberWorkSyncMetricEvent[]; +} + export interface MemberWorkSyncReportRequest { teamName: string; memberName: string; @@ -126,3 +162,7 @@ export interface MemberWorkSyncStatusRequest { teamName: string; memberName: string; } + +export interface MemberWorkSyncMetricsRequest { + teamName: string; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts new file mode 100644 index 00000000..41cb9d4a --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts @@ -0,0 +1,35 @@ +import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics { + return { + teamName, + generatedAt, + memberCount: 0, + stateCounts: { + caught_up: 0, + needs_sync: 0, + still_working: 0, + blocked: 0, + inactive: 0, + unknown: 0, + }, + actionableItemCount: 0, + wouldNudgeCount: 0, + fingerprintChangeCount: 0, + reportAcceptedCount: 0, + reportRejectedCount: 0, + recentEvents: [], + }; +} + +export class MemberWorkSyncMetricsReader { + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} + + async execute(request: MemberWorkSyncMetricsRequest): Promise { + if (!this.deps.statusStore.readTeamMetrics) { + return emptyMetrics(request.teamName, this.deps.clock.now().toISOString()); + } + return this.deps.statusStore.readTeamMetrics(request.teamName); + } +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts index 3e401d5a..de2bd018 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -2,6 +2,7 @@ import type { MemberWorkSyncReport, MemberWorkSyncReportRequest, MemberWorkSyncReportResult, + MemberWorkSyncStatus, } from '../../contracts'; import { validateMemberWorkSyncReport } from '../domain'; import { @@ -27,11 +28,12 @@ export class MemberWorkSyncReporter { : true; if (!teamActive) { const status = await this.reconciler.execute(request); + const rejectedStatus = await this.recordRejectedReport(status, request, 'team_runtime_inactive'); return { accepted: false, code: 'team_runtime_inactive', message: 'Team runtime is not active. Restart the team before reporting work sync state.', - status, + status: rejectedStatus, }; } const tokenValidation = this.deps.reportToken @@ -53,11 +55,12 @@ export class MemberWorkSyncReporter { if (!validation.ok) { const status = await this.reconciler.execute(request); + const rejectedStatus = await this.recordRejectedReport(status, request, validation.code); return { accepted: false, code: validation.code, message: validation.message, - status, + status: rejectedStatus, }; } @@ -103,4 +106,29 @@ export class MemberWorkSyncReporter { status, }; } + + private async recordRejectedReport( + status: MemberWorkSyncStatus, + request: MemberWorkSyncReportRequest, + rejectionCode: string + ): Promise { + const rejectedStatus: MemberWorkSyncStatus = { + ...status, + report: { + teamName: status.teamName, + memberName: status.memberName, + state: request.state, + agendaFingerprint: request.agendaFingerprint, + reportedAt: status.evaluatedAt, + ...(request.taskIds ? { taskIds: [...request.taskIds] } : {}), + ...(request.note ? { note: request.note } : {}), + source: request.source ?? 'app', + accepted: false, + rejectionCode, + }, + diagnostics: [...status.diagnostics, `report_rejected:${rejectionCode}`], + }; + await this.deps.statusStore.write(rejectedStatus); + return rejectedStatus; + } } diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 6e5a81aa..b47b511c 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -1,4 +1,5 @@ export * from './MemberWorkSyncDiagnosticsReader'; +export * from './MemberWorkSyncMetricsReader'; export * from './MemberWorkSyncPendingReportIntentReplayer'; export * from './MemberWorkSyncReconciler'; export * from './MemberWorkSyncReporter'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 32aacc1f..b3865eb8 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -1,5 +1,6 @@ import type { MemberWorkSyncAgenda, + MemberWorkSyncTeamMetrics, MemberWorkSyncProviderId, MemberWorkSyncReport, MemberWorkSyncReportIntent, @@ -73,6 +74,7 @@ export interface MemberWorkSyncAgendaSourcePort { export interface MemberWorkSyncStatusStorePort { read(input: { teamName: string; memberName: string }): Promise; write(status: MemberWorkSyncStatus): Promise; + readTeamMetrics?(teamName: string): Promise; } export interface MemberWorkSyncReportStorePort { 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 ea41cf10..821f1ee2 100644 --- a/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts +++ b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts @@ -1,10 +1,13 @@ import { + MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT, + type MemberWorkSyncMetricsRequest, type MemberWorkSyncReportRequest, type MemberWorkSyncReportResult, type MemberWorkSyncStatus, type MemberWorkSyncStatusRequest, + type MemberWorkSyncTeamMetrics, } from '../../../contracts'; import { createLogger } from '@shared/utils/logger'; @@ -29,6 +32,18 @@ export function registerMemberWorkSyncIpc( } ); + ipcMain.handle( + MEMBER_WORK_SYNC_GET_METRICS, + async (_event, request: MemberWorkSyncMetricsRequest): Promise => { + try { + return await feature.getMetrics(request); + } catch (error) { + logger.error('Failed to get member work sync metrics', error); + throw error; + } + } + ); + ipcMain.handle( MEMBER_WORK_SYNC_REPORT, async (_event, request: MemberWorkSyncReportRequest): Promise => { @@ -44,5 +59,6 @@ export function registerMemberWorkSyncIpc( export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void { ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_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 ffdbf949..40034a4e 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -1,11 +1,14 @@ import type { + MemberWorkSyncMetricsRequest, MemberWorkSyncReportRequest, MemberWorkSyncReportResult, MemberWorkSyncStatus, MemberWorkSyncStatusRequest, + MemberWorkSyncTeamMetrics, } from '../../contracts'; import { MemberWorkSyncDiagnosticsReader, + MemberWorkSyncMetricsReader, MemberWorkSyncPendingReportIntentReplayer, type MemberWorkSyncPendingReportReplaySummary, MemberWorkSyncReconciler, @@ -33,6 +36,7 @@ import type { MemberWorkSyncLoggerPort } from '../../core/application'; export interface MemberWorkSyncFeatureFacade { getStatus(request: MemberWorkSyncStatusRequest): Promise; + getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; noteTeamChange(event: TeamChangeEvent): void; enqueueStartupScan(teamNames: string[]): Promise; @@ -74,6 +78,7 @@ export function createMemberWorkSyncFeature(deps: { logger: deps.logger, }; const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps); + const metricsReader = new MemberWorkSyncMetricsReader(useCaseDeps); const reporter = new MemberWorkSyncReporter(useCaseDeps); const reconciler = new MemberWorkSyncReconciler(useCaseDeps); const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps); @@ -88,6 +93,7 @@ export function createMemberWorkSyncFeature(deps: { return { getStatus: (request) => diagnosticsReader.execute(request), + getMetrics: (request) => metricsReader.execute(request), report: (request) => reporter.execute(request), noteTeamChange: (event) => router.noteTeamChange(event), enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames), diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index ec2df82e..a71cf6ff 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -4,9 +4,12 @@ import { mkdir, readFile, rename } from 'fs/promises'; import { withFileLock } from '@main/services/team/fileLock'; import type { + MemberWorkSyncMetricEvent, MemberWorkSyncReportIntent, MemberWorkSyncReportRequest, MemberWorkSyncStatus, + MemberWorkSyncStatusState, + MemberWorkSyncTeamMetrics, } from '../../contracts'; import type { MemberWorkSyncReportStorePort, @@ -17,6 +20,9 @@ import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; interface StoreFile { schemaVersion: 1; members: Record; + metrics?: { + recentEvents: MemberWorkSyncMetricEvent[]; + }; } interface PendingReportFile { @@ -39,6 +45,17 @@ function isStoreFile(value: unknown): value is StoreFile { ); } +function emptyStateCounts(): Record { + return { + caught_up: 0, + needs_sync: 0, + still_working: 0, + blocked: 0, + inactive: 0, + unknown: 0, + }; +} + function isPendingReportFile(value: unknown): value is PendingReportFile { return ( value != null && @@ -82,6 +99,91 @@ function buildPendingReportIntentId(request: MemberWorkSyncReportRequest): strin .digest('hex')}`; } +function buildMetricEventId(status: MemberWorkSyncStatus, kind: MemberWorkSyncMetricEvent['kind']) { + return `member-work-sync-metric:${createHash('sha256') + .update( + stableStringify({ + teamName: status.teamName, + memberName: normalizeMemberKey(status.memberName), + kind, + state: status.state, + agendaFingerprint: status.agenda.fingerprint, + evaluatedAt: status.evaluatedAt, + reportState: status.report?.state ?? '', + rejectionCode: status.report?.rejectionCode ?? '', + }) + ) + .digest('hex')}`; +} + +function buildMetricEvents(status: MemberWorkSyncStatus): MemberWorkSyncMetricEvent[] { + const base = { + teamName: status.teamName, + memberName: status.memberName, + state: status.state, + agendaFingerprint: status.agenda.fingerprint, + recordedAt: status.evaluatedAt, + actionableCount: status.agenda.items.length, + ...(status.providerId ? { providerId: status.providerId } : {}), + ...(status.shadow?.previousFingerprint + ? { previousFingerprint: status.shadow.previousFingerprint } + : {}), + ...(status.shadow?.triggerReasons?.length + ? { triggerReasons: [...status.shadow.triggerReasons] } + : {}), + ...(status.report?.state ? { reportState: status.report.state } : {}), + ...(status.report?.rejectionCode ? { rejectionCode: status.report.rejectionCode } : {}), + }; + const events: MemberWorkSyncMetricEvent[] = [ + { + ...base, + id: buildMetricEventId(status, 'status_evaluated'), + kind: 'status_evaluated', + }, + ]; + if (status.shadow?.wouldNudge) { + events.push({ + ...base, + id: buildMetricEventId(status, 'would_nudge'), + kind: 'would_nudge', + }); + } + if (status.shadow?.fingerprintChanged) { + events.push({ + ...base, + id: buildMetricEventId(status, 'fingerprint_changed'), + kind: 'fingerprint_changed', + }); + } + if (status.report?.accepted) { + events.push({ + ...base, + id: buildMetricEventId(status, 'report_accepted'), + kind: 'report_accepted', + }); + } else if (status.report?.rejectionCode) { + events.push({ + ...base, + id: buildMetricEventId(status, 'report_rejected'), + kind: 'report_rejected', + }); + } + return events; +} + +function appendMetricEvents(file: StoreFile, status: MemberWorkSyncStatus): void { + const current = file.metrics?.recentEvents ?? []; + const byId = new Map(current.map((event) => [event.id, event])); + for (const event of buildMetricEvents(status)) { + byId.set(event.id, event); + } + file.metrics = { + recentEvents: [...byId.values()] + .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)) + .slice(-200), + }; +} + async function quarantineFile(filePath: string): Promise { try { await rename(filePath, `${filePath}.invalid.${Date.now()}`); @@ -110,6 +212,7 @@ export class JsonMemberWorkSyncStore await withFileLock(this.paths.getStatusPath(status.teamName), async () => { const existing = await this.readFile(status.teamName); existing.members[normalizeMemberKey(status.memberName)] = status; + appendMetricEvents(existing, status); await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true }); await atomicWriteAsync( this.paths.getStatusPath(status.teamName), @@ -119,6 +222,36 @@ export class JsonMemberWorkSyncStore }); } + async readTeamMetrics(teamName: string): Promise { + const file = await this.readFile(teamName); + const stateCounts = emptyStateCounts(); + const members = Object.values(file.members); + let actionableItemCount = 0; + for (const status of members) { + stateCounts[status.state] += 1; + actionableItemCount += status.agenda.items.length; + } + const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) => + left.recordedAt.localeCompare(right.recordedAt) + ); + return { + teamName, + generatedAt: new Date().toISOString(), + memberCount: members.length, + stateCounts, + actionableItemCount, + wouldNudgeCount: recentEvents.filter((event) => event.kind === 'would_nudge').length, + fingerprintChangeCount: recentEvents.filter( + (event) => event.kind === 'fingerprint_changed' + ).length, + reportAcceptedCount: recentEvents.filter((event) => event.kind === 'report_accepted') + .length, + reportRejectedCount: recentEvents.filter((event) => event.kind === 'report_rejected') + .length, + recentEvents, + }; + } + async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { const id = buildPendingReportIntentId(request); await this.enqueue(request.teamName, async () => { @@ -190,7 +323,7 @@ export class JsonMemberWorkSyncStore await quarantineFile(filePath); } } - return { schemaVersion: 1, members: {} }; + return { schemaVersion: 1, members: {}, metrics: { recentEvents: [] } }; } private async readPendingFile(teamName: string): Promise { diff --git a/src/features/member-work-sync/preload/index.ts b/src/features/member-work-sync/preload/index.ts index c90efb6b..4f53d1d7 100644 --- a/src/features/member-work-sync/preload/index.ts +++ b/src/features/member-work-sync/preload/index.ts @@ -1,22 +1,27 @@ import { + MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT, + type MemberWorkSyncMetricsRequest, type MemberWorkSyncReportRequest, type MemberWorkSyncReportResult, type MemberWorkSyncStatus, type MemberWorkSyncStatusRequest, + type MemberWorkSyncTeamMetrics, } from '../contracts'; import type { IpcRenderer } from 'electron'; export interface MemberWorkSyncElectronApi { getStatus(request: MemberWorkSyncStatusRequest): Promise; + getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; } export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi { return { getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_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 2a93459c..906d0915 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -751,6 +751,31 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) } ); + app.get<{ Params: { teamName: string } }>( + '/api/teams/:teamName/member-work-sync/metrics', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + return reply.send( + await getMemberWorkSyncFeature(services).getMetrics({ + teamName: validatedTeamName.value!, + }) + ); + } catch (error) { + if (shouldLogError(error)) { + logger.error( + `Error in GET /api/teams/${request.params.teamName}/member-work-sync/metrics:`, + getErrorMessage(error) + ); + } + return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) }); + } + } + ); + app.get<{ Params: { teamName: string; memberName: string } }>( '/api/teams/:teamName/member-work-sync/:memberName', async (request, reply) => { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 171cbeaa..77479873 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1296,6 +1296,8 @@ export class HttpAPIClient implements ElectronAPI { request.memberName )}` ), + getMetrics: (request) => + this.get(`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/metrics`), report: (request) => this.post( `/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/report`, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 633430b2..c55a31bf 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -98,10 +98,12 @@ import type { CodexAccountElectronApi } from '@features/codex-account/contracts' import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; import type { + MemberWorkSyncMetricsRequest, MemberWorkSyncReportRequest, MemberWorkSyncReportResult, MemberWorkSyncStatus, MemberWorkSyncStatusRequest, + MemberWorkSyncTeamMetrics, } from '@features/member-work-sync/contracts'; import type { ConversationGroup, @@ -612,6 +614,7 @@ export interface TeamsAPI { export interface MemberWorkSyncElectronApi { getStatus(request: MemberWorkSyncStatusRequest): Promise; + getMetrics(request: MemberWorkSyncMetricsRequest): Promise; report(request: MemberWorkSyncReportRequest): Promise; } diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 45e2d2cb..18fea140 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -214,6 +214,12 @@ describe('MemberWorkSync use cases', () => { expect(result.accepted).toBe(false); expect(result.code).toBe('stale_fingerprint'); expect(result.status.state).toBe('needs_sync'); + expect(result.status.report).toMatchObject({ + accepted: false, + rejectionCode: 'stale_fingerprint', + agendaFingerprint: 'agenda:v1:stale', + }); + expect(store.writes.at(-1)?.diagnostics).toContain('report_rejected:stale_fingerprint'); expect(store.pendingReports).toHaveLength(0); }); @@ -288,6 +294,10 @@ describe('MemberWorkSync use cases', () => { expect(result.accepted).toBe(false); expect(result.code).toBe('invalid_report_token'); + expect(result.status.report).toMatchObject({ + accepted: false, + rejectionCode: 'invalid_report_token', + }); expect(store.pendingReports).toHaveLength(0); }); diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index 847766fc..a1af8cf3 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -5,6 +5,42 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; + +function makeStatus(overrides: Partial): MemberWorkSyncStatus { + return { + teamName: 'team-a', + memberName: 'bob', + state: 'needs_sync', + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abc', + items: [ + { + taskId: 'task-1', + displayId: '11111111', + subject: 'Ship UI', + kind: 'work', + assignee: 'bob', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { status: 'pending', owner: 'bob' }, + }, + ], + diagnostics: [], + }, + shadow: { + reconciledBy: 'queue', + wouldNudge: true, + fingerprintChanged: false, + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: [], + ...overrides, + }; +} describe('JsonMemberWorkSyncStore', () => { let root: string; @@ -69,4 +105,44 @@ describe('JsonMemberWorkSyncStore', () => { resultCode: 'accepted', }); }); + + it('records bounded shadow metrics from status writes', async () => { + await store.write(makeStatus({})); + await store.write( + makeStatus({ + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:01:00.000Z', + fingerprint: 'agenda:v1:def', + items: [], + diagnostics: [], + }, + state: 'caught_up', + shadow: { + reconciledBy: 'request', + wouldNudge: false, + fingerprintChanged: true, + previousFingerprint: 'agenda:v1:abc', + }, + evaluatedAt: '2026-04-29T00:01:00.000Z', + }) + ); + + const metrics = await store.readTeamMetrics('team-a'); + expect(metrics).toMatchObject({ + teamName: 'team-a', + memberCount: 1, + actionableItemCount: 0, + wouldNudgeCount: 1, + fingerprintChangeCount: 1, + }); + expect(metrics.stateCounts.caught_up).toBe(1); + expect(metrics.recentEvents.map((event) => event.kind)).toEqual([ + 'status_evaluated', + 'would_nudge', + 'status_evaluated', + 'fingerprint_changed', + ]); + }); }); From 98a7c915ed459ded1734918f236cdabfb6fe75f9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:40:19 +0300 Subject: [PATCH 13/74] feat(member-work-sync): add neutral renderer diagnostics --- .../renderer/hooks/useMemberWorkSyncStatus.ts | 90 ++++++++++++++ .../member-work-sync/renderer/index.ts | 3 + .../renderer/ui/MemberWorkSyncBadge.tsx | 41 +++++++ .../renderer/ui/MemberWorkSyncDetails.tsx | 83 +++++++++++++ .../renderer/memberWorkSyncRenderer.test.tsx | 113 ++++++++++++++++++ 5 files changed, 330 insertions(+) create mode 100644 src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts create mode 100644 src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx create mode 100644 src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx create mode 100644 test/features/member-work-sync/renderer/memberWorkSyncRenderer.test.tsx diff --git a/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts b/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts new file mode 100644 index 00000000..3c6a71ef --- /dev/null +++ b/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; + +import { api } from '@renderer/api'; + +import type { MemberWorkSyncStatus } from '../../contracts'; +import { + toMemberWorkSyncStatusViewModel, + type MemberWorkSyncStatusViewModel, +} from '../adapters/memberWorkSyncStatusViewModel'; + +export interface UseMemberWorkSyncStatusOptions { + teamName?: string | null; + memberName?: string | null; + enabled?: boolean; +} + +export interface UseMemberWorkSyncStatusResult { + status: MemberWorkSyncStatus | null; + viewModel: MemberWorkSyncStatusViewModel; + loading: boolean; + error: string | null; + refresh: () => void; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Failed to load member work sync status.'; +} + +export function useMemberWorkSyncStatus({ + teamName, + memberName, + enabled = true, +}: UseMemberWorkSyncStatusOptions): UseMemberWorkSyncStatusResult { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + useEffect(() => { + const normalizedTeamName = teamName?.trim(); + const normalizedMemberName = memberName?.trim(); + + if (!enabled || !normalizedTeamName || !normalizedMemberName) { + setStatus(null); + setLoading(false); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + setStatus((current) => + current?.teamName === normalizedTeamName && current.memberName === normalizedMemberName + ? current + : null + ); + + api.memberWorkSync + .getStatus({ teamName: normalizedTeamName, memberName: normalizedMemberName }) + .then((nextStatus) => { + if (!cancelled) { + setStatus(nextStatus); + } + }) + .catch((nextError: unknown) => { + if (!cancelled) { + setStatus(null); + setError(getErrorMessage(nextError)); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [enabled, memberName, refreshKey, teamName]); + + return { + status, + viewModel: toMemberWorkSyncStatusViewModel(status), + loading, + error, + refresh: () => setRefreshKey((current) => current + 1), + }; +} diff --git a/src/features/member-work-sync/renderer/index.ts b/src/features/member-work-sync/renderer/index.ts index 118ec417..60559156 100644 --- a/src/features/member-work-sync/renderer/index.ts +++ b/src/features/member-work-sync/renderer/index.ts @@ -1 +1,4 @@ export * from './adapters/memberWorkSyncStatusViewModel'; +export * from './hooks/useMemberWorkSyncStatus'; +export * from './ui/MemberWorkSyncBadge'; +export * from './ui/MemberWorkSyncDetails'; diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx new file mode 100644 index 00000000..000b8bc9 --- /dev/null +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx @@ -0,0 +1,41 @@ +import { Badge } from '@renderer/components/ui/badge'; +import { cn } from '@renderer/lib/utils'; +import type React from 'react'; + +import type { MemberWorkSyncStatus } from '../../contracts'; +import { + toMemberWorkSyncStatusViewModel, + type MemberWorkSyncStatusViewModel, +} from '../adapters/memberWorkSyncStatusViewModel'; + +interface MemberWorkSyncBadgeProps { + status?: MemberWorkSyncStatus | null; + viewModel?: MemberWorkSyncStatusViewModel; + className?: string; +} + +const toneClassName: Record = { + neutral: 'border-[var(--color-border)] text-[var(--color-text-muted)]', + success: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-300', + working: 'border-sky-500/25 bg-sky-500/10 text-sky-300', + attention: 'border-amber-500/25 bg-amber-500/10 text-amber-200', + blocked: 'border-red-500/25 bg-red-500/10 text-red-300', +}; + +export function MemberWorkSyncBadge({ + status, + viewModel, + className, +}: MemberWorkSyncBadgeProps): React.ReactElement { + const resolved = viewModel ?? toMemberWorkSyncStatusViewModel(status); + + return ( + + {resolved.label} + + ); +} diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx new file mode 100644 index 00000000..81921c5d --- /dev/null +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx @@ -0,0 +1,83 @@ +import { MemberWorkSyncBadge } from './MemberWorkSyncBadge'; +import type { MemberWorkSyncStatus } from '../../contracts'; +import { toMemberWorkSyncStatusViewModel } from '../adapters/memberWorkSyncStatusViewModel'; +import type React from 'react'; + +interface MemberWorkSyncDetailsProps { + status: MemberWorkSyncStatus | null; + showDiagnostics?: boolean; +} + +function shortFingerprint(fingerprint?: string): string { + if (!fingerprint) { + return 'unknown'; + } + const suffix = fingerprint.split(':').at(-1) ?? fingerprint; + return suffix.length > 12 ? `${suffix.slice(0, 12)}...` : suffix; +} + +export function MemberWorkSyncDetails({ + status, + showDiagnostics = false, +}: MemberWorkSyncDetailsProps): React.ReactElement { + const viewModel = toMemberWorkSyncStatusViewModel(status); + const agendaItems = status?.agenda.items ?? []; + + return ( +
+
+
+

Member work sync

+

{viewModel.tooltip}

+
+ +
+ +
+
+
Actionable items
+
{viewModel.actionableCount}
+
+
+
Fingerprint
+
+ {shortFingerprint(viewModel.fingerprint)} +
+
+
+
Report
+
+ {viewModel.reportState ?? 'none'} +
+
+
+
Shadow would nudge
+
+ {viewModel.wouldNudge ? 'yes' : 'no'} +
+
+
+ + {agendaItems.length > 0 ? ( +
    + {agendaItems.slice(0, 3).map((item) => ( +
  • + #{item.displayId ?? item.taskId.slice(0, 8)} - {item.kind} - {item.subject} +
  • + ))} + {agendaItems.length > 3 ? ( +
  • + {agendaItems.length - 3} more actionable item(s) +
  • + ) : null} +
+ ) : null} + + {showDiagnostics && status?.diagnostics.length ? ( +

+ Diagnostics: {status.diagnostics.join(', ')} +

+ ) : null} +
+ ); +} diff --git a/test/features/member-work-sync/renderer/memberWorkSyncRenderer.test.tsx b/test/features/member-work-sync/renderer/memberWorkSyncRenderer.test.tsx new file mode 100644 index 00000000..7e19b1d4 --- /dev/null +++ b/test/features/member-work-sync/renderer/memberWorkSyncRenderer.test.tsx @@ -0,0 +1,113 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + MemberWorkSyncBadge, + MemberWorkSyncDetails, + useMemberWorkSyncStatus, +} from '@features/member-work-sync/renderer'; + +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; + +const apiMocks = vi.hoisted(() => ({ + getStatus: vi.fn(), +})); + +vi.mock('@renderer/api', () => ({ + api: { + memberWorkSync: { + getStatus: apiMocks.getStatus, + }, + }, + isElectronMode: () => true, +})); + +function makeStatus(overrides: Partial = {}): MemberWorkSyncStatus { + return { + teamName: 'team-a', + memberName: 'bob', + state: 'needs_sync', + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abcdef1234567890', + items: [ + { + taskId: 'task-1', + displayId: '11111111', + subject: 'Ship UI', + kind: 'work', + assignee: 'bob', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { status: 'pending', owner: 'bob' }, + }, + ], + diagnostics: [], + }, + shadow: { + reconciledBy: 'queue', + wouldNudge: true, + fingerprintChanged: false, + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: ['developer_only'], + ...overrides, + }; +} + +describe('member work sync renderer', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('loads read-only status through the renderer hook', async () => { + apiMocks.getStatus.mockResolvedValue(makeStatus()); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + function Harness(): React.ReactElement { + const state = useMemberWorkSyncStatus({ teamName: 'team-a', memberName: 'bob' }); + return React.createElement('div', null, state.loading ? 'Loading' : state.viewModel.label); + } + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + expect(apiMocks.getStatus).toHaveBeenCalledWith({ teamName: 'team-a', memberName: 'bob' }); + expect(host.textContent).toContain('Needs sync'); + }); + + it('renders neutral diagnostics without exposing raw diagnostics by default', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + 'div', + null, + React.createElement(MemberWorkSyncBadge, { status: makeStatus() }), + React.createElement(MemberWorkSyncDetails, { status: makeStatus() }) + ) + ); + }); + + expect(host.textContent).toContain('Needs sync'); + expect(host.textContent).toContain('Shadow would nudge'); + expect(host.textContent).toContain('11111111'); + expect(host.textContent).not.toContain('developer_only'); + }); +}); From ab459fbae077738ceefb86c64a599596f2f952f9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:54:56 +0300 Subject: [PATCH 14/74] feat(member-work-sync): show status in member details --- .../member-work-sync/renderer/index.ts | 1 + .../renderer/ui/MemberWorkSyncStatusPanel.tsx | 51 ++++++++++++++++ .../team/members/MemberDetailDialog.tsx | 10 +++- .../renderer/memberWorkSyncRenderer.test.tsx | 23 ++++++++ .../team/members/MemberDetailDialog.test.ts | 59 +++++++++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx diff --git a/src/features/member-work-sync/renderer/index.ts b/src/features/member-work-sync/renderer/index.ts index 60559156..98f250ce 100644 --- a/src/features/member-work-sync/renderer/index.ts +++ b/src/features/member-work-sync/renderer/index.ts @@ -2,3 +2,4 @@ export * from './adapters/memberWorkSyncStatusViewModel'; export * from './hooks/useMemberWorkSyncStatus'; export * from './ui/MemberWorkSyncBadge'; export * from './ui/MemberWorkSyncDetails'; +export * from './ui/MemberWorkSyncStatusPanel'; diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx new file mode 100644 index 00000000..6a4d1392 --- /dev/null +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx @@ -0,0 +1,51 @@ +import type React from 'react'; + +import { MemberWorkSyncBadge } from './MemberWorkSyncBadge'; +import { MemberWorkSyncDetails } from './MemberWorkSyncDetails'; +import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus'; + +interface MemberWorkSyncStatusPanelProps { + teamName: string; + memberName: string; + enabled?: boolean; + showDiagnostics?: boolean; +} + +export function MemberWorkSyncStatusPanel({ + teamName, + memberName, + enabled = true, + showDiagnostics = false, +}: MemberWorkSyncStatusPanelProps): React.ReactElement | null { + const { status, viewModel, loading, error } = useMemberWorkSyncStatus({ + teamName, + memberName, + enabled, + }); + + if (!enabled) { + return null; + } + + if (status) { + return ; + } + + return ( +
+
+
+

Member work sync

+

+ {loading + ? 'Loading member work sync diagnostics.' + : error + ? 'Member work sync diagnostics are unavailable.' + : viewModel.tooltip} +

+
+ +
+
+ ); +} diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 3828f04c..1b3c5166 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -6,6 +6,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useStore } from '@renderer/store'; import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; +import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; import { buildMemberLaunchDiagnosticsPayload, getMemberLaunchDiagnosticsErrorMessage, @@ -291,7 +292,14 @@ export const MemberDetailDialog = ({ - +
+ + +
{ expect(host.textContent).toContain('11111111'); expect(host.textContent).not.toContain('developer_only'); }); + + it('renders the status panel through the read-only API hook', async () => { + apiMocks.getStatus.mockResolvedValue(makeStatus()); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberWorkSyncStatusPanel, { + teamName: 'team-a', + memberName: 'bob', + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Member work sync'); + expect(host.textContent).toContain('Needs sync'); + expect(host.textContent).toContain('Shadow would nudge'); + expect(apiMocks.getStatus).toHaveBeenCalledWith({ teamName: 'team-a', memberName: 'bob' }); + }); }); diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts index ff4b0be1..fb6dcdd2 100644 --- a/test/renderer/components/team/members/MemberDetailDialog.test.ts +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -4,8 +4,59 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useStore } from '@renderer/store'; +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +const apiMocks = vi.hoisted(() => ({ + getMemberWorkSyncStatus: vi.fn(), +})); + +function makeMemberWorkSyncStatus( + overrides: Partial = {} +): MemberWorkSyncStatus { + return { + teamName: 'demo-team', + memberName: 'jack', + state: 'needs_sync', + agenda: { + teamName: 'demo-team', + memberName: 'jack', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abcdef123456', + items: [ + { + taskId: 'task-1', + displayId: '11111111', + subject: 'Review patch', + kind: 'work', + assignee: 'jack', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { status: 'in_progress', owner: 'jack' }, + }, + ], + diagnostics: [], + }, + shadow: { + reconciledBy: 'request', + wouldNudge: true, + fingerprintChanged: false, + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: [], + ...overrides, + }; +} + +vi.mock('@renderer/api', () => ({ + api: { + memberWorkSync: { + getStatus: apiMocks.getMemberWorkSyncStatus, + }, + }, + isElectronMode: () => true, +})); + vi.mock('@renderer/hooks/useMemberStats', () => ({ useMemberStats: () => ({ stats: null, @@ -115,6 +166,7 @@ import { MemberDetailDialog } from '@renderer/components/team/members/MemberDeta describe('MemberDetailDialog activity count', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiMocks.getMemberWorkSyncStatus.mockResolvedValue(makeMemberWorkSyncStatus()); useStore.setState({ teamMessagesByName: { 'demo-team': { @@ -134,6 +186,7 @@ describe('MemberDetailDialog activity count', () => { afterEach(() => { document.body.innerHTML = ''; + vi.clearAllMocks(); useStore.setState({ teamMessagesByName: {} } as never); vi.unstubAllGlobals(); }); @@ -202,6 +255,12 @@ describe('MemberDetailDialog activity count', () => { expect(host.textContent).toContain('activity-count:1'); expect(host.textContent).toContain('Activity1'); + expect(host.textContent).toContain('Member work sync'); + expect(host.textContent).toContain('Needs sync'); + expect(apiMocks.getMemberWorkSyncStatus).toHaveBeenCalledWith({ + teamName: 'demo-team', + memberName: 'jack', + }); await act(async () => { root.unmount(); From 440548531bace0e0421ec06bc81e7d358293ec12 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:55:23 +0300 Subject: [PATCH 15/74] docs(member-work-sync): mark details surface complete --- docs/team-management/member-work-sync-control-plane-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 eae41bbe..87ec0f8d 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 and Phase 1.5 observability implemented, Phase 2 deferred until shadow metrics are reviewed +**Status:** Phase 1 and Phase 1.5 observability implemented, minimal read-only member details surface wired, Phase 2 deferred until shadow metrics are reviewed **Scope:** Team management, task work synchronization, agent work coordination **Primary repo:** `claude_team` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` @@ -31,7 +31,7 @@ Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint ch Current implementation note: -- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and read-only renderer view models. +- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface. - Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. From c5a97fd796b29d1bf1fd1bf5be8340dc727c3b6c Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:07:19 +0300 Subject: [PATCH 16/74] feat(member-work-sync): assess phase two readiness --- .../member-work-sync-control-plane-plan.md | 2 + .../member-work-sync/contracts/types.ts | 39 ++++++ .../MemberWorkSyncMetricsReader.ts | 16 ++- .../domain/MemberWorkSyncPhase2Readiness.ts | 131 ++++++++++++++++++ .../member-work-sync/core/domain/index.ts | 1 + .../infrastructure/JsonMemberWorkSyncStore.ts | 10 +- .../MemberWorkSyncPhase2Readiness.test.ts | 77 ++++++++++ .../main/JsonMemberWorkSyncStore.test.ts | 7 + 8 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts create mode 100644 test/features/member-work-sync/core/MemberWorkSyncPhase2Readiness.test.ts 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 87ec0f8d..5a17c0b3 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -33,6 +33,7 @@ Current implementation note: - Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface. - 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 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: @@ -2981,6 +2982,7 @@ Check: - stale report rate; - invalid caught-up attempts; - how many nudges Phase 2 would send. +- `phase2Readiness.state` remains `collecting_shadow_data` until the sample is large enough, `blocked` if rates are noisy, and only then `shadow_ready`. Exit criteria: diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index 3139a921..d7a8566b 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -136,6 +136,45 @@ export interface MemberWorkSyncTeamMetrics { reportAcceptedCount: number; reportRejectedCount: number; recentEvents: MemberWorkSyncMetricEvent[]; + phase2Readiness: MemberWorkSyncPhase2ReadinessAssessment; +} + +export type MemberWorkSyncPhase2ReadinessState = + | 'collecting_shadow_data' + | 'shadow_ready' + | 'blocked'; + +export type MemberWorkSyncPhase2ReadinessReason = + | 'insufficient_members' + | 'insufficient_status_events' + | 'insufficient_observation_window' + | 'would_nudge_rate_high' + | 'fingerprint_churn_high' + | 'report_rejection_rate_high'; + +export interface MemberWorkSyncPhase2ReadinessThresholds { + minObservedMembers: number; + minStatusEvents: number; + minObservationHours: number; + maxWouldNudgesPerMemberHour: number; + maxFingerprintChangesPerMemberHour: number; + maxReportRejectionRate: number; +} + +export interface MemberWorkSyncPhase2ReadinessRates { + observationHours: number; + statusEventCount: number; + wouldNudgesPerMemberHour: number; + fingerprintChangesPerMemberHour: number; + reportRejectionRate: number; +} + +export interface MemberWorkSyncPhase2ReadinessAssessment { + state: MemberWorkSyncPhase2ReadinessState; + reasons: MemberWorkSyncPhase2ReadinessReason[]; + thresholds: MemberWorkSyncPhase2ReadinessThresholds; + rates: MemberWorkSyncPhase2ReadinessRates; + diagnostics: string[]; } export interface MemberWorkSyncReportRequest { diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts index 41cb9d4a..702cb81a 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts @@ -1,4 +1,5 @@ import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts'; +import { assessMemberWorkSyncPhase2Readiness } from '../domain'; import type { MemberWorkSyncUseCaseDeps } from './ports'; function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics { @@ -20,6 +21,10 @@ function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeam reportAcceptedCount: 0, reportRejectedCount: 0, recentEvents: [], + phase2Readiness: assessMemberWorkSyncPhase2Readiness({ + memberCount: 0, + recentEvents: [], + }), }; } @@ -30,6 +35,15 @@ export class MemberWorkSyncMetricsReader { if (!this.deps.statusStore.readTeamMetrics) { return emptyMetrics(request.teamName, this.deps.clock.now().toISOString()); } - return this.deps.statusStore.readTeamMetrics(request.teamName); + const metrics = await this.deps.statusStore.readTeamMetrics(request.teamName); + return { + ...metrics, + phase2Readiness: + metrics.phase2Readiness ?? + assessMemberWorkSyncPhase2Readiness({ + memberCount: metrics.memberCount, + recentEvents: metrics.recentEvents, + }), + }; } } diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts new file mode 100644 index 00000000..b989f160 --- /dev/null +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncPhase2Readiness.ts @@ -0,0 +1,131 @@ +import type { + MemberWorkSyncMetricEvent, + MemberWorkSyncPhase2ReadinessAssessment, + MemberWorkSyncPhase2ReadinessReason, + MemberWorkSyncPhase2ReadinessThresholds, +} from '../../contracts'; + +export const DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS: MemberWorkSyncPhase2ReadinessThresholds = + { + minObservedMembers: 1, + minStatusEvents: 20, + minObservationHours: 1, + maxWouldNudgesPerMemberHour: 2, + maxFingerprintChangesPerMemberHour: 1, + maxReportRejectionRate: 0.2, + }; + +interface AssessMemberWorkSyncPhase2ReadinessInput { + memberCount: number; + recentEvents: MemberWorkSyncMetricEvent[]; + thresholds?: Partial; +} + +function parseTime(value: string): number | null { + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : null; +} + +function getObservationHours(events: MemberWorkSyncMetricEvent[]): number { + const times = events.flatMap((event) => { + const time = parseTime(event.recordedAt); + return time == null ? [] : [time]; + }); + if (times.length < 2) { + return 0; + } + const min = Math.min(...times); + const max = Math.max(...times); + return Math.max(0, (max - min) / 3_600_000); +} + +function roundRate(value: number): number { + return Math.round(value * 1000) / 1000; +} + +function pushIf( + reasons: MemberWorkSyncPhase2ReadinessReason[], + condition: boolean, + reason: MemberWorkSyncPhase2ReadinessReason +): void { + if (condition) { + reasons.push(reason); + } +} + +export function assessMemberWorkSyncPhase2Readiness({ + memberCount, + recentEvents, + thresholds: thresholdOverrides, +}: AssessMemberWorkSyncPhase2ReadinessInput): MemberWorkSyncPhase2ReadinessAssessment { + const thresholds = { + ...DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS, + ...thresholdOverrides, + }; + const statusEvents = recentEvents.filter((event) => event.kind === 'status_evaluated'); + const wouldNudgeEvents = recentEvents.filter((event) => event.kind === 'would_nudge'); + const fingerprintChangeEvents = recentEvents.filter( + (event) => event.kind === 'fingerprint_changed' + ); + const reportAcceptedEvents = recentEvents.filter((event) => event.kind === 'report_accepted'); + const reportRejectedEvents = recentEvents.filter((event) => event.kind === 'report_rejected'); + const observationHours = getObservationHours(recentEvents); + const memberHourDenominator = Math.max(memberCount, 1) * Math.max(observationHours, 1 / 60); + const wouldNudgesPerMemberHour = wouldNudgeEvents.length / memberHourDenominator; + const fingerprintChangesPerMemberHour = fingerprintChangeEvents.length / memberHourDenominator; + const reportEventCount = reportAcceptedEvents.length + reportRejectedEvents.length; + const reportRejectionRate = + reportEventCount > 0 ? reportRejectedEvents.length / reportEventCount : 0; + + const collectingReasons: MemberWorkSyncPhase2ReadinessReason[] = []; + pushIf(collectingReasons, memberCount < thresholds.minObservedMembers, 'insufficient_members'); + pushIf( + collectingReasons, + statusEvents.length < thresholds.minStatusEvents, + 'insufficient_status_events' + ); + pushIf( + collectingReasons, + observationHours < thresholds.minObservationHours, + 'insufficient_observation_window' + ); + + const blockingReasons: MemberWorkSyncPhase2ReadinessReason[] = []; + pushIf( + blockingReasons, + wouldNudgesPerMemberHour > thresholds.maxWouldNudgesPerMemberHour, + 'would_nudge_rate_high' + ); + pushIf( + blockingReasons, + fingerprintChangesPerMemberHour > thresholds.maxFingerprintChangesPerMemberHour, + 'fingerprint_churn_high' + ); + pushIf( + blockingReasons, + reportRejectionRate > thresholds.maxReportRejectionRate, + 'report_rejection_rate_high' + ); + + const state = + collectingReasons.length > 0 + ? 'collecting_shadow_data' + : blockingReasons.length > 0 + ? 'blocked' + : 'shadow_ready'; + const reasons = [...collectingReasons, ...blockingReasons]; + + return { + state, + reasons, + thresholds, + rates: { + observationHours: roundRate(observationHours), + statusEventCount: statusEvents.length, + wouldNudgesPerMemberHour: roundRate(wouldNudgesPerMemberHour), + fingerprintChangesPerMemberHour: roundRate(fingerprintChangesPerMemberHour), + reportRejectionRate: roundRate(reportRejectionRate), + }, + diagnostics: reasons.map((reason) => `phase2_readiness:${reason}`), + }; +} diff --git a/src/features/member-work-sync/core/domain/index.ts b/src/features/member-work-sync/core/domain/index.ts index 8e9ccf16..11ba63d6 100644 --- a/src/features/member-work-sync/core/domain/index.ts +++ b/src/features/member-work-sync/core/domain/index.ts @@ -2,5 +2,6 @@ export * from './ActionableWorkAgenda'; export * from './AgendaFingerprint'; export * from './currentReviewCycle'; export * from './memberName'; +export * from './MemberWorkSyncPhase2Readiness'; export * from './MemberWorkSyncReportValidator'; export * from './SyncDecisionPolicy'; diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index a71cf6ff..d7b61e8a 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -11,6 +11,7 @@ import type { MemberWorkSyncStatusState, MemberWorkSyncTeamMetrics, } from '../../contracts'; +import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain'; import type { MemberWorkSyncReportStorePort, MemberWorkSyncStatusStorePort, @@ -234,7 +235,7 @@ export class JsonMemberWorkSyncStore const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) => left.recordedAt.localeCompare(right.recordedAt) ); - return { + const metrics = { teamName, generatedAt: new Date().toISOString(), memberCount: members.length, @@ -250,6 +251,13 @@ export class JsonMemberWorkSyncStore .length, recentEvents, }; + return { + ...metrics, + phase2Readiness: assessMemberWorkSyncPhase2Readiness({ + memberCount: metrics.memberCount, + recentEvents: metrics.recentEvents, + }), + }; } async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { diff --git a/test/features/member-work-sync/core/MemberWorkSyncPhase2Readiness.test.ts b/test/features/member-work-sync/core/MemberWorkSyncPhase2Readiness.test.ts new file mode 100644 index 00000000..f75f0330 --- /dev/null +++ b/test/features/member-work-sync/core/MemberWorkSyncPhase2Readiness.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { assessMemberWorkSyncPhase2Readiness } from '@features/member-work-sync/core/domain'; +import type { MemberWorkSyncMetricEvent } from '@features/member-work-sync/contracts'; + +function event( + index: number, + kind: MemberWorkSyncMetricEvent['kind'], + recordedAt: string +): MemberWorkSyncMetricEvent { + return { + id: `event-${index}-${kind}`, + teamName: 'team-a', + memberName: index % 2 === 0 ? 'bob' : 'alice', + kind, + state: 'needs_sync', + agendaFingerprint: `agenda:v1:${index}`, + recordedAt, + actionableCount: 1, + }; +} + +function statusEvents(count: number, start = Date.parse('2026-04-29T00:00:00.000Z')) { + return Array.from({ length: count }, (_, index) => + event(index, 'status_evaluated', new Date(start + index * 6 * 60_000).toISOString()) + ); +} + +describe('assessMemberWorkSyncPhase2Readiness', () => { + it('keeps Phase 2 collecting until enough shadow data exists', () => { + const assessment = assessMemberWorkSyncPhase2Readiness({ + memberCount: 0, + recentEvents: [], + }); + + expect(assessment.state).toBe('collecting_shadow_data'); + expect(assessment.reasons).toEqual([ + 'insufficient_members', + 'insufficient_status_events', + 'insufficient_observation_window', + ]); + }); + + it('reports shadow-ready only when sample size and rates are acceptable', () => { + const assessment = assessMemberWorkSyncPhase2Readiness({ + memberCount: 2, + recentEvents: statusEvents(24), + }); + + expect(assessment.state).toBe('shadow_ready'); + expect(assessment.reasons).toEqual([]); + expect(assessment.rates.statusEventCount).toBe(24); + expect(assessment.rates.observationHours).toBeGreaterThan(1); + }); + + it('blocks Phase 2 when would-nudge or fingerprint churn rates are too high', () => { + const base = statusEvents(24); + const noisyEvents = [ + ...base, + ...base + .slice(0, 8) + .map((source, index) => event(100 + index, 'would_nudge', source.recordedAt)), + ...base + .slice(0, 5) + .map((source, index) => event(200 + index, 'fingerprint_changed', source.recordedAt)), + ]; + + const assessment = assessMemberWorkSyncPhase2Readiness({ + memberCount: 1, + recentEvents: noisyEvents, + }); + + expect(assessment.state).toBe('blocked'); + expect(assessment.reasons).toContain('would_nudge_rate_high'); + expect(assessment.reasons).toContain('fingerprint_churn_high'); + }); +}); diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index a1af8cf3..f5acfff2 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -144,5 +144,12 @@ describe('JsonMemberWorkSyncStore', () => { 'status_evaluated', 'fingerprint_changed', ]); + expect(metrics.phase2Readiness).toMatchObject({ + state: 'collecting_shadow_data', + reasons: expect.arrayContaining([ + 'insufficient_status_events', + 'insufficient_observation_window', + ]), + }); }); }); From ec7935e593b18efeb46f888da70670b6d0c5d23a Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:15:42 +0300 Subject: [PATCH 17/74] feat(member-work-sync): add durable nudge outbox store --- .../member-work-sync-control-plane-plan.md | 1 + .../member-work-sync/contracts/types.ts | 93 +++++++ .../core/application/ports.ts | 16 ++ .../infrastructure/JsonMemberWorkSyncStore.ts | 240 +++++++++++++++++- .../MemberWorkSyncStorePaths.ts | 4 + .../main/JsonMemberWorkSyncStore.test.ts | 113 ++++++++- 6 files changed, 465 insertions(+), 2 deletions(-) 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 5a17c0b3..14f64d56 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -34,6 +34,7 @@ Current implementation note: - Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface. - 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 an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index d7a8566b..9f74309d 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -205,3 +205,96 @@ export interface MemberWorkSyncStatusRequest { export interface MemberWorkSyncMetricsRequest { teamName: string; } + +export type MemberWorkSyncOutboxStatus = + | 'pending' + | 'claimed' + | 'delivered' + | 'superseded' + | 'failed_retryable' + | 'failed_terminal'; + +export interface MemberWorkSyncNudgePayload { + from: 'system'; + to: string; + messageKind: 'member_work_sync_nudge'; + source: 'member-work-sync'; + actionMode: 'do'; + text: string; + taskRefs: Array<{ + taskId: string; + displayId: string; + teamName: string; + }>; +} + +export interface MemberWorkSyncOutboxItem { + id: string; + teamName: string; + memberName: string; + agendaFingerprint: string; + payloadHash: string; + payload: MemberWorkSyncNudgePayload; + status: MemberWorkSyncOutboxStatus; + attemptGeneration: number; + claimedBy?: string; + claimedAt?: string; + deliveredMessageId?: string; + lastError?: string; + nextAttemptAt?: string; + createdAt: string; + updatedAt: string; +} + +export type MemberWorkSyncOutboxEnsureResult = + | { ok: true; outcome: 'created' | 'existing'; item: MemberWorkSyncOutboxItem } + | { + ok: false; + outcome: 'payload_conflict'; + item: MemberWorkSyncOutboxItem; + existingPayloadHash: string; + requestedPayloadHash: string; + }; + +export interface MemberWorkSyncOutboxEnsureInput { + id: string; + teamName: string; + memberName: string; + agendaFingerprint: string; + payloadHash: string; + payload: MemberWorkSyncNudgePayload; + nowIso: string; + nextAttemptAt?: string; +} + +export interface MemberWorkSyncOutboxClaimInput { + teamName: string; + claimedBy: string; + nowIso: string; + limit: number; +} + +export interface MemberWorkSyncOutboxMarkDeliveredInput { + teamName: string; + id: string; + attemptGeneration: number; + deliveredMessageId: string; + nowIso: string; +} + +export interface MemberWorkSyncOutboxMarkSupersededInput { + teamName: string; + id: string; + reason: string; + nowIso: string; +} + +export interface MemberWorkSyncOutboxMarkFailedInput { + teamName: string; + id: string; + attemptGeneration: number; + error: string; + retryable: boolean; + nowIso: string; + nextAttemptAt?: string; +} diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index b3865eb8..47487cd8 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -2,6 +2,13 @@ import type { MemberWorkSyncAgenda, MemberWorkSyncTeamMetrics, MemberWorkSyncProviderId, + MemberWorkSyncOutboxClaimInput, + MemberWorkSyncOutboxEnsureInput, + MemberWorkSyncOutboxEnsureResult, + MemberWorkSyncOutboxItem, + MemberWorkSyncOutboxMarkDeliveredInput, + MemberWorkSyncOutboxMarkFailedInput, + MemberWorkSyncOutboxMarkSupersededInput, MemberWorkSyncReport, MemberWorkSyncReportIntent, MemberWorkSyncReportIntentStatus, @@ -87,12 +94,21 @@ export interface MemberWorkSyncReportStorePort { ): Promise; } +export interface MemberWorkSyncOutboxStorePort { + ensurePending(input: MemberWorkSyncOutboxEnsureInput): Promise; + claimDue(input: MemberWorkSyncOutboxClaimInput): Promise; + markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise; + markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise; + markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise; +} + export interface MemberWorkSyncUseCaseDeps { clock: MemberWorkSyncClockPort; hash: MemberWorkSyncHashPort; agendaSource: MemberWorkSyncAgendaSourcePort; statusStore: MemberWorkSyncStatusStorePort; reportStore?: MemberWorkSyncReportStorePort; + outboxStore?: MemberWorkSyncOutboxStorePort; reportToken?: MemberWorkSyncReportTokenPort; lifecycle?: MemberWorkSyncLifecyclePort; logger?: MemberWorkSyncLoggerPort; diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index d7b61e8a..586c1c34 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -5,6 +5,13 @@ import { mkdir, readFile, rename } from 'fs/promises'; import { withFileLock } from '@main/services/team/fileLock'; import type { MemberWorkSyncMetricEvent, + MemberWorkSyncOutboxClaimInput, + MemberWorkSyncOutboxEnsureInput, + MemberWorkSyncOutboxEnsureResult, + MemberWorkSyncOutboxItem, + MemberWorkSyncOutboxMarkDeliveredInput, + MemberWorkSyncOutboxMarkFailedInput, + MemberWorkSyncOutboxMarkSupersededInput, MemberWorkSyncReportIntent, MemberWorkSyncReportRequest, MemberWorkSyncStatus, @@ -13,6 +20,7 @@ import type { } from '../../contracts'; import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain'; import type { + MemberWorkSyncOutboxStorePort, MemberWorkSyncReportStorePort, MemberWorkSyncStatusStorePort, } from '../../core/application'; @@ -31,6 +39,11 @@ interface PendingReportFile { intents: Record; } +interface OutboxFile { + schemaVersion: 1; + items: Record; +} + function normalizeMemberKey(memberName: string): string { return memberName.trim().toLowerCase(); } @@ -68,6 +81,31 @@ function isPendingReportFile(value: unknown): value is PendingReportFile { ); } +function isOutboxFile(value: unknown): value is OutboxFile { + return ( + value != null && + typeof value === 'object' && + (value as OutboxFile).schemaVersion === 1 && + (value as OutboxFile).items != null && + typeof (value as OutboxFile).items === 'object' && + !Array.isArray((value as OutboxFile).items) + ); +} + +function isOutboxTerminal(status: MemberWorkSyncOutboxItem['status']): boolean { + return status === 'delivered' || status === 'superseded' || status === 'failed_terminal'; +} + +function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boolean { + if (item.status !== 'pending' && item.status !== 'failed_retryable') { + return false; + } + if (!item.nextAttemptAt) { + return true; + } + return item.nextAttemptAt <= nowIso; +} + function stableStringify(value: unknown): string { if (value == null || typeof value !== 'object') { return JSON.stringify(value); @@ -194,7 +232,10 @@ async function quarantineFile(filePath: string): Promise { } export class JsonMemberWorkSyncStore - implements MemberWorkSyncStatusStorePort, MemberWorkSyncReportStorePort + implements + MemberWorkSyncStatusStorePort, + MemberWorkSyncReportStorePort, + MemberWorkSyncOutboxStorePort { private readonly writeQueues = new Map>(); @@ -317,6 +358,161 @@ export class JsonMemberWorkSyncStore }); } + async ensurePending( + input: MemberWorkSyncOutboxEnsureInput + ): Promise { + let result: MemberWorkSyncOutboxEnsureResult | null = null; + await this.enqueue(input.teamName, async () => { + await withFileLock(this.paths.getOutboxPath(input.teamName), async () => { + const existing = await this.readOutboxFile(input.teamName); + const current = existing.items[input.id]; + if (current) { + if (current.payloadHash !== input.payloadHash) { + result = { + ok: false, + outcome: 'payload_conflict', + item: current, + existingPayloadHash: current.payloadHash, + requestedPayloadHash: input.payloadHash, + }; + return; + } + + if (!isOutboxTerminal(current.status) && current.status !== 'pending') { + const next: MemberWorkSyncOutboxItem = { + ...current, + status: 'pending', + updatedAt: input.nowIso, + }; + const nextAttemptAt = input.nextAttemptAt ?? current.nextAttemptAt; + if (nextAttemptAt) { + next.nextAttemptAt = nextAttemptAt; + } else { + delete next.nextAttemptAt; + } + delete next.claimedBy; + delete next.claimedAt; + delete next.lastError; + existing.items[input.id] = next; + await this.writeOutboxFile(input.teamName, existing); + result = { ok: true, outcome: 'existing', item: existing.items[input.id] }; + return; + } + + result = { ok: true, outcome: 'existing', item: current }; + return; + } + + const item: MemberWorkSyncOutboxItem = { + id: input.id, + teamName: input.teamName, + memberName: input.memberName, + agendaFingerprint: input.agendaFingerprint, + payloadHash: input.payloadHash, + payload: input.payload, + status: 'pending', + attemptGeneration: 0, + ...(input.nextAttemptAt ? { nextAttemptAt: input.nextAttemptAt } : {}), + createdAt: input.nowIso, + updatedAt: input.nowIso, + }; + existing.items[input.id] = item; + await this.writeOutboxFile(input.teamName, existing); + result = { ok: true, outcome: 'created', item }; + }); + }); + + if (!result) { + throw new Error('Member work sync outbox write did not produce a result'); + } + return result; + } + + async claimDue(input: MemberWorkSyncOutboxClaimInput): Promise { + const claimed: MemberWorkSyncOutboxItem[] = []; + await this.enqueue(input.teamName, async () => { + await withFileLock(this.paths.getOutboxPath(input.teamName), async () => { + const existing = await this.readOutboxFile(input.teamName); + const due = Object.values(existing.items) + .filter((item) => canClaimOutboxItem(item, input.nowIso)) + .sort((left, right) => { + const leftTime = left.nextAttemptAt ?? left.updatedAt; + const rightTime = right.nextAttemptAt ?? right.updatedAt; + return leftTime.localeCompare(rightTime); + }) + .slice(0, Math.max(0, input.limit)); + + for (const item of due) { + const next: MemberWorkSyncOutboxItem = { + ...item, + status: 'claimed', + attemptGeneration: item.attemptGeneration + 1, + claimedBy: input.claimedBy, + claimedAt: input.nowIso, + updatedAt: input.nowIso, + }; + delete next.lastError; + existing.items[item.id] = next; + claimed.push(next); + } + + if (due.length > 0) { + await this.writeOutboxFile(input.teamName, existing); + } + }); + }); + return claimed; + } + + async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise { + await this.updateOutboxItem(input.teamName, input.id, (current) => { + if (!current || current.attemptGeneration !== input.attemptGeneration) { + return current; + } + const next: MemberWorkSyncOutboxItem = { + ...current, + status: 'delivered', + deliveredMessageId: input.deliveredMessageId, + updatedAt: input.nowIso, + }; + delete next.lastError; + return next; + }); + } + + async markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise { + await this.updateOutboxItem(input.teamName, input.id, (current) => { + if (!current || isOutboxTerminal(current.status)) { + return current; + } + return { + ...current, + status: 'superseded', + lastError: input.reason, + updatedAt: input.nowIso, + }; + }); + } + + async markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise { + await this.updateOutboxItem(input.teamName, input.id, (current) => { + if (!current || current.attemptGeneration !== input.attemptGeneration) { + return current; + } + const next: MemberWorkSyncOutboxItem = { + ...current, + status: input.retryable ? 'failed_retryable' : 'failed_terminal', + lastError: input.error, + ...(input.retryable && input.nextAttemptAt ? { nextAttemptAt: input.nextAttemptAt } : {}), + updatedAt: input.nowIso, + }; + if (!input.retryable) { + delete next.nextAttemptAt; + } + return next; + }); + } + private async readFile(teamName: string): Promise { const filePath = this.paths.getStatusPath(teamName); try { @@ -351,6 +547,48 @@ export class JsonMemberWorkSyncStore return { schemaVersion: 1, intents: {} }; } + private async readOutboxFile(teamName: string): Promise { + const filePath = this.paths.getOutboxPath(teamName); + try { + const raw = await readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw); + if (isOutboxFile(parsed)) { + return parsed; + } + await quarantineFile(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + await quarantineFile(filePath); + } + } + return { schemaVersion: 1, items: {} }; + } + + private async writeOutboxFile(teamName: string, file: OutboxFile): Promise { + await mkdir(this.paths.getTeamDir(teamName), { recursive: true }); + await atomicWriteAsync(this.paths.getOutboxPath(teamName), JSON.stringify(file, null, 2)); + } + + private async updateOutboxItem( + teamName: string, + id: string, + updater: ( + current: MemberWorkSyncOutboxItem | undefined + ) => MemberWorkSyncOutboxItem | undefined + ): Promise { + await this.enqueue(teamName, async () => { + await withFileLock(this.paths.getOutboxPath(teamName), async () => { + const existing = await this.readOutboxFile(teamName); + const next = updater(existing.items[id]); + if (!next) { + return; + } + existing.items[id] = next; + await this.writeOutboxFile(teamName, existing); + }); + }); + } + private async writePendingFile(teamName: string, file: PendingReportFile): Promise { await mkdir(this.paths.getTeamDir(teamName), { recursive: true }); await atomicWriteAsync( diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts index 6d72b670..55d1facc 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts @@ -15,6 +15,10 @@ export class MemberWorkSyncStorePaths { return join(this.getTeamDir(teamName), 'pending-reports.json'); } + getOutboxPath(teamName: string): string { + return join(this.getTeamDir(teamName), 'outbox.json'); + } + getReportTokenSecretPath(teamName: string): string { return join(this.getTeamDir(teamName), 'report-token-secret.json'); } diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index f5acfff2..6d7685a3 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; -import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; +import type { + MemberWorkSyncNudgePayload, + MemberWorkSyncStatus, +} from '@features/member-work-sync/contracts'; function makeStatus(overrides: Partial): MemberWorkSyncStatus { return { @@ -42,6 +45,19 @@ function makeStatus(overrides: Partial): MemberWorkSyncSta }; } +function makeNudgePayload(overrides: Partial = {}): MemberWorkSyncNudgePayload { + return { + from: 'system', + to: 'bob', + messageKind: 'member_work_sync_nudge', + source: 'member-work-sync', + actionMode: 'do', + text: 'Work sync check: continue the current task or report a blocker.', + taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], + ...overrides, + }; +} + describe('JsonMemberWorkSyncStore', () => { let root: string; let store: JsonMemberWorkSyncStore; @@ -152,4 +168,99 @@ describe('JsonMemberWorkSyncStore', () => { ]), }); }); + + it('deduplicates outbox items by id and rejects payload hash conflicts', async () => { + const input = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + + await expect(store.ensurePending(input)).resolves.toMatchObject({ + ok: true, + outcome: 'created', + item: { status: 'pending', attemptGeneration: 0 }, + }); + await expect(store.ensurePending(input)).resolves.toMatchObject({ + ok: true, + outcome: 'existing', + }); + await expect(store.ensurePending({ ...input, payloadHash: 'hash-b' })).resolves.toMatchObject({ + ok: false, + outcome: 'payload_conflict', + existingPayloadHash: 'hash-a', + requestedPayloadHash: 'hash-b', + }); + }); + + it('claims due outbox items and fences terminal updates by attempt generation', async () => { + const input = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + await store.ensurePending(input); + + const claimed = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 5, + }); + expect(claimed).toHaveLength(1); + expect(claimed[0]).toMatchObject({ + id: input.id, + status: 'claimed', + attemptGeneration: 1, + claimedBy: 'dispatcher-a', + }); + + await store.markDelivered({ + teamName: 'team-a', + id: input.id, + attemptGeneration: 0, + deliveredMessageId: 'wrong-generation', + nowIso: '2026-04-29T00:02:00.000Z', + }); + await expect( + store.ensurePending({ + ...input, + nowIso: '2026-04-29T00:03:00.000Z', + }) + ).resolves.toMatchObject({ + ok: true, + item: { status: 'pending', attemptGeneration: 1 }, + }); + + const claimedAgain = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:04:00.000Z', + limit: 1, + }); + await store.markDelivered({ + teamName: 'team-a', + id: input.id, + attemptGeneration: claimedAgain[0].attemptGeneration, + deliveredMessageId: 'message-1', + nowIso: '2026-04-29T00:05:00.000Z', + }); + + const file = JSON.parse( + await readFile(join(root, 'team-a', '.member-work-sync', 'outbox.json'), 'utf8') + ); + expect(file.items[input.id]).toMatchObject({ + status: 'delivered', + deliveredMessageId: 'message-1', + attemptGeneration: 2, + }); + }); }); From 3552a4ba61fd08b1b2cf5f8d634cf3d743ccf183 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:20:51 +0300 Subject: [PATCH 18/74] feat(member-work-sync): plan nudges behind readiness --- .../member-work-sync-control-plane-plan.md | 1 + .../MemberWorkSyncNudgeOutboxPlanner.ts | 56 +++++++++ .../application/MemberWorkSyncReconciler.ts | 20 ++- .../core/application/index.ts | 1 + .../core/domain/MemberWorkSyncNudge.ts | 105 ++++++++++++++++ .../member-work-sync/core/domain/index.ts | 1 + .../createMemberWorkSyncFeature.ts | 1 + .../core/MemberWorkSyncUseCases.test.ts | 118 ++++++++++++++++++ 8 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts create mode 100644 src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts 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 14f64d56..9d51cfd9 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -35,6 +35,7 @@ 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 an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet. +- Reconciler can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; otherwise it records normal shadow status and does nothing. This preserves the anti-spam gate before any dispatcher exists. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts new file mode 100644 index 00000000..8281a87a --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts @@ -0,0 +1,56 @@ +import type { MemberWorkSyncStatus } from '../../contracts'; +import { buildMemberWorkSyncOutboxEnsureInput } from '../domain'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +export interface MemberWorkSyncNudgeOutboxPlanResult { + planned: boolean; + code: + | 'outbox_unavailable' + | 'metrics_unavailable' + | 'status_not_nudgeable' + | 'phase2_not_ready' + | 'created' + | 'existing' + | 'payload_conflict'; +} + +export class MemberWorkSyncNudgeOutboxPlanner { + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} + + async plan(status: MemberWorkSyncStatus): Promise { + if (!this.deps.outboxStore) { + return { planned: false, code: 'outbox_unavailable' }; + } + if (!this.deps.statusStore.readTeamMetrics) { + return { planned: false, code: 'metrics_unavailable' }; + } + + const input = buildMemberWorkSyncOutboxEnsureInput({ + status, + hash: this.deps.hash, + nowIso: status.evaluatedAt, + }); + if (!input) { + return { planned: false, code: 'status_not_nudgeable' }; + } + + const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName); + if (metrics.phase2Readiness.state !== 'shadow_ready') { + return { planned: false, code: 'phase2_not_ready' }; + } + + const result = await this.deps.outboxStore.ensurePending(input); + if (!result.ok) { + this.deps.logger?.warn('member work sync nudge outbox payload conflict', { + teamName: status.teamName, + memberName: status.memberName, + outboxId: input.id, + existingPayloadHash: result.existingPayloadHash, + requestedPayloadHash: result.requestedPayloadHash, + }); + return { planned: false, code: 'payload_conflict' }; + } + + return { planned: true, code: result.outcome }; + } +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index a98d9cf6..1459d06e 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -6,6 +6,7 @@ import { } from '../domain'; import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports'; +import { MemberWorkSyncNudgeOutboxPlanner } from './MemberWorkSyncNudgeOutboxPlanner'; export interface MemberWorkSyncReconcileContext { reconciledBy?: 'request' | 'queue'; @@ -33,7 +34,11 @@ export function finalizeMemberWorkSyncAgenda( } export class MemberWorkSyncReconciler { - constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} + private readonly nudgeOutboxPlanner: MemberWorkSyncNudgeOutboxPlanner; + + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) { + this.nudgeOutboxPlanner = new MemberWorkSyncNudgeOutboxPlanner(deps); + } async execute( request: MemberWorkSyncStatusRequest, @@ -82,8 +87,21 @@ export class MemberWorkSyncReconciler { }); await this.deps.statusStore.write(status); + await this.planNudgeOutbox(status); return status; } + + private async planNudgeOutbox(status: MemberWorkSyncStatus): Promise { + const result = await this.nudgeOutboxPlanner.plan(status); + if (result.code !== 'outbox_unavailable' && result.code !== 'status_not_nudgeable') { + this.deps.logger?.debug('member work sync nudge outbox planning result', { + teamName: status.teamName, + memberName: status.memberName, + code: result.code, + planned: result.planned, + }); + } + } } export async function attachMemberWorkSyncReportToken( diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index b47b511c..9391fad3 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -1,5 +1,6 @@ export * from './MemberWorkSyncDiagnosticsReader'; export * from './MemberWorkSyncMetricsReader'; +export * from './MemberWorkSyncNudgeOutboxPlanner'; export * from './MemberWorkSyncPendingReportIntentReplayer'; export * from './MemberWorkSyncReconciler'; export * from './MemberWorkSyncReporter'; diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts new file mode 100644 index 00000000..4b48a074 --- /dev/null +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts @@ -0,0 +1,105 @@ +import type { + MemberWorkSyncNudgePayload, + MemberWorkSyncOutboxEnsureInput, + MemberWorkSyncStatus, +} from '../../contracts'; + +export const MEMBER_WORK_SYNC_NUDGE_ID_PREFIX = 'member-work-sync'; + +interface MemberWorkSyncNudgeHash { + sha256Hex(value: string): string; +} + +function stableJson(value: unknown): string { + if (value == null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(',')}]`; + } + + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(',')}}`; +} + +export function buildMemberWorkSyncNudgeId(input: { + teamName: string; + memberName: string; + agendaFingerprint: string; +}): string { + return [ + MEMBER_WORK_SYNC_NUDGE_ID_PREFIX, + input.teamName, + input.memberName.trim().toLowerCase(), + input.agendaFingerprint, + ].join(':'); +} + +export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload { + const taskRefs = status.agenda.items.map((item) => ({ + teamName: status.teamName, + taskId: item.taskId, + displayId: item.displayId ?? item.taskId.slice(0, 8), + })); + const preview = status.agenda.items + .slice(0, 3) + .map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`) + .join('; '); + + return { + from: 'system', + to: status.memberName, + messageKind: 'member_work_sync_nudge', + source: 'member-work-sync', + actionMode: 'do', + taskRefs, + 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.', + 'Do not reply only with acknowledgement.', + ] + .filter(Boolean) + .join('\n'), + }; +} + +export function buildMemberWorkSyncNudgePayloadHash( + hash: MemberWorkSyncNudgeHash, + payload: MemberWorkSyncNudgePayload +): string { + return hash.sha256Hex(stableJson(payload)); +} + +export function buildMemberWorkSyncOutboxEnsureInput(input: { + status: MemberWorkSyncStatus; + hash: MemberWorkSyncNudgeHash; + nowIso: string; +}): MemberWorkSyncOutboxEnsureInput | null { + const status = input.status; + if ( + status.state !== 'needs_sync' || + status.shadow?.wouldNudge !== true || + status.agenda.items.length === 0 + ) { + return null; + } + + const payload = buildMemberWorkSyncNudgePayload(status); + return { + id: buildMemberWorkSyncNudgeId({ + teamName: status.teamName, + memberName: status.memberName, + agendaFingerprint: status.agenda.fingerprint, + }), + teamName: status.teamName, + memberName: status.memberName, + agendaFingerprint: status.agenda.fingerprint, + payloadHash: buildMemberWorkSyncNudgePayloadHash(input.hash, payload), + payload, + nowIso: input.nowIso, + }; +} diff --git a/src/features/member-work-sync/core/domain/index.ts b/src/features/member-work-sync/core/domain/index.ts index 11ba63d6..2925168c 100644 --- a/src/features/member-work-sync/core/domain/index.ts +++ b/src/features/member-work-sync/core/domain/index.ts @@ -3,5 +3,6 @@ export * from './AgendaFingerprint'; export * from './currentReviewCycle'; export * from './memberName'; export * from './MemberWorkSyncPhase2Readiness'; +export * from './MemberWorkSyncNudge'; export * from './MemberWorkSyncReportValidator'; export * from './SyncDecisionPolicy'; diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 40034a4e..ce76d27f 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -73,6 +73,7 @@ export function createMemberWorkSyncFeature(deps: { agendaSource, statusStore: store, reportStore: store, + outboxStore: store, reportToken, ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), logger: deps.logger, diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 18fea140..23060c3d 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -5,14 +5,22 @@ import { MemberWorkSyncPendingReportIntentReplayer, MemberWorkSyncReporter, type MemberWorkSyncAgendaSourceResult, + type MemberWorkSyncOutboxStorePort, type MemberWorkSyncStatusStorePort, type MemberWorkSyncUseCaseDeps, } from '@features/member-work-sync/core/application'; import type { MemberWorkSyncActionableWorkItem, + MemberWorkSyncOutboxEnsureInput, + MemberWorkSyncOutboxItem, + MemberWorkSyncOutboxMarkDeliveredInput, + MemberWorkSyncOutboxMarkFailedInput, + MemberWorkSyncOutboxMarkSupersededInput, + MemberWorkSyncPhase2ReadinessState, MemberWorkSyncReportIntent, MemberWorkSyncReportRequest, MemberWorkSyncStatus, + MemberWorkSyncTeamMetrics, } from '@features/member-work-sync/contracts'; const workItem: MemberWorkSyncActionableWorkItem = { @@ -45,6 +53,7 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { readonly writes: MemberWorkSyncStatus[] = []; readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = []; readonly pendingIntents = new Map(); + phase2ReadinessState: MemberWorkSyncPhase2ReadinessState = 'collecting_shadow_data'; async read(): Promise { return this.writes.at(-1) ?? null; @@ -76,6 +85,74 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { this.pendingIntents.set(id, { ...current, ...result }); } } + + async readTeamMetrics(teamName: string): Promise { + return { + teamName, + generatedAt: '2026-04-29T00:00:00.000Z', + memberCount: 1, + stateCounts: { + caught_up: 0, + needs_sync: 1, + still_working: 0, + blocked: 0, + inactive: 0, + unknown: 0, + }, + actionableItemCount: this.writes.at(-1)?.agenda.items.length ?? 0, + wouldNudgeCount: 1, + fingerprintChangeCount: 0, + reportAcceptedCount: 0, + reportRejectedCount: 0, + recentEvents: [], + phase2Readiness: { + state: this.phase2ReadinessState, + reasons: [], + thresholds: { + minObservedMembers: 1, + minStatusEvents: 20, + minObservationHours: 1, + maxWouldNudgesPerMemberHour: 2, + maxFingerprintChangesPerMemberHour: 1, + maxReportRejectionRate: 0.2, + }, + rates: { + observationHours: 2, + statusEventCount: 30, + wouldNudgesPerMemberHour: 0.5, + fingerprintChangesPerMemberHour: 0, + reportRejectionRate: 0, + }, + diagnostics: [], + }, + }; + } +} + +class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { + readonly ensures: MemberWorkSyncOutboxEnsureInput[] = []; + + async ensurePending(input: MemberWorkSyncOutboxEnsureInput) { + this.ensures.push(input); + const item: MemberWorkSyncOutboxItem = { + ...input, + status: 'pending', + attemptGeneration: 0, + createdAt: input.nowIso, + updatedAt: input.nowIso, + }; + return { ok: true as const, outcome: 'created' as const, item }; + } + + async claimDue(): Promise { + return []; + } + + async markDelivered(_input: MemberWorkSyncOutboxMarkDeliveredInput): Promise {} + + async markSuperseded(_input: MemberWorkSyncOutboxMarkSupersededInput): Promise {} + + async markFailed(_input: MemberWorkSyncOutboxMarkFailedInput): Promise {} } function createDeps(options?: { @@ -84,6 +161,7 @@ function createDeps(options?: { inactive?: boolean; teamActive?: boolean; providerId?: 'opencode' | 'codex'; + outboxStore?: MemberWorkSyncOutboxStorePort; }) { const clock = new MutableClock(); const store = new InMemoryStatusStore(); @@ -110,6 +188,7 @@ function createDeps(options?: { }, statusStore: store, reportStore: store, + ...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}), reportToken: { create: async (input) => ({ token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`, @@ -277,6 +356,45 @@ describe('MemberWorkSync use cases', () => { expect(changed.state).toBe('needs_sync'); }); + it('does not create outbox nudges until shadow readiness is green', async () => { + const outbox = new InMemoryOutboxStore(); + const { deps } = createDeps({ outboxStore: outbox }); + + await new MemberWorkSyncDiagnosticsReader(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + }); + + expect(outbox.ensures).toEqual([]); + }); + + it('creates one idempotent outbox nudge intent when Phase 2 readiness is green', async () => { + const outbox = new InMemoryOutboxStore(); + const { deps, store } = createDeps({ outboxStore: outbox }); + store.phase2ReadinessState = 'shadow_ready'; + + const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + }); + + expect(outbox.ensures).toHaveLength(1); + expect(outbox.ensures[0]).toMatchObject({ + id: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: status.agenda.fingerprint, + payload: { + from: 'system', + to: 'bob', + messageKind: 'member_work_sync_nudge', + source: 'member-work-sync', + actionMode: 'do', + taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], + }, + }); + }); + it('rejects invalid report tokens without recording replayable intents', async () => { const { deps, store } = createDeps(); const reader = new MemberWorkSyncDiagnosticsReader(deps); From 23714f5ca8f52848d82c9d36833019260ec0328b Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:26:06 +0300 Subject: [PATCH 19/74] feat(member-work-sync): add guarded nudge dispatcher --- .../member-work-sync-control-plane-plan.md | 1 + .../MemberWorkSyncNudgeDispatcher.ts | 192 ++++++++++++++++++ .../core/application/index.ts | 1 + .../core/application/ports.ts | 31 +++ .../TeamInboxMemberWorkSyncNudgeSink.ts | 36 ++++ .../createMemberWorkSyncFeature.ts | 9 + src/shared/types/team.ts | 3 +- .../core/MemberWorkSyncUseCases.test.ts | 118 ++++++++++- 8 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts create mode 100644 src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts 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 9d51cfd9..aa5d7e3d 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -36,6 +36,7 @@ Current implementation note: - 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 an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet. - Reconciler can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; otherwise it records normal shadow status and does nothing. This preserves the anti-spam gate before any dispatcher exists. +- Dispatcher use case exists behind explicit facade invocation. 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. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts new file mode 100644 index 00000000..47d8ce87 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -0,0 +1,192 @@ +import type { MemberWorkSyncOutboxItem } from '../../contracts'; +import type { MemberWorkSyncUseCaseDeps } from './ports'; + +export interface MemberWorkSyncNudgeDispatchSummary { + claimed: number; + delivered: number; + superseded: number; + retryable: number; + terminal: number; +} + +export interface MemberWorkSyncNudgeDispatchOptions { + claimedBy: string; + teamNames: string[]; + limit?: number; +} + +function emptySummary(): MemberWorkSyncNudgeDispatchSummary { + return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 }; +} + +function addMinutes(iso: string, minutes: number): string { + return new Date(Date.parse(iso) + minutes * 60_000).toISOString(); +} + +export class MemberWorkSyncNudgeDispatcher { + constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} + + async dispatchDue(options: MemberWorkSyncNudgeDispatchOptions): Promise { + const outbox = this.deps.outboxStore; + const inbox = this.deps.inboxNudge; + if (!outbox || !inbox) { + return emptySummary(); + } + + const nowIso = this.deps.clock.now().toISOString(); + const summary = emptySummary(); + for (const teamName of [...new Set(options.teamNames.map((name) => name.trim()).filter(Boolean))]) { + const claimed = await outbox.claimDue({ + teamName, + claimedBy: options.claimedBy, + nowIso, + limit: options.limit ?? 10, + }); + summary.claimed += claimed.length; + for (const item of claimed) { + const result = await this.dispatchItem(item, nowIso); + summary[result] += 1; + } + } + return summary; + } + + private async dispatchItem( + item: MemberWorkSyncOutboxItem, + nowIso: string + ): Promise> { + const outbox = this.deps.outboxStore; + const inbox = this.deps.inboxNudge; + if (!outbox || !inbox) { + return 'terminal'; + } + + const revalidation = await this.revalidate(item, nowIso); + if (!revalidation.ok) { + if (revalidation.retryable) { + await outbox.markFailed({ + teamName: item.teamName, + id: item.id, + attemptGeneration: item.attemptGeneration, + error: revalidation.reason, + retryable: true, + nowIso, + nextAttemptAt: revalidation.nextAttemptAt ?? addMinutes(nowIso, 10), + }); + return 'retryable'; + } + await outbox.markSuperseded({ + teamName: item.teamName, + id: item.id, + reason: revalidation.reason, + nowIso, + }); + return 'superseded'; + } + + try { + const inserted = await inbox.insertIfAbsent({ + teamName: item.teamName, + memberName: item.memberName, + messageId: item.id, + payloadHash: item.payloadHash, + payload: item.payload, + timestamp: nowIso, + }); + if (inserted.conflict) { + await outbox.markFailed({ + teamName: item.teamName, + id: item.id, + attemptGeneration: item.attemptGeneration, + error: 'inbox_payload_conflict', + retryable: false, + nowIso, + }); + return 'terminal'; + } + await outbox.markDelivered({ + teamName: item.teamName, + id: item.id, + attemptGeneration: item.attemptGeneration, + deliveredMessageId: inserted.messageId, + nowIso, + }); + return 'delivered'; + } catch (error) { + await outbox.markFailed({ + teamName: item.teamName, + id: item.id, + attemptGeneration: item.attemptGeneration, + error: String(error), + retryable: true, + nowIso, + nextAttemptAt: addMinutes(nowIso, 10), + }); + return 'retryable'; + } + } + + private async revalidate( + item: MemberWorkSyncOutboxItem, + nowIso: string + ): Promise< + | { ok: true } + | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } + > { + if (this.deps.lifecycle && !(await this.deps.lifecycle.isTeamActive(item.teamName))) { + return { ok: false, reason: 'team_inactive', retryable: false }; + } + + const status = await this.deps.statusStore.read({ + teamName: item.teamName, + memberName: item.memberName, + }); + if (!status) { + return { ok: false, reason: 'status_missing', retryable: false }; + } + if ( + status.state !== 'needs_sync' || + status.shadow?.wouldNudge !== true || + status.agenda.fingerprint !== item.agendaFingerprint + ) { + return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false }; + } + + if (!this.deps.statusStore.readTeamMetrics) { + return { ok: false, reason: 'metrics_unavailable', retryable: true }; + } + const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName); + if (metrics.phase2Readiness.state !== 'shadow_ready') { + return { ok: false, reason: 'phase2_not_ready', retryable: true }; + } + + const busy = await this.deps.busySignal?.isBusy({ + teamName: item.teamName, + memberName: item.memberName, + nowIso, + }); + if (busy?.busy) { + return { + ok: false, + reason: `member_busy:${busy.reason ?? 'unknown'}`, + retryable: true, + nextAttemptAt: busy.retryAfterIso, + }; + } + + const taskIds = item.payload.taskRefs.map((taskRef) => taskRef.taskId); + if ( + this.deps.watchdogCooldown && + (await this.deps.watchdogCooldown.hasRecentNudge({ + teamName: item.teamName, + memberName: item.memberName, + taskIds, + nowIso, + })) + ) { + return { ok: false, reason: 'watchdog_cooldown_active', retryable: true }; + } + + return { ok: true }; + } +} diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 9391fad3..be3d297f 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -1,5 +1,6 @@ export * from './MemberWorkSyncDiagnosticsReader'; export * from './MemberWorkSyncMetricsReader'; +export * from './MemberWorkSyncNudgeDispatcher'; export * from './MemberWorkSyncNudgeOutboxPlanner'; export * from './MemberWorkSyncPendingReportIntentReplayer'; export * from './MemberWorkSyncReconciler'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 47487cd8..362e3da5 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -102,6 +102,34 @@ export interface MemberWorkSyncOutboxStorePort { markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise; } +export interface MemberWorkSyncInboxNudgePort { + insertIfAbsent(input: { + teamName: string; + memberName: string; + messageId: string; + payloadHash: string; + payload: MemberWorkSyncOutboxItem['payload']; + timestamp: string; + }): Promise<{ inserted: boolean; messageId: string; conflict?: boolean }>; +} + +export interface MemberWorkSyncWatchdogCooldownPort { + hasRecentNudge(input: { + teamName: string; + memberName: string; + taskIds: string[]; + nowIso: string; + }): Promise; +} + +export interface MemberWorkSyncBusySignalPort { + isBusy(input: { + teamName: string; + memberName: string; + nowIso: string; + }): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>; +} + export interface MemberWorkSyncUseCaseDeps { clock: MemberWorkSyncClockPort; hash: MemberWorkSyncHashPort; @@ -109,6 +137,9 @@ export interface MemberWorkSyncUseCaseDeps { statusStore: MemberWorkSyncStatusStorePort; reportStore?: MemberWorkSyncReportStorePort; outboxStore?: MemberWorkSyncOutboxStorePort; + inboxNudge?: MemberWorkSyncInboxNudgePort; + watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort; + busySignal?: MemberWorkSyncBusySignalPort; reportToken?: MemberWorkSyncReportTokenPort; lifecycle?: MemberWorkSyncLifecyclePort; logger?: MemberWorkSyncLoggerPort; diff --git a/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts b/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts new file mode 100644 index 00000000..fc2e9778 --- /dev/null +++ b/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts @@ -0,0 +1,36 @@ +import { TeamInboxReader } from '@main/services/team/TeamInboxReader'; +import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; +import type { MemberWorkSyncInboxNudgePort } from '../../../core/application'; + +export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudgePort { + constructor( + private readonly inboxReader: Pick = new TeamInboxReader(), + private readonly inboxWriter: Pick = new TeamInboxWriter() + ) {} + + async insertIfAbsent(input: Parameters[0]) { + const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName); + if (existing.some((message) => message.messageId === input.messageId)) { + return { inserted: false, messageId: input.messageId }; + } + + const result = await this.inboxWriter.sendMessage(input.teamName, { + member: input.memberName, + from: input.payload.from, + to: input.payload.to, + messageId: input.messageId, + timestamp: input.timestamp, + text: input.payload.text, + taskRefs: input.payload.taskRefs, + actionMode: input.payload.actionMode, + summary: 'Work sync check', + source: 'system_notification', + messageKind: input.payload.messageKind, + }); + + return { + inserted: true, + messageId: result.messageId, + }; + } +} diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index ce76d27f..4f0ea231 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -9,6 +9,8 @@ import type { import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncMetricsReader, + MemberWorkSyncNudgeDispatcher, + type MemberWorkSyncNudgeDispatchSummary, MemberWorkSyncPendingReportIntentReplayer, type MemberWorkSyncPendingReportReplaySummary, MemberWorkSyncReconciler, @@ -16,6 +18,7 @@ import { type MemberWorkSyncReconcileContext, } from '../../core/application'; import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; +import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { @@ -41,6 +44,7 @@ export interface MemberWorkSyncFeatureFacade { noteTeamChange(event: TeamChangeEvent): void; enqueueStartupScan(teamNames: string[]): Promise; replayPendingReports(teamNames: string[]): Promise; + dispatchDueNudges(teamNames: string[]): Promise; getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics; dispose(): Promise; } @@ -67,6 +71,7 @@ export function createMemberWorkSyncFeature(deps: { const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath); const store = new JsonMemberWorkSyncStore(storePaths); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); + const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink(); const useCaseDeps = { clock, hash, @@ -74,6 +79,7 @@ export function createMemberWorkSyncFeature(deps: { statusStore: store, reportStore: store, outboxStore: store, + inboxNudge, reportToken, ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), logger: deps.logger, @@ -83,6 +89,7 @@ export function createMemberWorkSyncFeature(deps: { const reporter = new MemberWorkSyncReporter(useCaseDeps); const reconciler = new MemberWorkSyncReconciler(useCaseDeps); const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps); + const nudgeDispatcher = new MemberWorkSyncNudgeDispatcher(useCaseDeps); const queue = new MemberWorkSyncEventQueue({ reconcile: async (request, context: MemberWorkSyncReconcileContext) => { await reconciler.execute(request, context); @@ -116,6 +123,8 @@ export function createMemberWorkSyncFeature(deps: { { processed: 0, accepted: 0, rejected: 0, superseded: 0 } ); }, + dispatchDueNudges: (teamNames) => + nudgeDispatcher.dispatchDue({ teamNames, claimedBy: `member-work-sync:${process.pid}` }), getQueueDiagnostics: () => queue.getDiagnostics(), dispose: () => queue.stop(), }; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index beef1d6b..d35a58d0 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -413,7 +413,8 @@ export type InboxMessageKind = | 'default' | 'slash_command' | 'slash_command_result' - | 'task_comment_notification'; + | 'task_comment_notification' + | 'member_work_sync_nudge'; export interface SlashCommandMeta { name: string; diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 23060c3d..e08b077d 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'; import { MemberWorkSyncDiagnosticsReader, + MemberWorkSyncNudgeDispatcher, MemberWorkSyncPendingReportIntentReplayer, MemberWorkSyncReporter, type MemberWorkSyncAgendaSourceResult, + type MemberWorkSyncInboxNudgePort, type MemberWorkSyncOutboxStorePort, type MemberWorkSyncStatusStorePort, type MemberWorkSyncUseCaseDeps, @@ -131,9 +133,14 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { readonly ensures: MemberWorkSyncOutboxEnsureInput[] = []; + readonly items = new Map(); async ensurePending(input: MemberWorkSyncOutboxEnsureInput) { this.ensures.push(input); + const current = this.items.get(input.id); + if (current) { + return { ok: true as const, outcome: 'existing' as const, item: current }; + } const item: MemberWorkSyncOutboxItem = { ...input, status: 'pending', @@ -141,18 +148,59 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { createdAt: input.nowIso, updatedAt: input.nowIso, }; + this.items.set(input.id, item); return { ok: true as const, outcome: 'created' as const, item }; } async claimDue(): Promise { - return []; + const due = [...this.items.values()].filter((item) => item.status === 'pending'); + for (const item of due) { + this.items.set(item.id, { + ...item, + status: 'claimed', + attemptGeneration: item.attemptGeneration + 1, + }); + } + return due.map((item) => this.items.get(item.id) as MemberWorkSyncOutboxItem); } - async markDelivered(_input: MemberWorkSyncOutboxMarkDeliveredInput): Promise {} + async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise { + const current = this.items.get(input.id); + if (current?.attemptGeneration === input.attemptGeneration) { + this.items.set(input.id, { + ...current, + status: 'delivered', + deliveredMessageId: input.deliveredMessageId, + }); + } + } - async markSuperseded(_input: MemberWorkSyncOutboxMarkSupersededInput): Promise {} + async markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise { + const current = this.items.get(input.id); + if (current) { + this.items.set(input.id, { ...current, status: 'superseded', lastError: input.reason }); + } + } - async markFailed(_input: MemberWorkSyncOutboxMarkFailedInput): Promise {} + async markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise { + const current = this.items.get(input.id); + if (current?.attemptGeneration === input.attemptGeneration) { + this.items.set(input.id, { + ...current, + status: input.retryable ? 'failed_retryable' : 'failed_terminal', + lastError: input.error, + }); + } + } +} + +class InMemoryInboxNudge implements MemberWorkSyncInboxNudgePort { + readonly inserted: Array[0]> = []; + + async insertIfAbsent(input: Parameters[0]) { + this.inserted.push(input); + return { inserted: true, messageId: input.messageId }; + } } function createDeps(options?: { @@ -162,6 +210,7 @@ function createDeps(options?: { teamActive?: boolean; providerId?: 'opencode' | 'codex'; outboxStore?: MemberWorkSyncOutboxStorePort; + inboxNudge?: MemberWorkSyncInboxNudgePort; }) { const clock = new MutableClock(); const store = new InMemoryStatusStore(); @@ -189,6 +238,7 @@ function createDeps(options?: { statusStore: store, reportStore: store, ...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}), + ...(options?.inboxNudge ? { inboxNudge: options.inboxNudge } : {}), reportToken: { create: async (input) => ({ token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`, @@ -395,6 +445,66 @@ describe('MemberWorkSync use cases', () => { }); }); + it('dispatches due nudges only after revalidating current status and readiness', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); + store.phase2ReadinessState = 'shadow_ready'; + + const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + }); + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 }); + expect(inbox.inserted).toHaveLength(1); + expect(inbox.inserted[0]).toMatchObject({ + teamName: 'team-a', + 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({ + status: 'delivered', + deliveredMessageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`, + }); + }); + + it('does not dispatch stale outbox items after the member reports still working', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); + store.phase2ReadinessState = 'shadow_ready'; + + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, + leaseTtlMs: 120_000, + source: 'test', + }); + + 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:${current.agenda.fingerprint}`)).toMatchObject({ + status: 'superseded', + lastError: 'status_no_longer_matches_outbox', + }); + }); + it('rejects invalid report tokens without recording replayable intents', async () => { const { deps, store } = createDeps(); const reader = new MemberWorkSyncDiagnosticsReader(deps); From 895c3afc5ec3fa6059098ff708857bde55682844 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:29:49 +0300 Subject: [PATCH 20/74] feat(member-work-sync): dispatch nudges after reconcile --- .../member-work-sync-control-plane-plan.md | 2 +- .../TeamTaskStallJournalWorkSyncCooldown.ts | 63 +++++++++++++++ .../createMemberWorkSyncFeature.ts | 7 ++ ...amTaskStallJournalWorkSyncCooldown.test.ts | 78 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown.ts create mode 100644 test/features/member-work-sync/main/TeamTaskStallJournalWorkSyncCooldown.test.ts 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 aa5d7e3d..43ec1bc7 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -36,7 +36,7 @@ Current implementation note: - 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 an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet. - Reconciler can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; otherwise it records normal shadow status and does nothing. This preserves the anti-spam gate before any dispatcher exists. -- Dispatcher use case exists behind explicit facade invocation. 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. +- 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. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown.ts new file mode 100644 index 00000000..2b0ad90d --- /dev/null +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown.ts @@ -0,0 +1,63 @@ +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import type { MemberWorkSyncWatchdogCooldownPort } from '../../../core/application'; + +const DEFAULT_WATCHDOG_COOLDOWN_MS = 10 * 60_000; + +interface StallJournalEntry { + taskId: string; + state: string; + alertedAt?: string; +} + +function parseTime(value: string | undefined): number | null { + if (!value) { + return null; + } + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : null; +} + +export class TeamTaskStallJournalWorkSyncCooldown implements MemberWorkSyncWatchdogCooldownPort { + constructor( + private readonly teamsBasePath: string, + private readonly cooldownMs: number = DEFAULT_WATCHDOG_COOLDOWN_MS + ) {} + + async hasRecentNudge(input: { + teamName: string; + memberName: string; + taskIds: string[]; + nowIso: string; + }): Promise { + const taskIds = new Set(input.taskIds); + if (taskIds.size === 0) { + return false; + } + + try { + const raw = await readFile( + join(this.teamsBasePath, input.teamName, 'stall-monitor-journal.json'), + 'utf8' + ); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return true; + } + const now = parseTime(input.nowIso) ?? Date.now(); + return parsed.some((entry): boolean => { + const row = entry as Partial; + if (row.state !== 'alerted' || !row.taskId || !taskIds.has(row.taskId)) { + return false; + } + const alertedAt = parseTime(row.alertedAt); + return alertedAt != null && now - alertedAt <= this.cooldownMs; + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + return true; + } + } +} diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 4f0ea231..1245a5c5 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -19,6 +19,7 @@ import { } from '../../core/application'; import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; +import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown'; import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { @@ -72,6 +73,7 @@ export function createMemberWorkSyncFeature(deps: { const store = new JsonMemberWorkSyncStore(storePaths); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink(); + const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath); const useCaseDeps = { clock, hash, @@ -80,6 +82,7 @@ export function createMemberWorkSyncFeature(deps: { reportStore: store, outboxStore: store, inboxNudge, + watchdogCooldown, reportToken, ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), logger: deps.logger, @@ -93,6 +96,10 @@ export function createMemberWorkSyncFeature(deps: { const queue = new MemberWorkSyncEventQueue({ reconcile: async (request, context: MemberWorkSyncReconcileContext) => { await reconciler.execute(request, context); + await nudgeDispatcher.dispatchDue({ + teamNames: [request.teamName], + claimedBy: `member-work-sync:${process.pid}`, + }); }, isTeamActive: deps.isTeamActive ?? (() => true), logger: deps.logger, diff --git a/test/features/member-work-sync/main/TeamTaskStallJournalWorkSyncCooldown.test.ts b/test/features/member-work-sync/main/TeamTaskStallJournalWorkSyncCooldown.test.ts new file mode 100644 index 00000000..6351c1dd --- /dev/null +++ b/test/features/member-work-sync/main/TeamTaskStallJournalWorkSyncCooldown.test.ts @@ -0,0 +1,78 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { TeamTaskStallJournalWorkSyncCooldown } from '@features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown'; + +describe('TeamTaskStallJournalWorkSyncCooldown', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'member-work-sync-watchdog-cooldown-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('detects recent watchdog alerts for the same task', async () => { + await mkdir(join(root, 'team-a'), { recursive: true }); + await writeFile( + join(root, 'team-a', 'stall-monitor-journal.json'), + JSON.stringify([ + { + taskId: 'task-1', + state: 'alerted', + alertedAt: '2026-04-29T00:05:00.000Z', + }, + ]), + 'utf8' + ); + + const cooldown = new TeamTaskStallJournalWorkSyncCooldown(root, 10 * 60_000); + + await expect( + cooldown.hasRecentNudge({ + teamName: 'team-a', + memberName: 'bob', + taskIds: ['task-1'], + nowIso: '2026-04-29T00:10:00.000Z', + }) + ).resolves.toBe(true); + }); + + it('ignores old watchdog alerts and missing journals', async () => { + await mkdir(join(root, 'team-a'), { recursive: true }); + await writeFile( + join(root, 'team-a', 'stall-monitor-journal.json'), + JSON.stringify([ + { + taskId: 'task-1', + state: 'alerted', + alertedAt: '2026-04-29T00:00:00.000Z', + }, + ]), + 'utf8' + ); + + const cooldown = new TeamTaskStallJournalWorkSyncCooldown(root, 10 * 60_000); + + await expect( + cooldown.hasRecentNudge({ + teamName: 'team-a', + memberName: 'bob', + taskIds: ['task-1'], + nowIso: '2026-04-29T00:20:00.000Z', + }) + ).resolves.toBe(false); + await expect( + cooldown.hasRecentNudge({ + teamName: 'team-missing', + memberName: 'bob', + taskIds: ['task-1'], + nowIso: '2026-04-29T00:20:00.000Z', + }) + ).resolves.toBe(false); + }); +}); From 99b636fd334f0c30605756fbd05614ffd770174b Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:36:27 +0300 Subject: [PATCH 21/74] feat(member-work-sync): rate limit nudge retries --- .../member-work-sync-control-plane-plan.md | 1 + .../member-work-sync/contracts/types.ts | 6 ++ .../MemberWorkSyncNudgeDispatcher.ts | 45 +++++++++- .../core/application/ports.ts | 2 + .../infrastructure/JsonMemberWorkSyncStore.ts | 13 +++ .../core/MemberWorkSyncUseCases.test.ts | 87 +++++++++++++++++++ 6 files changed, 152 insertions(+), 2 deletions(-) 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 43ec1bc7..e71aee38 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -37,6 +37,7 @@ Current implementation note: - Phase 2 storage foundation is implemented as an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet. - Reconciler can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; otherwise it records normal shadow status and does nothing. This preserves the anti-spam gate before any dispatcher exists. - 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. +- Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts. - Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index 9f74309d..c6daa966 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -298,3 +298,9 @@ export interface MemberWorkSyncOutboxMarkFailedInput { nowIso: string; nextAttemptAt?: string; } + +export interface MemberWorkSyncOutboxCountRecentDeliveredInput { + teamName: string; + memberName: string; + sinceIso: string; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index 47d8ce87..ebcbe89f 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -1,6 +1,10 @@ import type { MemberWorkSyncOutboxItem } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; +const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; +const MEMBER_WORK_SYNC_RETRY_BASE_MINUTES = 10; +const MEMBER_WORK_SYNC_RETRY_MAX_MINUTES = 60; + export interface MemberWorkSyncNudgeDispatchSummary { claimed: number; delivered: number; @@ -23,6 +27,26 @@ function addMinutes(iso: string, minutes: number): string { return new Date(Date.parse(iso) + minutes * 60_000).toISOString(); } +function subtractMinutes(iso: string, minutes: number): string { + return new Date(Date.parse(iso) - minutes * 60_000).toISOString(); +} + +function stableJitterMinutes(id: string, attemptGeneration: number): number { + const seed = `${id}:${attemptGeneration}`; + let value = 0; + for (const char of seed) { + value = (value * 31 + char.charCodeAt(0)) % 997; + } + return value % 5; +} + +function nextRetryAt(item: MemberWorkSyncOutboxItem, nowIso: string): string { + const exponentialMinutes = + MEMBER_WORK_SYNC_RETRY_BASE_MINUTES * 2 ** Math.max(0, item.attemptGeneration - 1); + const cappedMinutes = Math.min(MEMBER_WORK_SYNC_RETRY_MAX_MINUTES, exponentialMinutes); + return addMinutes(nowIso, cappedMinutes + stableJitterMinutes(item.id, item.attemptGeneration)); +} + export class MemberWorkSyncNudgeDispatcher { constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} @@ -71,7 +95,7 @@ export class MemberWorkSyncNudgeDispatcher { error: revalidation.reason, retryable: true, nowIso, - nextAttemptAt: revalidation.nextAttemptAt ?? addMinutes(nowIso, 10), + nextAttemptAt: revalidation.nextAttemptAt ?? nextRetryAt(item, nowIso), }); return 'retryable'; } @@ -120,7 +144,7 @@ export class MemberWorkSyncNudgeDispatcher { error: String(error), retryable: true, nowIso, - nextAttemptAt: addMinutes(nowIso, 10), + nextAttemptAt: nextRetryAt(item, nowIso), }); return 'retryable'; } @@ -160,6 +184,23 @@ export class MemberWorkSyncNudgeDispatcher { return { ok: false, reason: 'phase2_not_ready', retryable: true }; } + const recentDelivered = await this.deps.outboxStore?.countRecentDelivered({ + teamName: item.teamName, + memberName: item.memberName, + sinceIso: subtractMinutes(nowIso, 60), + }); + if ( + recentDelivered != null && + recentDelivered >= MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR + ) { + return { + ok: false, + reason: 'member_nudge_rate_limited', + retryable: true, + nextAttemptAt: addMinutes(nowIso, 60), + }; + } + const busy = await this.deps.busySignal?.isBusy({ teamName: item.teamName, memberName: item.memberName, diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 362e3da5..8cb0eeeb 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -3,6 +3,7 @@ import type { MemberWorkSyncTeamMetrics, MemberWorkSyncProviderId, MemberWorkSyncOutboxClaimInput, + MemberWorkSyncOutboxCountRecentDeliveredInput, MemberWorkSyncOutboxEnsureInput, MemberWorkSyncOutboxEnsureResult, MemberWorkSyncOutboxItem, @@ -100,6 +101,7 @@ export interface MemberWorkSyncOutboxStorePort { markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise; markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise; markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise; + countRecentDelivered(input: MemberWorkSyncOutboxCountRecentDeliveredInput): Promise; } export interface MemberWorkSyncInboxNudgePort { diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index 586c1c34..00aac225 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -6,6 +6,7 @@ import { withFileLock } from '@main/services/team/fileLock'; import type { MemberWorkSyncMetricEvent, MemberWorkSyncOutboxClaimInput, + MemberWorkSyncOutboxCountRecentDeliveredInput, MemberWorkSyncOutboxEnsureInput, MemberWorkSyncOutboxEnsureResult, MemberWorkSyncOutboxItem, @@ -513,6 +514,18 @@ export class JsonMemberWorkSyncStore }); } + async countRecentDelivered( + input: MemberWorkSyncOutboxCountRecentDeliveredInput + ): Promise { + const file = await this.readOutboxFile(input.teamName); + return Object.values(file.items).filter( + (item) => + item.memberName.trim().toLowerCase() === input.memberName.trim().toLowerCase() && + item.status === 'delivered' && + item.updatedAt >= input.sinceIso + ).length; + } + private async readFile(teamName: string): Promise { const filePath = this.paths.getStatusPath(teamName); try { diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index e08b077d..6d49b96f 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -171,6 +171,7 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { ...current, status: 'delivered', deliveredMessageId: input.deliveredMessageId, + updatedAt: input.nowIso, }); } } @@ -189,15 +190,33 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { ...current, status: input.retryable ? 'failed_retryable' : 'failed_terminal', lastError: input.error, + ...(input.nextAttemptAt ? { nextAttemptAt: input.nextAttemptAt } : {}), + updatedAt: input.nowIso, }); } } + + async countRecentDelivered(input: { + memberName: string; + sinceIso: string; + }): Promise { + return [...this.items.values()].filter( + (item) => + item.status === 'delivered' && + item.memberName === input.memberName && + item.updatedAt >= input.sinceIso + ).length; + } } class InMemoryInboxNudge implements MemberWorkSyncInboxNudgePort { readonly inserted: Array[0]> = []; + fail = false; async insertIfAbsent(input: Parameters[0]) { + if (this.fail) { + throw new Error('inbox unavailable'); + } this.inserted.push(input); return { inserted: true, messageId: input.messageId }; } @@ -505,6 +524,74 @@ describe('MemberWorkSync use cases', () => { }); }); + it('rate-limits delivered nudges per member per hour', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); + store.phase2ReadinessState = 'shadow_ready'; + + const current = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + }); + const firstId = `member-work-sync:team-a:bob:${current.agenda.fingerprint}:old-1`; + const secondId = `member-work-sync:team-a:bob:${current.agenda.fingerprint}:old-2`; + const baseItem = outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`); + expect(baseItem).toBeDefined(); + for (const id of [firstId, secondId]) { + outbox.items.set(id, { + ...(baseItem as NonNullable), + id, + status: 'delivered', + deliveredMessageId: id, + updatedAt: '2026-04-29T00:00:00.000Z', + }); + } + + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + 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({ + status: 'failed_retryable', + lastError: 'member_nudge_rate_limited', + nextAttemptAt: '2026-04-29T01:00:00.000Z', + }); + }); + + it('uses bounded retry backoff when inbox delivery fails', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + inbox.fail = true; + const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); + store.phase2ReadinessState = 'shadow_ready'; + + const current = await new MemberWorkSyncDiagnosticsReader(deps).execute({ + teamName: 'team-a', + memberName: 'bob', + }); + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + const item = outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`); + expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 }); + expect(item).toMatchObject({ + status: 'failed_retryable', + lastError: 'Error: inbox unavailable', + }); + expect(Date.parse(item?.nextAttemptAt ?? '')).toBeGreaterThan( + Date.parse('2026-04-29T00:09:59.000Z') + ); + expect(Date.parse(item?.nextAttemptAt ?? '')).toBeLessThanOrEqual( + Date.parse('2026-04-29T00:14:00.000Z') + ); + }); + it('rejects invalid report tokens without recording replayable intents', async () => { const { deps, store } = createDeps(); const reader = new MemberWorkSyncDiagnosticsReader(deps); From 325f1ffba26ac1d454155c3cc5a077dbd5936c01 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:42:08 +0300 Subject: [PATCH 22/74] fix(member-work-sync): keep diagnostics reads passive --- .../member-work-sync-control-plane-plan.md | 8 +-- .../application/MemberWorkSyncReconciler.ts | 4 +- .../core/MemberWorkSyncUseCases.test.ts | 68 ++++++++++++++----- 3 files changed, 57 insertions(+), 23 deletions(-) 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 e71aee38..8aaaef47 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 and Phase 1.5 observability implemented, minimal read-only member details surface wired, Phase 2 deferred until shadow metrics are reviewed +**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and readiness-gated Phase 2 nudge outbox/dispatcher implemented **Scope:** Team management, task work synchronization, agent work coordination **Primary repo:** `claude_team` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` @@ -34,11 +34,11 @@ Current implementation note: - Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface. - 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 an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet. -- Reconciler can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; otherwise it records normal shadow status and does nothing. This preserves the anti-spam gate before any dispatcher exists. +- 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. - 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. - Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts. -- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. +- Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. Patterns used: diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index 1459d06e..3ff508a1 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -87,7 +87,9 @@ export class MemberWorkSyncReconciler { }); await this.deps.statusStore.write(status); - await this.planNudgeOutbox(status); + if ((context.reconciledBy ?? 'request') === 'queue') { + await this.planNudgeOutbox(status); + } return status; } diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 6d49b96f..f6cb0789 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -4,6 +4,7 @@ import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncNudgeDispatcher, MemberWorkSyncPendingReportIntentReplayer, + MemberWorkSyncReconciler, MemberWorkSyncReporter, type MemberWorkSyncAgendaSourceResult, type MemberWorkSyncInboxNudgePort, @@ -429,6 +430,22 @@ describe('MemberWorkSync use cases', () => { const outbox = new InMemoryOutboxStore(); const { deps } = createDeps({ outboxStore: outbox }); + await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(outbox.ensures).toEqual([]); + }); + + it('does not create outbox nudges from read-only diagnostics requests', async () => { + const outbox = new InMemoryOutboxStore(); + const { deps, store } = createDeps({ outboxStore: outbox }); + store.phase2ReadinessState = 'shadow_ready'; + await new MemberWorkSyncDiagnosticsReader(deps).execute({ teamName: 'team-a', memberName: 'bob', @@ -442,10 +459,13 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox }); store.phase2ReadinessState = 'shadow_ready'; - const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ - teamName: 'team-a', - memberName: 'bob', - }); + const status = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); expect(outbox.ensures).toHaveLength(1); expect(outbox.ensures[0]).toMatchObject({ @@ -470,10 +490,13 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); store.phase2ReadinessState = 'shadow_ready'; - const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ - teamName: 'team-a', - memberName: 'bob', - }); + const status = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ teamNames: ['team-a'], claimedBy: 'test-dispatcher', @@ -498,9 +521,12 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); store.phase2ReadinessState = 'shadow_ready'; - const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reconciler = new MemberWorkSyncReconciler(deps); const reporter = new MemberWorkSyncReporter(deps); - const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + const current = await reconciler.execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); await reporter.execute({ teamName: 'team-a', memberName: 'bob', @@ -530,10 +556,13 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); store.phase2ReadinessState = 'shadow_ready'; - const current = await new MemberWorkSyncDiagnosticsReader(deps).execute({ - teamName: 'team-a', - memberName: 'bob', - }); + const current = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); const firstId = `member-work-sync:team-a:bob:${current.agenda.fingerprint}:old-1`; const secondId = `member-work-sync:team-a:bob:${current.agenda.fingerprint}:old-2`; const baseItem = outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`); @@ -569,10 +598,13 @@ describe('MemberWorkSync use cases', () => { const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); store.phase2ReadinessState = 'shadow_ready'; - const current = await new MemberWorkSyncDiagnosticsReader(deps).execute({ - teamName: 'team-a', - memberName: 'bob', - }); + const current = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ teamNames: ['team-a'], claimedBy: 'test-dispatcher', From 53c72e56aed6bcfa72402600c3d9ee66767c705e Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:48:02 +0300 Subject: [PATCH 23/74] feat(member-work-sync): suppress nudges while members are busy --- .../member-work-sync-control-plane-plan.md | 1 + .../createMemberWorkSyncFeature.ts | 8 +- .../MemberWorkSyncToolActivityBusySignal.ts | 208 ++++++++++++++++++ .../core/MemberWorkSyncUseCases.test.ts | 39 ++++ ...mberWorkSyncToolActivityBusySignal.test.ts | 145 ++++++++++++ 5 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 src/features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal.ts create mode 100644 test/features/member-work-sync/main/MemberWorkSyncToolActivityBusySignal.test.ts 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 8aaaef47..ebe8b0ef 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -37,6 +37,7 @@ Current implementation note: - 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. - 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. - Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts. - Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 1245a5c5..aa6a8ef1 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -28,6 +28,7 @@ import { } from '../infrastructure/MemberWorkSyncEventQueue'; import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; +import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal'; import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; @@ -74,6 +75,7 @@ export function createMemberWorkSyncFeature(deps: { const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink(); const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath); + const busySignal = new MemberWorkSyncToolActivityBusySignal(); const useCaseDeps = { clock, hash, @@ -83,6 +85,7 @@ export function createMemberWorkSyncFeature(deps: { outboxStore: store, inboxNudge, watchdogCooldown, + busySignal, reportToken, ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), logger: deps.logger, @@ -110,7 +113,10 @@ export function createMemberWorkSyncFeature(deps: { getStatus: (request) => diagnosticsReader.execute(request), getMetrics: (request) => metricsReader.execute(request), report: (request) => reporter.execute(request), - noteTeamChange: (event) => router.noteTeamChange(event), + noteTeamChange: (event) => { + busySignal.noteTeamChange(event); + router.noteTeamChange(event); + }, enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames), replayPendingReports: async (teamNames) => { const summaries = await Promise.allSettled( diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal.ts new file mode 100644 index 00000000..37acd736 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal.ts @@ -0,0 +1,208 @@ +import type { MemberWorkSyncBusySignalPort } from '../../core/application'; + +import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; + +const DEFAULT_TOOL_ACTIVITY_BUSY_GRACE_MS = 90_000; + +interface MemberActivityState { + activeToolIds: Set; + recentBusyUntilByToolId: Map; +} + +function memberKey(teamName: string, memberName: string): string { + return `${teamName}\0${memberName.trim().toLowerCase()}`; +} + +function parseToolActivity(detail: string | undefined): ToolActivityEventPayload | null { + if (!detail) { + return null; + } + try { + const parsed = JSON.parse(detail) as ToolActivityEventPayload; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +function parseIsoMs(value: string | undefined, fallbackMs: number): number { + const parsed = value ? Date.parse(value) : NaN; + return Number.isFinite(parsed) ? parsed : fallbackMs; +} + +function addMsIso(baseIso: string, ms: number): string { + return new Date(Date.parse(baseIso) + ms).toISOString(); +} + +function maxIso(values: Iterable): string | null { + let max: string | null = null; + for (const value of values) { + if (!max || Date.parse(value) > Date.parse(max)) { + max = value; + } + } + return max; +} + +export class MemberWorkSyncToolActivityBusySignal implements MemberWorkSyncBusySignalPort { + private readonly activityByMember = new Map(); + private readonly busyGraceMs: number; + + constructor(options: { busyGraceMs?: number } = {}) { + this.busyGraceMs = Math.max(0, options.busyGraceMs ?? DEFAULT_TOOL_ACTIVITY_BUSY_GRACE_MS); + } + + noteTeamChange(event: TeamChangeEvent): void { + if (event.type === 'lead-activity' && event.detail === 'offline') { + this.dropTeam(event.teamName); + return; + } + + if (event.type !== 'tool-activity') { + return; + } + + const payload = parseToolActivity(event.detail); + if (!payload) { + return; + } + + if (payload.action === 'start' && payload.activity) { + this.noteStart(event.teamName, payload.activity.memberName, payload.activity.toolUseId); + return; + } + + if (payload.action === 'finish' && payload.memberName && payload.toolUseId) { + this.noteFinish(event.teamName, payload.memberName, payload.toolUseId, payload.finishedAt); + return; + } + + if (payload.action === 'reset') { + this.noteReset(event.teamName, payload.memberName, payload.toolUseIds); + } + } + + async isBusy(input: { + teamName: string; + memberName: string; + nowIso: string; + }): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }> { + const key = memberKey(input.teamName, input.memberName); + const state = this.activityByMember.get(key); + if (!state) { + return { busy: false }; + } + + this.pruneState(key, state, input.nowIso); + + if (state.activeToolIds.size > 0) { + return { + busy: true, + reason: 'active_tool_activity', + retryAfterIso: addMsIso(input.nowIso, this.busyGraceMs), + }; + } + + const retryAfterIso = maxIso(state.recentBusyUntilByToolId.values()); + if (retryAfterIso) { + return { + busy: true, + reason: 'recent_tool_activity', + retryAfterIso, + }; + } + + return { busy: false }; + } + + private noteStart(teamName: string, memberName: string, toolUseId: string): void { + const normalizedToolUseId = toolUseId.trim(); + if (!memberName.trim() || !normalizedToolUseId) { + return; + } + const state = this.getOrCreateState(teamName, memberName); + state.activeToolIds.add(normalizedToolUseId); + state.recentBusyUntilByToolId.delete(normalizedToolUseId); + } + + private noteFinish( + teamName: string, + memberName: string, + toolUseId: string, + finishedAt: string | undefined + ): void { + const normalizedToolUseId = toolUseId.trim(); + if (!memberName.trim() || !normalizedToolUseId) { + return; + } + const finishedAtMs = parseIsoMs(finishedAt, Date.now()); + const busyUntilIso = new Date(finishedAtMs + this.busyGraceMs).toISOString(); + const state = this.getOrCreateState(teamName, memberName); + state.activeToolIds.delete(normalizedToolUseId); + state.recentBusyUntilByToolId.set(normalizedToolUseId, busyUntilIso); + } + + private noteReset(teamName: string, memberName?: string, toolUseIds?: string[]): void { + const normalizedMemberName = memberName?.trim(); + if (!normalizedMemberName) { + this.dropTeam(teamName); + return; + } + + const key = memberKey(teamName, normalizedMemberName); + const state = this.activityByMember.get(key); + if (!state) { + return; + } + + const normalizedToolUseIds = new Set( + (toolUseIds ?? []).map((toolUseId) => toolUseId.trim()).filter(Boolean) + ); + if (normalizedToolUseIds.size === 0) { + this.activityByMember.delete(key); + return; + } + + for (const toolUseId of normalizedToolUseIds) { + state.activeToolIds.delete(toolUseId); + state.recentBusyUntilByToolId.delete(toolUseId); + } + if (state.activeToolIds.size === 0 && state.recentBusyUntilByToolId.size === 0) { + this.activityByMember.delete(key); + } + } + + private getOrCreateState(teamName: string, memberName: string): MemberActivityState { + const key = memberKey(teamName, memberName); + const existing = this.activityByMember.get(key); + if (existing) { + return existing; + } + const created: MemberActivityState = { + activeToolIds: new Set(), + recentBusyUntilByToolId: new Map(), + }; + this.activityByMember.set(key, created); + return created; + } + + private pruneState(key: string, state: MemberActivityState, nowIso: string): void { + const nowMs = Date.parse(nowIso); + for (const [toolUseId, busyUntilIso] of state.recentBusyUntilByToolId) { + if (Date.parse(busyUntilIso) <= nowMs) { + state.recentBusyUntilByToolId.delete(toolUseId); + } + } + if (state.activeToolIds.size === 0 && state.recentBusyUntilByToolId.size === 0) { + this.activityByMember.delete(key); + } + } + + private dropTeam(teamName: string): void { + for (const key of this.activityByMember.keys()) { + if (key.startsWith(`${teamName}\0`)) { + this.activityByMember.delete(key); + } + } + } +} diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index f6cb0789..b1925f28 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -231,6 +231,7 @@ function createDeps(options?: { providerId?: 'opencode' | 'codex'; outboxStore?: MemberWorkSyncOutboxStorePort; inboxNudge?: MemberWorkSyncInboxNudgePort; + busySignal?: MemberWorkSyncUseCaseDeps['busySignal']; }) { const clock = new MutableClock(); const store = new InMemoryStatusStore(); @@ -259,6 +260,7 @@ function createDeps(options?: { reportStore: store, ...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}), ...(options?.inboxNudge ? { inboxNudge: options.inboxNudge } : {}), + ...(options?.busySignal ? { busySignal: options.busySignal } : {}), reportToken: { create: async (input) => ({ token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`, @@ -591,6 +593,43 @@ describe('MemberWorkSync use cases', () => { }); }); + it('defers nudge dispatch while the member has active or recent tool activity', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { deps, store } = createDeps({ + outboxStore: outbox, + inboxNudge: inbox, + busySignal: { + isBusy: async () => ({ + busy: true, + reason: 'active_tool_activity', + retryAfterIso: '2026-04-29T00:02:00.000Z', + }), + }, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const current = await new MemberWorkSyncReconciler(deps).execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['tool_finished'] } + ); + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + 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({ + status: 'failed_retryable', + lastError: 'member_busy:active_tool_activity', + nextAttemptAt: '2026-04-29T00:02:00.000Z', + }); + }); + it('uses bounded retry backoff when inbox delivery fails', async () => { const outbox = new InMemoryOutboxStore(); const inbox = new InMemoryInboxNudge(); diff --git a/test/features/member-work-sync/main/MemberWorkSyncToolActivityBusySignal.test.ts b/test/features/member-work-sync/main/MemberWorkSyncToolActivityBusySignal.test.ts new file mode 100644 index 00000000..bb6bbb65 --- /dev/null +++ b/test/features/member-work-sync/main/MemberWorkSyncToolActivityBusySignal.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { MemberWorkSyncToolActivityBusySignal } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal'; + +import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; + +function toolEvent(teamName: string, payload: ToolActivityEventPayload): TeamChangeEvent { + return { + type: 'tool-activity', + teamName, + detail: JSON.stringify(payload), + }; +} + +describe('MemberWorkSyncToolActivityBusySignal', () => { + it('treats active tools as busy and recent finishes as a bounded quiet window', async () => { + const signal = new MemberWorkSyncToolActivityBusySignal({ busyGraceMs: 90_000 }); + + signal.noteTeamChange( + toolEvent('team-a', { + action: 'start', + activity: { + memberName: 'bob', + toolUseId: 'tool-1', + toolName: 'bash', + startedAt: '2026-04-29T00:00:00.000Z', + source: 'runtime', + }, + }) + ); + + await expect( + signal.isBusy({ + teamName: 'team-a', + memberName: 'bob', + nowIso: '2026-04-29T00:00:15.000Z', + }) + ).resolves.toMatchObject({ + busy: true, + reason: 'active_tool_activity', + retryAfterIso: '2026-04-29T00:01:45.000Z', + }); + + signal.noteTeamChange( + toolEvent('team-a', { + action: 'finish', + memberName: 'bob', + toolUseId: 'tool-1', + finishedAt: '2026-04-29T00:01:00.000Z', + }) + ); + + await expect( + signal.isBusy({ + teamName: 'team-a', + memberName: 'bob', + nowIso: '2026-04-29T00:01:30.000Z', + }) + ).resolves.toMatchObject({ + busy: true, + reason: 'recent_tool_activity', + retryAfterIso: '2026-04-29T00:02:30.000Z', + }); + + await expect( + signal.isBusy({ + teamName: 'team-a', + memberName: 'bob', + nowIso: '2026-04-29T00:02:31.000Z', + }) + ).resolves.toEqual({ busy: false }); + }); + + it('does not leak activity across members and clears targeted reset events', async () => { + const signal = new MemberWorkSyncToolActivityBusySignal({ busyGraceMs: 90_000 }); + + signal.noteTeamChange( + toolEvent('team-a', { + action: 'start', + activity: { + memberName: 'bob', + toolUseId: 'tool-1', + toolName: 'read', + startedAt: '2026-04-29T00:00:00.000Z', + source: 'member_log', + }, + }) + ); + + await expect( + signal.isBusy({ + teamName: 'team-a', + memberName: 'alice', + nowIso: '2026-04-29T00:00:15.000Z', + }) + ).resolves.toEqual({ busy: false }); + + signal.noteTeamChange( + toolEvent('team-a', { + action: 'reset', + memberName: 'bob', + toolUseIds: ['tool-1'], + }) + ); + + await expect( + signal.isBusy({ + teamName: 'team-a', + memberName: 'bob', + nowIso: '2026-04-29T00:00:15.000Z', + }) + ).resolves.toEqual({ busy: false }); + }); + + it('drops all tracked activity for a team when it goes offline', async () => { + const signal = new MemberWorkSyncToolActivityBusySignal({ busyGraceMs: 90_000 }); + + signal.noteTeamChange( + toolEvent('team-a', { + action: 'start', + activity: { + memberName: 'bob', + toolUseId: 'tool-1', + toolName: 'write', + startedAt: '2026-04-29T00:00:00.000Z', + source: 'runtime', + }, + }) + ); + + signal.noteTeamChange({ + type: 'lead-activity', + teamName: 'team-a', + detail: 'offline', + }); + + await expect( + signal.isBusy({ + teamName: 'team-a', + memberName: 'bob', + nowIso: '2026-04-29T00:00:15.000Z', + }) + ).resolves.toEqual({ busy: false }); + }); +}); From f705cd25cb12322686a9e51ad9e9ca56cf082d4d Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 15:54:29 +0300 Subject: [PATCH 24/74] feat(member-work-sync): schedule due nudge dispatch --- .../member-work-sync-control-plane-plan.md | 1 + .../createMemberWorkSyncFeature.ts | 18 ++- .../MemberWorkSyncNudgeDispatchScheduler.ts | 104 ++++++++++++++++++ src/main/index.ts | 4 + ...mberWorkSyncNudgeDispatchScheduler.test.ts | 64 +++++++++++ 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler.ts create mode 100644 test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts 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 ebe8b0ef..7cb98ad3 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -38,6 +38,7 @@ Current implementation note: - 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. - 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 active teams. 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. - Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low. diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index aa6a8ef1..cbd5acbb 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -28,6 +28,7 @@ import { } from '../infrastructure/MemberWorkSyncEventQueue'; import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; +import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWorkSyncNudgeDispatchScheduler'; import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal'; import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; @@ -58,6 +59,7 @@ export function createMemberWorkSyncFeature(deps: { kanbanManager: TeamKanbanManager; membersMetaStore: TeamMembersMetaStore; isTeamActive?: (teamName: string) => Promise | boolean; + listActiveTeamNames?: () => Promise; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -108,6 +110,18 @@ export function createMemberWorkSyncFeature(deps: { logger: deps.logger, }); const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue); + const nudgeDispatchScheduler = deps.listActiveTeamNames + ? new MemberWorkSyncNudgeDispatchScheduler({ + listActiveTeamNames: deps.listActiveTeamNames, + dispatchDue: (teamNames) => + nudgeDispatcher.dispatchDue({ + teamNames, + claimedBy: `member-work-sync:${process.pid}:scheduled`, + }), + logger: deps.logger, + }) + : null; + nudgeDispatchScheduler?.start(); return { getStatus: (request) => diagnosticsReader.execute(request), @@ -139,6 +153,8 @@ export function createMemberWorkSyncFeature(deps: { dispatchDueNudges: (teamNames) => nudgeDispatcher.dispatchDue({ teamNames, claimedBy: `member-work-sync:${process.pid}` }), getQueueDiagnostics: () => queue.getDiagnostics(), - dispose: () => queue.stop(), + dispose: async () => { + await Promise.allSettled([queue.stop(), nudgeDispatchScheduler?.dispose()]); + }, }; } diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler.ts new file mode 100644 index 00000000..f2d5a5c0 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler.ts @@ -0,0 +1,104 @@ +import type { + MemberWorkSyncLoggerPort, + MemberWorkSyncNudgeDispatchSummary, +} from '../../core/application'; + +const DEFAULT_NUDGE_DISPATCH_INTERVAL_MS = 60_000; + +function uniqueNonEmpty(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +function unrefTimer(timer: ReturnType): void { + timer.unref?.(); +} + +export interface MemberWorkSyncNudgeDispatchSchedulerDeps { + listActiveTeamNames(): Promise; + dispatchDue(teamNames: string[]): Promise; + intervalMs?: number; + logger?: MemberWorkSyncLoggerPort; +} + +export class MemberWorkSyncNudgeDispatchScheduler { + private readonly intervalMs: number; + private timer: ReturnType | null = null; + private running: Promise | null = null; + private stopped = false; + + constructor(private readonly deps: MemberWorkSyncNudgeDispatchSchedulerDeps) { + this.intervalMs = Math.max(10_000, deps.intervalMs ?? DEFAULT_NUDGE_DISPATCH_INTERVAL_MS); + } + + start(): void { + if (this.stopped || this.timer) { + return; + } + this.schedule(this.intervalMs); + } + + async runOnce(): Promise { + if (this.stopped) { + return; + } + if (this.running) { + await this.running; + return; + } + + const work = this.dispatchOnce(); + this.running = work; + try { + await work; + } finally { + if (this.running === work) { + this.running = null; + } + } + } + + async dispose(): Promise { + this.stopped = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + if (this.running) { + await this.running.catch(() => undefined); + } + } + + private schedule(delayMs: number): void { + if (this.stopped) { + return; + } + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.timer = null; + void this.runOnce().finally(() => this.schedule(this.intervalMs)); + }, delayMs); + unrefTimer(this.timer); + } + + private async dispatchOnce(): Promise { + try { + const teamNames = uniqueNonEmpty(await this.deps.listActiveTeamNames()); + if (teamNames.length === 0) { + return; + } + const summary = await this.deps.dispatchDue(teamNames); + if (summary.claimed > 0 || summary.delivered > 0 || summary.retryable > 0) { + this.deps.logger?.debug('member work sync scheduled nudge dispatch completed', { + teamCount: teamNames.length, + ...summary, + }); + } + } catch (error) { + this.deps.logger?.warn('member work sync scheduled nudge dispatch failed', { + error: String(error), + }); + } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index eb8f057f..f3df407b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1236,6 +1236,10 @@ async function initializeServices(): Promise { isTeamActive: (teamName) => teamProvisioningService.isTeamAlive(teamName) || teamProvisioningService.hasProvisioningRun(teamName), + listActiveTeamNames: async () => + (await teamDataService.listTeams()) + .filter((team) => !team.deletedAt) + .map((team) => team.teamName), logger: createLogger('Feature:MemberWorkSync'), }); void teamDataService diff --git a/test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts b/test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts new file mode 100644 index 00000000..ceb65ab5 --- /dev/null +++ b/test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { MemberWorkSyncNudgeDispatchScheduler } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler'; + +describe('MemberWorkSyncNudgeDispatchScheduler', () => { + it('dispatches due nudges for unique active teams without overlapping runs', async () => { + let release!: () => void; + const firstDispatch = new Promise((resolve) => { + release = resolve; + }); + const dispatchDue = vi.fn(async () => { + await firstDispatch; + return { claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0 }; + }); + const scheduler = new MemberWorkSyncNudgeDispatchScheduler({ + listActiveTeamNames: async () => ['team-a', 'team-a', ' ', 'team-b'], + dispatchDue, + }); + + const first = scheduler.runOnce(); + const second = scheduler.runOnce(); + await Promise.resolve(); + expect(dispatchDue).toHaveBeenCalledTimes(1); + + release(); + await Promise.all([first, second]); + + expect(dispatchDue).toHaveBeenCalledWith(['team-a', 'team-b']); + }); + + it('skips dispatch when there are no active teams', async () => { + const dispatchDue = vi.fn(); + const scheduler = new MemberWorkSyncNudgeDispatchScheduler({ + listActiveTeamNames: async () => [], + dispatchDue, + }); + + await scheduler.runOnce(); + + expect(dispatchDue).not.toHaveBeenCalled(); + }); + + it('logs and survives list failures without throwing', async () => { + const warn = vi.fn(); + const scheduler = new MemberWorkSyncNudgeDispatchScheduler({ + listActiveTeamNames: async () => { + throw new Error('list failed'); + }, + dispatchDue: vi.fn(), + logger: { + debug: vi.fn(), + warn, + error: vi.fn(), + }, + }); + + await expect(scheduler.runOnce()).resolves.toBeUndefined(); + + expect(warn).toHaveBeenCalledWith( + 'member work sync scheduled nudge dispatch failed', + expect.objectContaining({ error: 'Error: list failed' }) + ); + }); +}); From d27c1bcc51232c8944173463139ddd5f96b601bc Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 16:04:38 +0300 Subject: [PATCH 25/74] fix(member-work-sync): preserve undelivered nudge retries --- .../member-work-sync-control-plane-plan.md | 20 +++++++- .../infrastructure/JsonMemberWorkSyncStore.ts | 6 ++- src/main/index.ts | 15 ++++-- .../core/MemberWorkSyncUseCases.test.ts | 25 +++++++++- .../main/JsonMemberWorkSyncStore.test.ts | 50 +++++++++++++++++++ 5 files changed, 108 insertions(+), 8 deletions(-) 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 7cb98ad3..1f845c83 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 readiness-gated Phase 2 nudge outbox/dispatcher implemented +**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and readiness-gated 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` @@ -38,8 +38,9 @@ Current implementation note: - 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. - 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 active teams. 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 only. 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. Patterns used: @@ -3012,6 +3013,15 @@ Includes: - per-member token bucket; - shared cooldown with watchdog. +Implemented safety constraints: + +- only queued reconciles plan outbox rows; +- read-only diagnostics never plan outbox rows; +- outbox planning requires `phase2Readiness.state === "shadow_ready"`; +- dispatch revalidates lifecycle, current status, current fingerprint, readiness, busy state, rate limit, and watchdog cooldown immediately before inbox insert; +- scheduled dispatch lists lifecycle-active teams only, not all stored teams; +- undelivered `superseded` rows can be revived by a later fresh reconcile for the same fingerprint, while `delivered` rows remain one-per-fingerprint. + ### Phase 3: Provider Accelerators `šŸŽÆ 8 šŸ›”ļø 8 🧠 5`, `300-600 LOC`. @@ -3025,6 +3035,12 @@ Includes: 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; +- 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. + --- ## 22. Runtime Defaults And No Feature Flags diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index 00aac225..3a5b7c86 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -97,6 +97,10 @@ function isOutboxTerminal(status: MemberWorkSyncOutboxItem['status']): boolean { return status === 'delivered' || status === 'superseded' || status === 'failed_terminal'; } +function canReviveOutboxItem(status: MemberWorkSyncOutboxItem['status']): boolean { + return status === 'superseded' || (!isOutboxTerminal(status) && status !== 'pending'); +} + function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boolean { if (item.status !== 'pending' && item.status !== 'failed_retryable') { return false; @@ -379,7 +383,7 @@ export class JsonMemberWorkSyncStore return; } - if (!isOutboxTerminal(current.status) && current.status !== 'pending') { + if (canReviveOutboxItem(current.status)) { const next: MemberWorkSyncOutboxItem = { ...current, status: 'pending', diff --git a/src/main/index.ts b/src/main/index.ts index f3df407b..86ff2de2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1236,10 +1236,17 @@ async function initializeServices(): Promise { isTeamActive: (teamName) => teamProvisioningService.isTeamAlive(teamName) || teamProvisioningService.hasProvisioningRun(teamName), - listActiveTeamNames: async () => - (await teamDataService.listTeams()) - .filter((team) => !team.deletedAt) - .map((team) => team.teamName), + listActiveTeamNames: async () => { + const teams = await teamDataService.listTeams(); + return teams + .filter( + (team) => + !team.deletedAt && + (teamProvisioningService.isTeamAlive(team.teamName) || + teamProvisioningService.hasProvisioningRun(team.teamName)) + ) + .map((team) => team.teamName); + }, logger: createLogger('Feature:MemberWorkSync'), }); void teamDataService diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index b1925f28..04bd3892 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -140,6 +140,18 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { this.ensures.push(input); const current = this.items.get(input.id); if (current) { + if (current.status === 'superseded') { + const revived = { + ...current, + status: 'pending' as const, + updatedAt: input.nowIso, + }; + delete revived.lastError; + delete revived.claimedBy; + delete revived.claimedAt; + this.items.set(input.id, revived); + return { ok: true as const, outcome: 'existing' as const, item: revived }; + } return { ok: true as const, outcome: 'existing' as const, item: current }; } const item: MemberWorkSyncOutboxItem = { @@ -520,7 +532,7 @@ describe('MemberWorkSync use cases', () => { it('does not dispatch stale outbox items after the member reports still working', async () => { const outbox = new InMemoryOutboxStore(); const inbox = new InMemoryInboxNudge(); - const { deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); + const { clock, deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox }); store.phase2ReadinessState = 'shadow_ready'; const reconciler = new MemberWorkSyncReconciler(deps); @@ -550,6 +562,17 @@ describe('MemberWorkSync use cases', () => { status: 'superseded', lastError: 'status_no_longer_matches_outbox', }); + + clock.set('2026-04-29T00:03:00.000Z'); + const expired = await reconciler.execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + + expect(expired.state).toBe('needs_sync'); + const revived = outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`); + expect(revived).toMatchObject({ status: 'pending' }); + expect(revived).not.toHaveProperty('lastError'); }); it('rate-limits delivered nudges per member per hour', async () => { diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index 6d7685a3..1f90fde5 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -197,6 +197,56 @@ describe('JsonMemberWorkSyncStore', () => { }); }); + it('revives superseded outbox items but keeps delivered nudges one-per-fingerprint', async () => { + const input = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + + await store.ensurePending(input); + await store.markSuperseded({ + teamName: 'team-a', + id: input.id, + reason: 'status_no_longer_matches_outbox', + nowIso: '2026-04-29T00:01:00.000Z', + }); + + const revived = await store.ensurePending({ ...input, nowIso: '2026-04-29T00:02:00.000Z' }); + expect(revived).toMatchObject({ + ok: true, + outcome: 'existing', + item: { status: 'pending' }, + }); + expect(revived.item).not.toHaveProperty('lastError'); + + const [claimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:03:00.000Z', + limit: 1, + }); + await store.markDelivered({ + teamName: 'team-a', + id: input.id, + attemptGeneration: claimed.attemptGeneration, + deliveredMessageId: 'message-1', + nowIso: '2026-04-29T00:04:00.000Z', + }); + + await expect( + store.ensurePending({ ...input, nowIso: '2026-04-29T00:05:00.000Z' }) + ).resolves.toMatchObject({ + ok: true, + outcome: 'existing', + item: { status: 'delivered', deliveredMessageId: 'message-1' }, + }); + }); + it('claims due outbox items and fences terminal updates by attempt generation', async () => { const input = { id: 'member-work-sync:team-a:bob:agenda:v1:abc', From 90401b41a19ed2fae35e474bae22e1e6ba7fe016 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 16:09:46 +0300 Subject: [PATCH 26/74] chore(member-work-sync): clarify lifecycle dispatch boundary --- .../member-work-sync-control-plane-plan.md | 16 +++++++++------- .../composition/createMemberWorkSyncFeature.ts | 6 +++--- .../MemberWorkSyncNudgeDispatchScheduler.ts | 4 ++-- src/main/index.ts | 2 +- .../MemberWorkSyncNudgeDispatchScheduler.test.ts | 6 +++--- 5 files changed, 18 insertions(+), 16 deletions(-) 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 1f845c83..46f0aa61 100644 --- a/docs/team-management/member-work-sync-control-plane-plan.md +++ b/docs/team-management/member-work-sync-control-plane-plan.md @@ -3045,15 +3045,15 @@ Current implementation: ## 22. Runtime Defaults And No Feature Flags -Phase 1 should ship without feature flags. +Phase 1 shipped without feature flags. Reason: - Phase 1 has no nudges, no inbox writes, no task mutation, and no runtime restart behavior. - Adding feature flags for passive status/report validation creates extra branches and makes failures harder to reason about. -- The safe boundary is architectural, not configurational: Phase 1 code simply does not contain the side-effect dispatcher. +- The safe boundary is architectural, not configurational: passive status/report validation stays independent from Phase 2 side effects. -Phase 1 defaults: +Runtime defaults: | Behavior | Default | Why | |---|---:|---| @@ -3061,7 +3061,9 @@ Phase 1 defaults: | `member_work_sync_status` | on | read-only diagnostics | | `member_work_sync_report` | on | server-validated, no board mutation | | pending report intent fallback | on only when identity is not terminally invalid | compatibility with old app/runtime boundaries | -| nudges/outbox/inbox writes | not implemented | avoids hidden flag branches | +| outbox planning | on only for queued reconciles and only when `phase2Readiness=shadow_ready` | prevents status reads from causing side effects | +| scheduled nudge dispatch | on only for lifecycle-active teams | stopped teams must not claim or supersede pending nudges | +| inbox nudge writes | guarded by dispatcher revalidation | lifecycle, current fingerprint, readiness, busy signal, rate limit, and watchdog cooldown are checked immediately before write | Do not add: @@ -3073,9 +3075,9 @@ If Phase 1 needs to be disabled during development, revert or patch the narrow c Phase 2 policy: -- Phase 2 is a separate implementation, not a disabled code path hidden behind a flag in Phase 1. -- If Phase 2 adds nudges, it must add dispatcher/outbox code in its own cut after metrics review. -- Phase 2 may use constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch. +- Phase 2 is implemented as a separate outbox/dispatcher/scheduler path, not as hidden branching inside passive diagnostics. +- Phase 2 does not bypass shadow readiness. If metrics are noisy, the planner returns `phase2_not_ready`. +- Phase 2 uses constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch. Phase 2 runtime constants can be normal typed defaults, not feature gates: diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index cbd5acbb..4bac2374 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -59,7 +59,7 @@ export function createMemberWorkSyncFeature(deps: { kanbanManager: TeamKanbanManager; membersMetaStore: TeamMembersMetaStore; isTeamActive?: (teamName: string) => Promise | boolean; - listActiveTeamNames?: () => Promise; + listLifecycleActiveTeamNames?: () => Promise; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -110,9 +110,9 @@ export function createMemberWorkSyncFeature(deps: { logger: deps.logger, }); const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue); - const nudgeDispatchScheduler = deps.listActiveTeamNames + const nudgeDispatchScheduler = deps.listLifecycleActiveTeamNames ? new MemberWorkSyncNudgeDispatchScheduler({ - listActiveTeamNames: deps.listActiveTeamNames, + listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames, dispatchDue: (teamNames) => nudgeDispatcher.dispatchDue({ teamNames, diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler.ts index f2d5a5c0..276b04a1 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncNudgeDispatchScheduler.ts @@ -14,7 +14,7 @@ function unrefTimer(timer: ReturnType): void { } export interface MemberWorkSyncNudgeDispatchSchedulerDeps { - listActiveTeamNames(): Promise; + listLifecycleActiveTeamNames(): Promise; dispatchDue(teamNames: string[]): Promise; intervalMs?: number; logger?: MemberWorkSyncLoggerPort; @@ -84,7 +84,7 @@ export class MemberWorkSyncNudgeDispatchScheduler { private async dispatchOnce(): Promise { try { - const teamNames = uniqueNonEmpty(await this.deps.listActiveTeamNames()); + const teamNames = uniqueNonEmpty(await this.deps.listLifecycleActiveTeamNames()); if (teamNames.length === 0) { return; } diff --git a/src/main/index.ts b/src/main/index.ts index 86ff2de2..109aa9e2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1236,7 +1236,7 @@ async function initializeServices(): Promise { isTeamActive: (teamName) => teamProvisioningService.isTeamAlive(teamName) || teamProvisioningService.hasProvisioningRun(teamName), - listActiveTeamNames: async () => { + listLifecycleActiveTeamNames: async () => { const teams = await teamDataService.listTeams(); return teams .filter( diff --git a/test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts b/test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts index ceb65ab5..90cc0a30 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts @@ -13,7 +13,7 @@ describe('MemberWorkSyncNudgeDispatchScheduler', () => { return { claimed: 1, delivered: 1, superseded: 0, retryable: 0, terminal: 0 }; }); const scheduler = new MemberWorkSyncNudgeDispatchScheduler({ - listActiveTeamNames: async () => ['team-a', 'team-a', ' ', 'team-b'], + listLifecycleActiveTeamNames: async () => ['team-a', 'team-a', ' ', 'team-b'], dispatchDue, }); @@ -31,7 +31,7 @@ describe('MemberWorkSyncNudgeDispatchScheduler', () => { it('skips dispatch when there are no active teams', async () => { const dispatchDue = vi.fn(); const scheduler = new MemberWorkSyncNudgeDispatchScheduler({ - listActiveTeamNames: async () => [], + listLifecycleActiveTeamNames: async () => [], dispatchDue, }); @@ -43,7 +43,7 @@ describe('MemberWorkSyncNudgeDispatchScheduler', () => { it('logs and survives list failures without throwing', async () => { const warn = vi.fn(); const scheduler = new MemberWorkSyncNudgeDispatchScheduler({ - listActiveTeamNames: async () => { + listLifecycleActiveTeamNames: async () => { throw new Error('list failed'); }, dispatchDue: vi.fn(), From b2edfe5d2a415b94c06aa2ffe57b37932241f8ac Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 17:55:19 +0300 Subject: [PATCH 27/74] test: characterize runtime settings hook merge --- src/main/services/runtime/cliSettingsArgs.ts | 69 +++++++++++++++++++ .../services/runtime/cliSettingsArgs.test.ts | 67 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/main/services/runtime/cliSettingsArgs.ts b/src/main/services/runtime/cliSettingsArgs.ts index fb7dc940..70f0d285 100644 --- a/src/main/services/runtime/cliSettingsArgs.ts +++ b/src/main/services/runtime/cliSettingsArgs.ts @@ -1,5 +1,7 @@ type JsonObject = Record; +type JsonArray = unknown[]; + function isJsonObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -13,10 +15,77 @@ function parseJsonSettingsObject(raw: string): JsonObject | null { } } +function isHookEntry(value: unknown): value is JsonObject { + return isJsonObject(value) && Array.isArray(value.hooks); +} + +function getHookEntryDedupeKey(value: unknown): string | null { + if (!isHookEntry(value)) { + return null; + } + + const commands = value.hooks + .map((hook) => (isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null)) + .filter((command): command is string => Boolean(command)); + if (commands.length === 0) { + return null; + } + + return JSON.stringify({ + matcher: typeof value.matcher === 'string' ? value.matcher : '', + commands, + }); +} + +function mergeHookEntryArrays(target: JsonArray, source: JsonArray): JsonArray { + const merged = [...target]; + const seen = new Set(); + for (const entry of merged) { + const key = getHookEntryDedupeKey(entry); + if (key) { + seen.add(key); + } + } + + for (const entry of source) { + const key = getHookEntryDedupeKey(entry); + if (key && seen.has(key)) { + continue; + } + merged.push(entry); + if (key) { + seen.add(key); + } + } + + return merged; +} + +function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject { + const merged: JsonObject = { ...target }; + for (const [hookName, sourceValue] of Object.entries(source)) { + const currentValue = merged[hookName]; + if (Array.isArray(currentValue) && Array.isArray(sourceValue)) { + merged[hookName] = mergeHookEntryArrays(currentValue, sourceValue); + continue; + } + if (isJsonObject(currentValue) && isJsonObject(sourceValue)) { + merged[hookName] = deepMergeJsonObjects(currentValue, sourceValue); + continue; + } + merged[hookName] = sourceValue; + } + return merged; +} + function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject { const merged: JsonObject = { ...target }; for (const [key, value] of Object.entries(source)) { const current = merged[key]; + if (key === 'hooks' && isJsonObject(current) && isJsonObject(value)) { + merged[key] = mergeHooksObject(current, value); + continue; + } if (isJsonObject(current) && isJsonObject(value)) { merged[key] = deepMergeJsonObjects(current, value); continue; diff --git a/test/main/services/runtime/cliSettingsArgs.test.ts b/test/main/services/runtime/cliSettingsArgs.test.ts index 742d14a2..c76dfd2a 100644 --- a/test/main/services/runtime/cliSettingsArgs.test.ts +++ b/test/main/services/runtime/cliSettingsArgs.test.ts @@ -64,4 +64,71 @@ describe('mergeJsonSettingsArgs', () => { '{"fastMode":false,"codex":{"forced_login_method":"chatgpt"}}', ]); }); + + it('preserves multiple hook entries for the same hook event', () => { + const merged = mergeJsonSettingsArgs([ + '--settings', + JSON.stringify({ + hooks: { + Stop: [ + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh user-stop.sh' }], + }, + ], + }, + }), + '--settings', + JSON.stringify({ + hooks: { + Stop: [ + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }], + }, + ], + }, + }), + ]); + + expect(JSON.parse(getSettingsValues(merged)[0] ?? '{}')).toEqual({ + hooks: { + Stop: [ + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh user-stop.sh' }], + }, + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }], + }, + ], + }, + }); + }); + + it('dedupes identical hook entries while preserving unrelated array replacement semantics', () => { + const appHook = { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }], + }; + + const merged = mergeJsonSettingsArgs([ + '--settings', + JSON.stringify({ + permissions: { allow: ['Read'] }, + hooks: { Stop: [appHook] }, + }), + '--settings', + JSON.stringify({ + permissions: { allow: ['Bash'] }, + hooks: { Stop: [appHook] }, + }), + ]); + + expect(JSON.parse(getSettingsValues(merged)[0] ?? '{}')).toEqual({ + permissions: { allow: ['Bash'] }, + hooks: { Stop: [appHook] }, + }); + }); }); From 05d9b5a9b16fada72a320a531760bfbc21c696c7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 18:18:02 +0300 Subject: [PATCH 28/74] feat: add member work sync stop hook ingest --- ...member-work-sync-runtime-stop-hook-plan.md | 1767 +++++++++++++++++ .../application/RuntimeTurnSettledIngestor.ts | 96 + .../application/RuntimeTurnSettledPorts.ts | 64 + .../core/application/index.ts | 2 + .../core/domain/RuntimeTurnSettledEvent.ts | 31 + .../core/domain/RuntimeTurnSettledProvider.ts | 5 + .../member-work-sync/core/domain/index.ts | 2 + .../input/MemberWorkSyncTeamChangeRouter.ts | 31 + .../TeamRuntimeTurnSettledTargetResolver.ts | 174 ++ .../createMemberWorkSyncFeature.ts | 120 +- src/features/member-work-sync/main/index.ts | 6 +- .../ClaudeStopHookPayloadNormalizer.ts | 86 + .../FileRuntimeTurnSettledEventStore.ts | 191 ++ .../MemberWorkSyncEventQueue.ts | 1 + .../RuntimeTurnSettledDrainScheduler.ts | 69 + .../RuntimeTurnSettledSpoolPaths.ts | 33 + ...llRuntimeTurnSettledHookScriptInstaller.ts | 68 + .../runtimeTurnSettledHookSettings.ts | 48 + src/main/index.ts | 5 + src/main/services/runtime/cliSettingsArgs.ts | 7 +- .../services/team/TeamProvisioningService.ts | 44 + src/shared/types/team.ts | 1 + .../FileRuntimeTurnSettledEventStore.test.ts | 89 + .../MemberWorkSyncTeamChangeRouter.test.ts | 28 + .../RuntimeTurnSettledHookSettings.test.ts | 86 + .../main/RuntimeTurnSettledIngestor.test.ts | 143 ++ ...amRuntimeTurnSettledTargetResolver.test.ts | 77 + .../main/createMemberWorkSyncFeature.test.ts | 117 ++ 28 files changed, 3379 insertions(+), 12 deletions(-) create mode 100644 docs/team-management/member-work-sync-runtime-stop-hook-plan.md create mode 100644 src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts create mode 100644 src/features/member-work-sync/core/application/RuntimeTurnSettledPorts.ts create mode 100644 src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts create mode 100644 src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts create mode 100644 src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts create mode 100644 src/features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer.ts create mode 100644 src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts create mode 100644 src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledDrainScheduler.ts create mode 100644 src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths.ts create mode 100644 src/features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts create mode 100644 src/features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings.ts create mode 100644 test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts create mode 100644 test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts create mode 100644 test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts create mode 100644 test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts create mode 100644 test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts 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 new file mode 100644 index 00000000..a15dc7c4 --- /dev/null +++ b/docs/team-management/member-work-sync-runtime-stop-hook-plan.md @@ -0,0 +1,1767 @@ +# Member Work Sync Runtime Stop Hook Plan + +**Status:** design ready, not implemented +**Scope:** `member-work-sync`, Claude runtime hook integration, future Codex hook adapter +**Primary repo:** `claude_team` +**Secondary dependency:** `agent_teams_orchestrator` runtime hook payload contract +**Feature name:** `member-work-sync` +**Recommended cut:** provider-neutral turn-settled control plane with Claude Stop hook adapter first + +--- + +## 1. Summary + +Add a provider-neutral runtime turn-settled signal for `member-work-sync`. + +The goal is not to ping agents directly from a hook. The hook only records that a runtime turn ended. The app then performs the same level-triggered reconcile that `member-work-sync` already owns: + +```text +runtime Stop hook +-> durable raw event spool +-> app-side drain and provider normalization +-> team/member resolver +-> MemberWorkSyncEventQueue +-> MemberWorkSyncReconciler +-> optional durable nudge outbox, if policy allows +``` + +Recommended approach: + +**Provider-neutral Stop hook event pipeline, Claude adapter first** +`šŸŽÆ 9 šŸ›”ļø 9 🧠 6`, roughly `650-1000 LOC`. + +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. +- 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. + +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. +- 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. + +Implementation summary: + +```text +Launch path asks member-work-sync for provider settings patch +-> settings patch appends one Stop hook command +-> Stop hook writes raw provider payload to spool +-> app drainer normalizes payload +-> resolver proves active team/member +-> router enqueues turn_settled reconcile +-> existing reconciler/outbox policy decides whether to do nothing or nudge +``` + +Architecture answer: + +- The durable Stop hook ingestion belongs in `claude_team`, not in `agent_teams_orchestrator`, because `member-work-sync` agenda, leases, nudge outbox, cooldowns, and watchdog separation are app-owned policy. +- The orchestrator should only remain a runtime launcher/bridge. It can help with `--settings` materialization, but it should not decide member agenda sync. +- Frontend changes are not needed in v1. This is a control-plane signal, not a new UI workflow. +- The only cross-repo risk is `--settings` merge behavior. If `claude_team` can guarantee a single already-merged inline settings object, no orchestrator change is required. If multiple app-owned inline `--settings` values can still cross the boundary, the orchestrator merge helper must become hook-aware too. + +--- + +## 2. Key Design Decisions + +### 2.1 Use `Stop` Only As A Wake-Up Signal + +The Stop hook must not: + +- decide whether the agent is done; +- write tasks or comments; +- send inbox nudges directly; +- call the app HTTP API; +- block the runtime with `decision: "block"`; +- inject more prompt context. + +The Stop hook only writes raw event payload to durable storage and exits `0`. + +This keeps the model turn lifecycle independent from app policy. It also prevents expensive or looping "keep working" prompts from hook code. + +### 2.2 Do Not Mutate Project `.claude/settings.local.json` + +Do not install this hook by editing a user's project-local Claude settings. + +Reasons: + +- Project settings may already contain user hooks. +- Invalid JSON in project settings should not become our problem. +- Worktrees would each need careful mutation and cleanup. +- A team launch should not permanently alter a customer repo. +- Future Codex support needs a provider adapter, not a Claude-only project settings hack. + +Preferred installation is a managed `--settings` fragment added at launch time. + +### 2.3 Hook Command Must Be Generic + +Do not bake `teamName`, `memberName`, or `runId` into the settings hook command. + +Reason: Claude teammate subprocesses inherit `--settings` from the parent. A hook setting created for the lead can run inside a teammate process. A hook setting created for one teammate can also be copied through restart paths. Identity must therefore be resolved after the event is recorded. + +Allowed identity hints: + +- process environment values, if present; +- hook payload `session_id`; +- hook payload `transcript_path`; +- hook payload `cwd`; +- runtime launch state and log source attribution. + +Hints are never accepted as final truth until validated against active team/member state. + +### 2.4 Use A POSIX Shell Writer, Not Node + +The hook command should invoke a tiny app-owned POSIX shell script: + +```text +/bin/sh "/member-work-sync/hooks/bin/turn-settled-hook-v1.sh" "" "claude" +``` + +Why shell is better than Node here: + +- No dependency on `node` being available in the user's shell PATH. +- No dependency on Electron internals from an external hook process. +- Very small failure surface. +- Works for Claude now and can be reused for Codex later. + +The shell script should not parse JSON. It writes raw stdin to an atomic event file. The app process parses and validates later. + +### 2.5 Spool Files Instead Of Shared JSONL Append + +Use one atomic file per hook event, not one append-only JSONL file. + +Reason: many agents can stop at nearly the same time. Atomic file writes avoid append interleaving, file lock contention, and partial JSONL line corruption. + +Recommended layout: + +```text +/.member-work-sync/runtime-hooks/ + bin/ + turn-settled-hook-v1.sh + incoming/ + 20260429T120102Z-12345-a1b2c3.json + processing/ + processed/ + invalid/ +``` + +The hook script writes a hidden temp file under `incoming/`, then `mv`s it to a final `.json` filename. + +### 2.6 Drain In The App Process + +The Electron main process owns: + +- reading incoming hook files; +- parsing raw payloads; +- normalizing provider-specific payloads; +- resolving team/member; +- emitting `member-turn-settled` events to `member-work-sync`. + +The hook process must stay dumb. + +### 2.7 No New Feature Flag For Claude Stop Hook + +Do not add a user-facing feature flag for the Claude Stop hook in v1. + +Reason: + +- The hook is fail-open and only enqueues an existing `member-work-sync` reconcile. +- It does not directly send nudges. +- 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. + +--- + +## 3. Alternatives Considered + +### Option 1: Provider-Neutral Stop Hook Spool + +`šŸŽÆ 9 šŸ›”ļø 9 🧠 6`, roughly `650-1000 LOC`. + +Add provider-neutral infrastructure now. Implement Claude adapter first. Add Codex adapter later by plugging into the same raw-event contract. + +Pros: + +- clean provider boundary; +- minimal model interruption; +- no user settings mutation; +- safe under high agent count; +- aligns with existing `member-work-sync` control plane. + +Cons: + +- more code than direct hook-to-HTTP; +- requires resolver tests for session and transcript attribution. + +Decision: choose this. + +### Option 2: Claude-Only Stop Hook Directly Enqueues Member + +`šŸŽÆ 7 šŸ›”ļø 7 🧠 4`, roughly `350-600 LOC`. + +Install a Claude Stop hook that writes `{ teamName, memberName }` directly and bypasses provider-neutral normalization. + +Pros: + +- faster to build; +- simpler tests. + +Cons: + +- hard to extend to Codex; +- unsafe if `--settings` is inherited across processes; +- more likely to misattribute lead vs teammate; +- encourages Claude-specific logic inside the feature. + +Decision: reject. + +### Option 3: Gastown-Style Blocking Stop Hook + +`šŸŽÆ 5 šŸ›”ļø 5 🧠 6`, roughly `400-750 LOC`. + +Return `decision: "block"` from the Stop hook when the app thinks there is work. + +Pros: + +- can force an immediate continuation. + +Cons: + +- risks loops; +- risks repeated acknowledgement-only turns; +- increases token usage; +- bypasses durable outbox and cooldown policy; +- conflicts with watchdog and `member-work-sync` separation. + +Decision: reject for this app. + +### Option 4: Use `TeammateIdle` + +`šŸŽÆ 6 šŸ›”ļø 7 🧠 4`, roughly `250-450 LOC`. + +Use Claude Code's team-specific `TeammateIdle` event. + +Pros: + +- gives direct `team_name` and `teammate_name` for Claude teammates; +- simpler resolver. + +Cons: + +- not provider-neutral; +- not available for Codex; +- does not cover lead/non-team sessions; +- makes future runtime support harder. + +Decision: do not use as v1 base. + +--- + +## 3.1 External Pattern Takeaways + +From the prior Gastown/GoClaw comparison and production control-plane patterns: + +- Keep Gastown's strongest idea: use provider Stop hooks as a low-latency wake-up signal. +- Do not copy Gastown's blocking Stop hook behavior. It is too easy to create loops and token burn in this product. +- Keep GoClaw-style simplicity only at adapter boundaries. The app still needs durable state because team membership, worktrees, task boards, and provider runtimes are richer here. +- Use Kubernetes-style level-triggered reconciliation: the event only says "recheck now", the app recomputes current work state. +- Use SQS/BullMQ-style lease semantics already present in `member-work-sync`: a report/lease is temporary and tied to current agenda fingerprint. +- Use GitHub Actions-style concurrency key behavior: one in-flight reconcile/nudge per `team/member/fingerprint`, not one per hook event. + +This means the architecture should be better suited to this app than a direct Gastown copy: provider hooks are adapters, durable reconciliation remains in the feature core, and watchdog stays a separate slow semantic layer. + +--- + +## 4. Clean Architecture Shape + +The feature remains under: + +```text +src/features/member-work-sync/ +``` + +New code should follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +### 4.0 Current Code Integration Map + +The implementation should touch these current seams: + +```text +src/features/member-work-sync/ + contracts/ + types.ts # add runtime turn-settled diagnostics DTOs only if UI/API needs them + core/ + domain/ + RuntimeTurnSettledEvent.ts # normalized event value objects + RuntimeTurnSettledProvider.ts # 'claude' | 'codex' + application/ + RuntimeTurnSettledIngestor.ts # drain, normalize, resolve, enqueue + runtimeTurnSettledPorts.ts # narrow ports + ports.ts # re-export or colocate new ports + main/ + adapters/ + input/ + MemberWorkSyncTeamChangeRouter.ts # route member-turn-settled + RuntimeTurnSettledMemberQueue.ts # small queue adapter, if keeping router thin + output/ + TeamRuntimeTurnSettledTargetResolver.ts + composition/ + createMemberWorkSyncFeature.ts # wire installer/store/normalizers/scheduler/ingestor + infrastructure/ + RuntimeTurnSettledSpoolPaths.ts + ShellRuntimeTurnSettledHookScriptInstaller.ts + FileRuntimeTurnSettledEventStore.ts + RuntimeTurnSettledDrainScheduler.ts + ClaudeStopHookPayloadNormalizer.ts + CodexStopHookPayloadNormalizer.ts # tests only first + runtimeTurnSettledHookSettings.ts # settings patch builder and hook-aware merge +``` + +Launch integration should be intentionally small: + +```text +src/main/services/team/TeamProvisioningService.ts + # call public member-work-sync main helper/facade to get a settings patch + # pass patch into provider args/settings merge + # do not import member-work-sync infrastructure internals + +src/main/services/runtime/cliSettingsArgs.ts + # replace or extend current generic --settings JSON merge so hooks arrays are additive +``` + +Shared event type touch: + +```text +src/shared/types/team.ts + # add TeamChangeEvent type 'member-turn-settled' + +src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts + # add trigger reason 'turn_settled' +``` + +Tests should live with the feature: + +```text +test/features/member-work-sync/main/ + RuntimeTurnSettledHookSettings.test.ts + RuntimeTurnSettledSpool.test.ts + RuntimeTurnSettledIngestor.test.ts + RuntimeTurnSettledTargetResolver.test.ts + createMemberWorkSyncFeature.test.ts +``` + +Do not add runtime Stop hook code to: + +- renderer components; +- `TeamTaskStallMonitor`; +- OpenCode delivery ledger; +- task log stream services; +- direct IPC handlers outside the feature facade. + +Those layers can observe outcomes later, but they should not own ingestion or policy. This keeps SRP clean: runtime hook ingestion is an input adapter, `member-work-sync` is policy, watchdog is semantic stall policy, and runtime adapters only launch processes. + +### 4.1 Core Domain + +`core/domain` remains provider-agnostic. + +Possible additions: + +```text +core/domain/RuntimeTurnSettledEvent.ts +core/domain/RuntimeTurnSettledProvider.ts +``` + +Domain responsibilities: + +- define normalized event value objects; +- validate basic invariants; +- no filesystem; +- no Electron; +- no Claude/Codex CLI details. + +Example: + +```ts +export type RuntimeTurnSettledProvider = 'claude' | 'codex'; + +export interface RuntimeTurnSettledEvent { + schemaVersion: 1; + provider: RuntimeTurnSettledProvider; + hookEventName: 'Stop'; + sourceId: string; + payloadHash: string; + recordedAt: string; + sessionId?: string; + turnId?: string; + transcriptPath?: string; + cwd?: string; + hints?: { + teamName?: string; + memberName?: string; + runId?: string; + }; +} +``` + +`sourceId` should be deterministic for storage/debugging but not trusted for dedupe alone: + +```ts +export function buildRuntimeTurnSettledSourceId(input: { + provider: RuntimeTurnSettledProvider; + sessionId?: string; + turnId?: string; + payloadHash: string; +}): string { + return [ + 'runtime-turn-settled', + input.provider, + input.sessionId || 'no-session', + input.turnId || 'no-turn', + input.payloadHash, + ].join(':'); +} +``` + +Domain invariants: + +- `provider` is known. +- `hookEventName` is `Stop`. +- `payloadHash` is non-empty. +- `recordedAt` is valid ISO or normalized by infrastructure before domain construction. +- `sourceId` contains no filesystem path and no message text. + +### 4.2 Core Application + +`core/application` owns use cases and ports. + +Possible additions: + +```text +core/application/RuntimeTurnSettledIngestor.ts +core/application/RuntimeTurnSettledResolver.ts +core/application/runtimeTurnSettledPorts.ts +``` + +Ports: + +```ts +export interface RuntimeTurnSettledEventStorePort { + claimPending(limit: number): Promise; + markProcessed(input: { id: string; resolved: boolean; reason?: string }): Promise; + markInvalid(input: { id: string; reason: string }): Promise; + release(input: { id: string; reason: string }): Promise; +} + +export interface RuntimeTurnSettledNormalizerPort { + provider: RuntimeTurnSettledProvider; + normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null; +} + +export interface RuntimeTurnSettledTargetResolverPort { + resolve(event: RuntimeTurnSettledEvent): Promise< + | { ok: true; teamName: string; memberName: string; runId?: string } + | { ok: false; reason: string } + >; +} + +export interface RuntimeTurnSettledQueuePort { + enqueue(input: { + teamName: string; + memberName: string; + triggerReason: 'turn_settled'; + runAfterMs?: number; + }): void; +} +``` + +The use case: + +```ts +export class RuntimeTurnSettledIngestor { + constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {} + + async drain(): Promise { + const claims = await this.deps.store.claimPending(this.deps.batchSize); + const summary = createEmptyDrainSummary(); + + for (const claim of claims) { + try { + const normalizer = this.deps.normalizers.get(claim.provider); + if (!normalizer) { + await this.deps.store.markInvalid({ + id: claim.id, + reason: 'unsupported_provider', + }); + summary.invalid += 1; + continue; + } + + const normalized = normalizer.normalize(claim.raw); + if (!normalized) { + await this.deps.store.markInvalid({ + id: claim.id, + reason: 'invalid_payload', + }); + summary.invalid += 1; + continue; + } + + const target = await this.deps.targetResolver.resolve(normalized); + if (!target.ok) { + await this.deps.store.markProcessed({ + id: claim.id, + resolved: false, + reason: target.reason, + }); + summary.unresolved += 1; + continue; + } + + this.deps.queue.enqueue({ + teamName: target.teamName, + memberName: target.memberName, + triggerReason: 'turn_settled', + runAfterMs: this.deps.turnSettledDelayMs, + }); + await this.deps.store.markProcessed({ id: claim.id, resolved: true }); + summary.enqueued += 1; + } catch (error) { + await this.deps.store.release({ + id: claim.id, + reason: error instanceof Error ? error.message : String(error), + }); + summary.released += 1; + } + } + + return summary; + } +} +``` + +The use case depends only on ports. It should not import `fs`, `path`, Electron, `TeamProvisioningService`, or concrete stores. + +### 4.3 Main Infrastructure + +`main/infrastructure` owns technical details. + +Possible additions: + +```text +main/infrastructure/RuntimeTurnSettledSpoolPaths.ts +main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts +main/infrastructure/FileRuntimeTurnSettledEventStore.ts +main/infrastructure/RuntimeTurnSettledDrainScheduler.ts +main/infrastructure/ClaudeStopHookPayloadNormalizer.ts +main/infrastructure/CodexStopHookPayloadNormalizer.ts +``` + +Responsibilities: + +- write shell hook script; +- manage spool directories; +- claim event files with atomic rename to `processing/`; +- parse JSON with bounded size; +- move invalid files to `invalid/`; +- delete or archive processed files with bounded retention; +- schedule drain with bounded concurrency and `unref()` timers. + +### 4.4 Main Adapters + +Input adapter: + +```text +main/adapters/input/RuntimeTurnSettledMemberWorkSyncAdapter.ts +``` + +It translates resolved runtime turn events into `MemberWorkSyncEventQueue.enqueue`. + +Output adapters: + +```text +main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +``` + +It resolves team/member through existing team stores and runtime metadata. + +### 4.5 Composition + +`createMemberWorkSyncFeature()` wires: + +- spool paths; +- hook script installer; +- event store; +- provider normalizers; +- target resolver; +- drain scheduler; +- ingestor. + +Facade additions: + +```ts +export interface MemberWorkSyncFeatureFacade { + buildRuntimeTurnSettledHookSettings(input: { + provider: RuntimeTurnSettledProvider; + }): Promise | null>; + + drainRuntimeTurnSettledEvents(): Promise; +} +``` + +Launch code should call only the facade or a public helper from `@features/member-work-sync/main`. It should not deep-import infrastructure. + +--- + +## 5. Hook Installation Details + +### 5.1 Managed Settings Fragment + +For Claude: + +```ts +const settingsPatch = { + hooks: { + Stop: [ + { + matcher: '', + hooks: [ + { + type: 'command', + command: buildTurnSettledHookCommand({ + scriptPath, + spoolPath, + provider: 'claude', + }), + }, + ], + }, + ], + }, +}; +``` + +The command must be deterministic and idempotent. + +### 5.2 Hook Marker And Deduplication + +Claude hook settings do not have a formal `id` field in the command schema. Deduplication should use a stable command marker: + +```text +# agent-teams:member-work-sync-turn-settled:v1 +``` + +Example command: + +```sh +/bin/sh '/.../turn-settled-hook-v1.sh' '/.../runtime-hooks' 'claude' # agent-teams:member-work-sync-turn-settled:v1 +``` + +Settings merge should: + +- preserve existing user hooks; +- append our hook only if marker is absent; +- not reorder unrelated hooks; +- not duplicate our hook across repeated launch/restart. + +### 5.3 Settings Merge Must Be Hook-Aware + +Current generic deep merge replaces arrays. That is unsafe for `hooks.Stop`. + +Add a hook-aware merge helper. It can live in `src/main/services/runtime/cliSettingsArgs.ts` if it must merge all launch `--settings`, or in `src/features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings.ts` if the launch path only needs to build a single already-merged fragment. Prefer a shared runtime helper if multiple launch fragments already use `--settings`. + +```ts +type JsonObject = Record; + +const MEMBER_WORK_SYNC_HOOK_MARKER = 'agent-teams:member-work-sync-turn-settled:v1'; + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function mergeHooksConfig(target: unknown, source: unknown): unknown { + if (!isJsonObject(target) && !isJsonObject(source)) { + return source; + } + + const merged: JsonObject = isJsonObject(target) ? { ...target } : {}; + if (!isJsonObject(source)) { + return merged; + } + + for (const [eventName, sourceEntries] of Object.entries(source)) { + if (!Array.isArray(sourceEntries)) { + merged[eventName] = sourceEntries; + continue; + } + + const currentEntries = Array.isArray(merged[eventName]) + ? [...(merged[eventName] as unknown[])] + : []; + for (const entry of sourceEntries) { + if (isMemberWorkSyncHookEntry(entry)) { + const alreadyPresent = currentEntries.some(isMemberWorkSyncHookEntry); + if (alreadyPresent) { + continue; + } + } + currentEntries.push(entry); + } + merged[eventName] = currentEntries; + } + + return merged; +} + +function isMemberWorkSyncHookEntry(entry: unknown): boolean { + if (!isJsonObject(entry) || !Array.isArray(entry.hooks)) { + return false; + } + + return entry.hooks.some((hook) => { + if (!isJsonObject(hook) || typeof hook.command !== 'string') { + return false; + } + return hook.command.includes(MEMBER_WORK_SYNC_HOOK_MARKER); + }); +} + +export function mergeRuntimeSettingsFragments(fragments: JsonObject[]): JsonObject { + let merged: JsonObject = {}; + for (const fragment of fragments) { + merged = mergeRuntimeSettingsObject(merged, fragment); + } + return merged; +} + +function mergeRuntimeSettingsObject(target: JsonObject, source: JsonObject): JsonObject { + const output: JsonObject = { ...target }; + for (const [key, value] of Object.entries(source)) { + if (key === 'hooks') { + output.hooks = mergeHooksConfig(output.hooks, value); + continue; + } + + const current = output[key]; + if (isJsonObject(current) && isJsonObject(value)) { + output[key] = mergeRuntimeSettingsObject(current, value); + continue; + } + + output[key] = value; + } + return output; +} +``` + +Test cases: + +- fastMode settings + Stop hook settings both survive; +- two Stop hook fragments dedupe our marker; +- user Stop hook and our Stop hook both survive; +- non-hook arrays keep existing generic replace behavior unless explicitly supported. +- malformed `hooks.Stop` from another fragment is not "fixed" silently. Preserve it if it is a non-array value and let Claude validation handle it. +- if any `--settings` value is a file path, do not try to parse or rewrite it in `claude_team`; preserve existing behavior and append our own inline settings fragment separately. + +Important implementation note: + +The current `mergeJsonObjectCliFlagValues()` in `agent_teams_orchestrator` also deep-merges `--settings` values. If multiple inline JSON settings are passed to Claude, arrays can still be overwritten there. Either: + +- upstream the same hook-aware merge into `agent_teams_orchestrator/src/utils/cliArgs.ts`, or +- ensure `claude_team` passes exactly one already-merged inline JSON settings fragment. + +Recommended v1: pass exactly one already-merged inline JSON settings fragment from `claude_team` for app-owned settings, and add an orchestrator test proving `fastMode + hooks.Stop` survive eager settings load. + +### 5.4 Do Not Fail Team Launch If Hook Install Fails + +If hook script installation or settings patch generation fails: + +- log a warning; +- continue launch without Stop hook; +- `member-work-sync` still works from startup scans, task events, inbox events, tool activity, and watchdog. + +Reason: Stop hook is an optimization signal, not a hard runtime dependency. + +### 5.5 Launch Integration Shape + +Launch code should ask the feature for an optional settings patch and merge it with existing app-owned Claude settings before args are finalized. + +Sketch: + +```ts +const appSettingsFragments: JsonObject[] = []; + +const fastModeSettings = buildAnthropicFastModeSettings(...); +if (fastModeSettings) { + appSettingsFragments.push(fastModeSettings); +} + +const turnSettledHookSettings = + await memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings({ + provider: 'claude', + }).catch((error) => { + logger.warn('member-work-sync Stop hook unavailable', { error }); + return null; + }); + +if (turnSettledHookSettings) { + appSettingsFragments.push(turnSettledHookSettings); +} + +const appSettings = mergeRuntimeSettingsFragments(appSettingsFragments); +args = mergeJsonSettingsArgs(args, JSON.stringify(appSettings)); +``` + +Rules: + +- existing user-supplied config/settings stays user-owned and is not rewritten; +- app-owned settings should be merged before crossing process boundaries; +- hook settings generation failure logs warning only; +- tests must prove no duplicate hook command after restart/relaunch. + +--- + +## 6. Hook Writer Script + +### 6.1 Requirements + +The shell script must: + +- use `/bin/sh`; +- accept `spoolRoot` and `provider`; +- create `incoming/`; +- read stdin to a temp file with size bounded by shell-independent outer command where possible; +- atomically rename temp to final `.json`; +- add no stdout; +- keep stderr quiet unless debugging is explicitly enabled; +- exit `0` even when it cannot write. + +### 6.2 Example Script Shape + +```sh +#!/bin/sh +set +e + +spool_root="$1" +provider="$2" +max_bytes="${3:-262144}" + +if [ -z "$spool_root" ] || [ -z "$provider" ]; then + exit 0 +fi + +case "$provider" in + claude|codex) ;; + *) exit 0 ;; +esac + +incoming="$spool_root/incoming" +mkdir -p "$incoming" 2>/dev/null || exit 0 + +stamp="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo unknown-time)" +tmp="$(mktemp "$incoming/.turn-settled.XXXXXX" 2>/dev/null)" || exit 0 +suffix="$(basename "$tmp" | sed 's/^\\.turn-settled\\.//')" +final="$incoming/$stamp-$$-$suffix.$provider.json" + +# Use dd to bound the file even if a provider accidentally sends a huge payload. +# dd is POSIX. If it fails, fall back to fail-open cleanup. +dd bs="$max_bytes" count=1 of="$tmp" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null + exit 0 +} + +# Empty payloads are not useful. They are dropped here to reduce invalid spool noise. +if [ ! -s "$tmp" ]; then + rm -f "$tmp" 2>/dev/null + exit 0 +fi + +mv "$tmp" "$final" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null + exit 0 +} + +exit 0 +``` + +The file content is raw provider hook payload. The provider is also encoded in the filename. The drainer validates both. + +### 6.3 Payload Size Guard + +Claude Stop payload can include `last_assistant_message`. To avoid huge files: + +- the shell writer limits stdin to `256KB` by default; +- app-side reader still refuses files over the same fixed limit; +- oversized payloads are treated as invalid, never as resolved events. + +For v1, app-side read limit is mandatory. + +### 6.4 Hook Command Quoting + +Build the hook command with shell-safe single-quote escaping: + +```ts +const HOOK_MARKER = 'agent-teams:member-work-sync-turn-settled:v1'; + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function buildTurnSettledHookCommand(input: { + scriptPath: string; + spoolRoot: string; + provider: RuntimeTurnSettledProvider; + maxBytes?: number; +}): string { + return [ + '/bin/sh', + shellQuote(input.scriptPath), + shellQuote(input.spoolRoot), + shellQuote(input.provider), + shellQuote(String(input.maxBytes ?? 262_144)), + '#', + HOOK_MARKER, + ].join(' '); +} +``` + +Rules: + +- never interpolate unquoted paths; +- never include team/member identity in the command; +- never include secrets or auth paths; +- keep the marker at the end so it is easy to detect without parsing shell syntax. + +--- + +## 7. Event Store And Drain Semantics + +### 7.1 Claiming Files + +`FileRuntimeTurnSettledEventStore.claimPending(limit)`: + +1. list `incoming/*.json`; +2. sort by filename for deterministic order; +3. for each file, atomic rename to `processing/`; +4. if rename fails, skip because another process may have claimed it; +5. return claims with file path and provider inferred from extension. + +Code sketch: + +```ts +export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEventStorePort { + constructor(private readonly deps: { + paths: RuntimeTurnSettledSpoolPaths; + maxBytes: number; + clock: MemberWorkSyncClockPort; + }) {} + + async claimPending(limit: number): Promise { + await this.recoverStaleProcessingFiles(); + const incoming = this.deps.paths.incomingDir(); + const processing = this.deps.paths.processingDir(); + await fs.promises.mkdir(processing, { recursive: true }); + + const names = (await fs.promises.readdir(incoming).catch(() => [])) + .filter((name) => providerFromFileName(name) !== null) + .sort() + .slice(0, Math.max(0, limit)); + + const claims: RuntimeTurnSettledClaim[] = []; + for (const name of names) { + const sourcePath = path.join(incoming, name); + const claimedPath = path.join(processing, name); + const provider = providerFromFileName(name); + if (!provider) { + continue; + } + try { + await fs.promises.rename(sourcePath, claimedPath); + } catch { + continue; + } + + let stat: Awaited>; + try { + stat = await fs.promises.stat(claimedPath); + } catch (error) { + claims.push({ + id: name, + provider, + raw: null, + claimedPath, + transientError: error instanceof Error ? error.message : String(error), + }); + continue; + } + if (stat.size > this.deps.maxBytes) { + claims.push({ + id: name, + provider, + raw: null, + claimedPath, + invalidReason: 'payload_too_large', + }); + continue; + } + + let raw: string; + try { + raw = await fs.promises.readFile(claimedPath, 'utf8'); + } catch (error) { + claims.push({ + id: name, + provider, + raw: null, + claimedPath, + transientError: error instanceof Error ? error.message : String(error), + }); + continue; + } + claims.push({ + id: name, + provider, + raw, + claimedPath, + }); + } + return claims; + } +} +``` + +`claimPending()` should not throw for one bad file. Bad files become claims with `invalidReason`, so the ingestor can quarantine them and continue. Transient read/stat errors become claims with `transientError`, so the ingestor can release them back for retry. + +File name validation is part of the store, not the normalizer: + +```ts +const EVENT_FILE_PATTERN = + /^\d{8}T\d{6}Z-\d+-[A-Za-z0-9._-]+\.(claude|codex)\.json$/; + +function providerFromFileName(name: string): RuntimeTurnSettledProvider | null { + const match = EVENT_FILE_PATTERN.exec(name); + if (!match) { + return null; + } + return match[1] as RuntimeTurnSettledProvider; +} +``` + +Rules: + +- ignore hidden temp files; +- reject names with path separators; +- reject unknown provider suffixes; +- never derive any target identity from the filename. + +### 7.2 Processing Outcome + +For each claim: + +- processed and resolved: move to `processed/` or delete after metrics are recorded; +- parsed but unresolved: move to `processed/` with unresolved metric, not `invalid`; +- invalid JSON or too large: move to `invalid/`; +- transient read error: release back to `incoming/` or leave in `processing/` with recovery scan. + +Important distinction: + +```text +invalid = payload cannot be trusted or parsed +unresolved = payload is valid, but no active team/member target was proven +``` + +Unresolved events are not errors. They are expected when a lead or non-team Claude session stops. + +### 7.3 Recovery From Crashes + +On app startup: + +- move stale `processing/` files older than e.g. `5 minutes` back to `incoming/`; +- then drain normally. + +This prevents losing events if the app crashes mid-drain. + +Recovery should run before every scheduled drain as well, not only at startup. This handles app crashes and abrupt process kills without requiring a full app restart. + +Recovery should use file age, not process liveness. The app can crash while a process that wrote the file is still alive, and checking arbitrary PIDs here would couple the store to runtime process management. + +### 7.4 Retention + +Keep processed/invalid files only for a short developer/debug window: + +- processed: max `1000` files or `24h`; +- invalid: max `100` files or `72h`. + +These are not user-facing logs. + +### 7.5 Drain Scheduler + +The drain scheduler should be lightweight and bounded: + +```ts +export class RuntimeTurnSettledDrainScheduler { + private timer: ReturnType | null = null; + private running = false; + + start(): void { + if (this.timer) { + return; + } + this.timer = setInterval(() => void this.tick(), 10_000); + this.timer.unref?.(); + void this.tick(); + } + + private async tick(): Promise { + if (this.running) { + return; + } + this.running = true; + try { + await this.ingestor.drain(); + } finally { + this.running = false; + } + } +} +``` + +Rules: + +- one drain at a time; +- bounded batch size, recommended `50`; +- `unref()` timer; +- no UI dependency; +- `dispose()` stops timer and waits for current drain; +- scheduler errors are logged once per tick and never crash the app; +- if one drain finds a full batch, schedule an immediate follow-up tick to clear backlog without waiting the full interval. + +--- + +## 8. Provider Normalizers + +### 8.1 Claude Stop Normalizer + +Input fields expected from Claude hook payload: + +- `hook_event_name`; +- `session_id`; +- `transcript_path`; +- `cwd`; +- possibly `turn_id`; +- possibly `last_assistant_message`. + +Rules: + +- accept only `hook_event_name === "Stop"`; +- do not persist full `last_assistant_message`; +- compute `payloadHash` from raw payload; +- trim and normalize paths but do not require them to exist at normalization time; +- produce `RuntimeTurnSettledEvent`. + +Implementation sketch: + +```ts +export class ClaudeStopHookPayloadNormalizer implements RuntimeTurnSettledNormalizerPort { + readonly provider = 'claude' as const; + + normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null { + if (raw.provider !== 'claude' || typeof raw.text !== 'string') { + return null; + } + + const parsed = parseJsonObject(raw.text); + if (!parsed || parsed.hook_event_name !== 'Stop') { + return null; + } + + const payloadHash = sha256(raw.text); + const sessionId = readOptionalString(parsed.session_id); + const turnId = readOptionalString(parsed.turn_id); + + return { + schemaVersion: 1, + provider: 'claude', + hookEventName: 'Stop', + sourceId: buildRuntimeTurnSettledSourceId({ + provider: 'claude', + sessionId, + turnId, + payloadHash, + }), + payloadHash, + recordedAt: raw.recordedAt, + sessionId, + turnId, + transcriptPath: normalizeOptionalPath(parsed.transcript_path), + cwd: normalizeOptionalPath(parsed.cwd), + }; + } +} +``` + +`parseJsonObject()` must reject arrays and primitives. Do not pass raw parsed provider payload outside the normalizer. + +Normalizer tests should cover: + +- valid Stop payload returns normalized event; +- non-Stop event returns `null`; +- malformed JSON returns `null`; +- `last_assistant_message` is not copied; +- missing `session_id` is allowed but resolver may later ignore the event; +- huge payload never reaches normalizer because the store quarantines it first. + +### 8.2 Codex Stop Normalizer, Future + +Codex support should be added by implementing the same interface: + +```ts +class CodexStopHookPayloadNormalizer implements RuntimeTurnSettledNormalizerPort { + provider = 'codex' as const; + normalize(raw: RuntimeTurnSettledRawPayload): RuntimeTurnSettledEvent | null { + // Codex payload mapping + } +} +``` + +Do not add Codex launch behavior until there is a contract test with a captured or synthetic Codex Stop payload. + +--- + +## 9. Target Resolution + +`TeamRuntimeTurnSettledTargetResolver` should resolve conservatively. + +Resolution order: + +1. Runtime launch state by `sessionId`. +2. Transcript path attribution. +3. Validated explicit hints, if event contains them. +4. CWD/team metadata match, only if it maps to exactly one active team/member. + +Session and transcript evidence should beat hints because hints can be inherited through `--settings`. + +If resolution is ambiguous: + +- do not enqueue; +- record metric `unresolved_ambiguous_target`; +- leave debug diagnostics. + +If team is stopped/offline: + +- do not enqueue; +- record metric `ignored_inactive_team`. + +If member is removed/inactive: + +- do not enqueue; +- record metric `ignored_inactive_member`. + +Implementation sketch: + +```ts +export class TeamRuntimeTurnSettledTargetResolver + implements RuntimeTurnSettledTargetResolverPort { + constructor(private readonly deps: { + teamReader: TeamConfigReaderPort; + launchStateReader: RuntimeLaunchStateReaderPort; + transcriptAttribution: RuntimeTranscriptAttributionPort; + teamStatusReader: TeamStatusReaderPort; + }) {} + + async resolve(event: RuntimeTurnSettledEvent): Promise { + const candidates = compact([ + await this.fromSessionId(event), + await this.fromTranscriptPath(event), + await this.fromValidatedHints(event), + await this.fromUniqueCwd(event), + ]); + + const active = []; + for (const candidate of candidates) { + const verified = await this.verifyCandidate(event, candidate); + if (verified.ok) { + active.push(verified.target); + } + } + + const unique = uniqueTargets(active); + if (unique.length === 1) { + return { ok: true, ...unique[0] }; + } + if (unique.length > 1) { + return { ok: false, reason: 'unresolved_ambiguous_target' }; + } + return { ok: false, reason: 'unresolved_no_target' }; + } +} +``` + +`verifyCandidate()` must check: + +- team exists and is not stopped/cancelled; +- member exists in current config or members-meta; +- member is active, not removed; +- provider matches `claude` for Claude Stop events; +- if `sessionId` exists, it belongs to the same member or is absent from known runtime state; +- if `transcriptPath` exists, it is under the expected team/member transcript root or matches known attribution. + +Never enqueue based only on `cwd` when more than one team can match the same project path. Worktrees and restarted teams make CWD ambiguous. + +### 9.1 Explicit Hints Are Not Trusted Alone + +If a hook event includes hints: + +```ts +{ + hints: { + teamName: 'atlas', + memberName: 'bob' + } +} +``` + +The resolver must still verify: + +- team exists; +- member exists in config/meta; +- member is active; +- event session/transcript is compatible if those fields are present. + +### 9.2 Lead Sessions + +Lead sessions may emit Stop events too. + +Policy: + +- Ignore lead Stop for member-specific work sync in v1, because task/inbox events already cover most team-wide changes. +- Do not fan out a lead Stop to all active members unless a later phase explicitly adds a separate team-level policy with its own anti-spam tests. + +Recommended v1 default: ignore unresolved lead events. + +If a lead event is strongly resolved as lead, record `ignored_lead_turn_settled` and do not enqueue all members in v1. Team-wide task/inbox changes already trigger member sync, and fan-out from lead Stop could create noisy false positives. + +### 9.3 OpenCode Sessions + +This plan is not for OpenCode. OpenCode runtime already has separate delivery/watchdog mechanisms. + +Do not infer OpenCode member work sync from Claude Stop events. + +--- + +## 10. Member Work Sync Integration + +Add trigger reason: + +```ts +export type MemberWorkSyncTriggerReason = + | ... + | 'turn_settled'; +``` + +Add event type: + +```ts +export interface TeamChangeEvent { + type: + | ... + | 'member-turn-settled'; + teamName: string; + runId?: string; + detail?: string; + taskId?: string; +} +``` + +Current `TeamChangeEvent.detail` is a string used broadly across preload, renderer store, and log/task panels. Do not change it to `unknown` in this cut. Encode a small JSON detail string and validate it at the router boundary: + +```ts +export interface MemberTurnSettledTeamChangeDetail { + memberName: string; + provider: 'claude' | 'codex'; + sourceId: string; +} + +function serializeMemberTurnSettledDetail( + detail: MemberTurnSettledTeamChangeDetail +): string { + return JSON.stringify(detail); +} +``` + +Router behavior: + +```ts +if (event.type === 'member-turn-settled') { + const detail = parseMemberTurnSettledDetail(event.detail); + if (!detail) { + return; + } + + queue.enqueue({ + teamName: event.teamName, + memberName: detail.memberName, + triggerReason: 'turn_settled', + runAfterMs: 15_000, + }); + return; +} +``` + +Parsing detail explicitly avoids fragile string conventions and gives future Codex/Claude diagnostics without changing the queue contract again. `sourceId` is intentionally not added to the queue item in v1, because queue coalescing is keyed by `team/member`. Keep `sourceId` in drain/router debug logs only. + +`parseMemberTurnSettledDetail()` should reject missing JSON, non-object JSON, empty `memberName`, and unknown provider values. Invalid detail is ignored, not routed as team-wide. + +Why `15_000` instead of immediate: + +- allows logs/task writes from the just-finished turn to land; +- coalesces repeated hook events; +- avoids racing with task/comment writes emitted near turn end. + +The existing queue still applies normal quiet/coalescing behavior. + +--- + +## 11. Watchdog Interaction + +Stop hook events must not conflict with `TeamTaskStallMonitor`. + +Rules: + +- Stop hook event is a fast consistency trigger. +- It does not count as progress proof. +- It does not suppress watchdog by itself. +- If `member-work-sync` sends a nudge, the existing watchdog cooldown adapter can suppress near-duplicate task-stall nudges. +- If watchdog sends a nudge first, `member-work-sync` dispatcher must see that cooldown and avoid duplicate member sync pings. + +This keeps responsibilities separate: + +```text +member-work-sync: does the member know the current actionable agenda? +watchdog: has meaningful task progress stalled for too long? +delivery ledger: did a specific message reach a runtime? +runtime liveness: is a process/session alive? +``` + +Required anti-spam contract: + +- A `turn_settled` enqueue may cause at most one `member-work-sync` reconciliation for `(teamName, memberName)` after coalescing. +- It must not create a nudge if the agenda fingerprint is already caught up or covered by a valid lease/report. +- If a watchdog nudge was recently sent for the same member/task, `member-work-sync` must respect the existing cooldown adapter. +- If `member-work-sync` sends a nudge first, watchdog must see the same cooldown signal through the already-existing `TeamTaskStallJournalWorkSyncCooldown`. +- Stop hook events do not reset leases and do not extend leases. Only explicit report/tool/task state can do that. + +--- + +## 12. Security And Reliability + +### 12.1 No Network In Hook + +The hook must not call local HTTP control endpoints. Reasons: + +- app may be down; +- hook could hang; +- local ports can change; +- network calls increase Stop latency. + +File spool is safer. + +### 12.2 No Secrets In Spool + +Do not persist: + +- API keys; +- full env; +- full assistant messages; +- user prompt content beyond what Claude already puts in raw Stop payload. + +If raw payload includes `last_assistant_message`, the app normalizer should not copy it into normalized event. Optionally hash it for dedupe. + +### 12.3 Bounded IO + +Every read/list operation must be bounded: + +- max event file size; +- max files per drain tick; +- bounded retention cleanup; +- no recursive scanning outside known spool dirs. + +### 12.4 Fail Open + +If hook settings cannot be installed, launch continues. + +If hook command cannot write, it exits `0`. + +If event cannot resolve, no nudge is sent. + +--- + +## 13. Implementation Phases + +Commit order should follow dependency direction. Each commit must leave tests for touched layer green. + +```text +commit 1: domain ports + hook settings merge + script installer +commit 2: file spool store + normalizers + ingestor +commit 3: target resolver + router integration +commit 4: launch integration + composition wiring +commit 5: docs/test hardening and optional Codex contract adapter +``` + +Do not wire launch integration before `RuntimeTurnSettledHookSettings.test.ts` and store/ingestor tests exist. Otherwise a bad hook command could silently ship into every launched Claude teammate. + +### Phase 0: Preflight And Existing-Code Assertions + +`šŸŽÆ 10 šŸ›”ļø 9 🧠 2`, roughly `40-90 LOC`. + +Tasks: + +- add characterization tests for current `mergeJsonSettingsArgs` or equivalent `--settings` helper; +- add characterization test that `createMemberWorkSyncFeature()` exposes stable startup/dispose lifecycle; +- add a small fixture for a Claude Stop payload with `session_id`, `transcript_path`, and `cwd`; +- confirm no renderer import path is needed. + +Why this phase exists: it locks current behavior before adding hook-aware merge and prevents accidental breakage of existing Claude fast mode settings. + +### Phase 1: Hook Settings And Spool Foundation + +`šŸŽÆ 9 šŸ›”ļø 9 🧠 5`, roughly `300-450 LOC`. + +Tasks: + +- add `RuntimeTurnSettledSpoolPaths`; +- add shell hook script installer; +- add hook-aware settings merge helper; +- add Claude Stop settings patch builder; +- wire launch path to include patch through public member-work-sync main helper/facade; +- keep failure non-fatal. +- if the orchestrator can still receive multiple inline app-owned settings fragments, add the same hook-aware merge test/fix there before wiring production launch. + +Tests: + +- script installer writes executable content; +- settings merge preserves existing user hooks; +- settings merge dedupes our hook marker; +- fastMode settings and hook settings both survive. + +### Phase 2: Drainer And Resolver + +`šŸŽÆ 9 šŸ›”ļø 9 🧠 6`, roughly `350-600 LOC`. + +Tasks: + +- add file event store; +- add Claude payload normalizer; +- add target resolver; +- add drain scheduler; +- add startup recovery for stale processing files; +- emit `member-turn-settled` only for resolved active member. +- add structured drain summary for diagnostics but do not expose it to UI yet. + +Tests: + +- concurrent file claims are safe; +- malformed and oversized payloads are quarantined; +- unresolved events do not enqueue; +- sessionId and transcriptPath resolution works; +- stopped team and removed member are ignored. + +### Phase 3: Member Work Sync Router Integration + +`šŸŽÆ 10 šŸ›”ļø 9 🧠 3`, roughly `100-180 LOC`. + +Tasks: + +- add `turn_settled` trigger reason; +- add `member-turn-settled` event type; +- route to one member with a short delay; +- include trigger reason in diagnostics. +- verify watchdog cooldown path is unchanged. + +Tests: + +- router enqueues only target member; +- missing detail is ignored; +- coalescing works with existing queue. + +### Phase 4: Codex Contract-Ready Adapter, No Production Launch Yet + +`šŸŽÆ 7 šŸ›”ļø 8 🧠 5`, roughly `150-300 LOC`. + +Tasks: + +- add `CodexStopHookPayloadNormalizer` behind tests only; +- add synthetic captured-payload tests; +- do not install Codex Stop hook in production until runtime launch and config format are verified. + +Tests: + +- Codex payload maps into same normalized event; +- unsupported event names are ignored; +- missing identity fields stay unresolved. + +--- + +## 14. Testing Plan + +### Unit + +```bash +pnpm vitest run \ + test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts \ + test/features/member-work-sync/main/RuntimeTurnSettledSpool.test.ts \ + test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts \ + test/features/member-work-sync/main/RuntimeTurnSettledTargetResolver.test.ts \ + test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts +``` + +### Existing Regression + +```bash +pnpm vitest run \ + test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts \ + test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts \ + test/features/member-work-sync/main/MemberWorkSyncNudgeDispatchScheduler.test.ts \ + test/features/member-work-sync/main/MemberWorkSyncToolActivityBusySignal.test.ts +``` + +### Broader Regression + +```bash +pnpm typecheck --pretty false +git diff --check +``` + +### Optional Live Claude Hook Probe + +Only if token/cost is acceptable: + +```bash +CLAUDE_TEAM_LIVE_HOOK_PROBE=1 pnpm vitest run test/features/member-work-sync/live/ClaudeStopHook.live.test.ts +``` + +The live probe should: + +- create temp settings with only our Stop hook; +- launch a minimal Claude runtime turn; +- verify one spool event; +- not assert any model behavior. + +Do not run this in normal CI. + +--- + +## 15. Edge Cases + +### 15.1 App Closed While Agent Stops + +Hook still writes event file. On next app startup, drainer processes it if still relevant. If team/member is no longer active, event is ignored. + +### 15.2 Many Agents Stop Together + +Each hook writes a separate file. Drainer claims a bounded batch. Queue coalesces by team/member. + +### 15.3 Hook Script Missing After App Update + +Launch calls installer before creating settings patch. If script path changes, new settings patch uses new path. Old running agents may keep old hook command. If old script is gone, hook exits fail-open or shell fails. No runtime break. + +Recommended improvement: keep versioned scripts for at least one app session after update. + +### 15.4 `--settings` Inheritance + +Inherited settings are expected. The hook is generic. Resolver validates identity post-fact. + +### 15.5 User Has Their Own Stop Hook + +Hook-aware merge appends our hook and preserves the user's hook. If the user hook fails or blocks, that is outside our control, but our hook should be independent. + +### 15.6 Invalid User Project Settings + +Because we do not mutate project settings, invalid project `.claude/settings.local.json` does not affect hook installation beyond normal Claude settings loading behavior. + +### 15.7 Duplicate Events + +Dedupe can use: + +- provider; +- sessionId; +- turnId if present; +- payloadHash; +- recordedAt bucket. + +Even without perfect dedupe, queue coalescing and outbox one-per-fingerprint prevent duplicate nudges. + +### 15.8 Runtime Payload Has No Session ID + +Resolver may use transcript path. If both are missing, event is unresolved and ignored. + +### 15.9 Codex Payload Differs From Claude + +Codex normalizer is separate. It must not force Claude assumptions into the core event model. + +--- + +## 16. Open Questions + +No blocker questions for v1. + +Non-blocking decisions with recommended defaults: + +1. Processed event retention + Recommended: keep `24h` or `1000` files. + `šŸŽÆ 9 šŸ›”ļø 9 🧠 2`, roughly `20-40 LOC`. + This gives enough debugging signal without making hook files a growing log store. + +2. Turn-settled queue delay + Recommended: `15s`. + `šŸŽÆ 8 šŸ›”ļø 9 🧠 1`, roughly `5-15 LOC`. + Short enough to feel responsive, long enough for task/comment writes from the just-finished turn to appear. + +3. Max event file size + Recommended: `256KB`. + `šŸŽÆ 8 šŸ›”ļø 9 🧠 1`, roughly `10-20 LOC`. + Claude Stop payloads should be small for our use case. Large payloads likely contain message text we do not need. + +4. Lead Stop events + Recommended: ignore in v1. + `šŸŽÆ 8 šŸ›”ļø 9 🧠 2`, roughly `10-30 LOC`. + Fan-out from lead Stop is the highest-noise path and current task/inbox events already cover team-wide changes. + +5. Codex production hook installation + Recommended: not in v1. + `šŸŽÆ 7 šŸ›”ļø 8 🧠 5`, roughly `150-300 LOC` later. + Keep the adapter seam and tests, but do not install until Codex hook payload and config semantics are proven. + +The only question worth escalating before implementation is this: + +```text +Should processed hook payload files be retained for 24h for debugging, or deleted immediately after metrics? +``` + +Default if no answer: retain short-term with bounded cleanup. + +--- + +## 17. Acceptance Criteria + +Implementation is accepted when: + +- Claude launches include exactly one managed member-work-sync Stop hook. +- Existing user `hooks.Stop` entries are preserved. +- Existing app inline settings such as fast mode are preserved. +- Hook command writes raw payload files atomically. +- App drains payloads without blocking launch or UI. +- Resolved Claude Stop event enqueues one `turn_settled` reconcile for the correct active member. +- Unresolved, inactive, oversized, and malformed events never send nudges. +- Existing watchdog tests remain green. +- Codex support can be added by implementing provider adapter only, without rewriting core or Claude adapter. +- `member-work-sync` does not nudge if current agenda fingerprint is already caught up or covered by valid lease/report. +- A Stop hook event alone is not counted as task progress. +- Stopped teams, removed members, stale run ids, unresolved lead events, and ambiguous CWD matches are ignored. +- No renderer code is required for v1. +- No user project settings file is mutated. diff --git a/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts b/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts new file mode 100644 index 00000000..4dc6ee5a --- /dev/null +++ b/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts @@ -0,0 +1,96 @@ +import type { MemberWorkSyncClockPort, MemberWorkSyncLoggerPort } from './ports'; +import type { + RuntimeTurnSettledEventStorePort, + RuntimeTurnSettledPayloadNormalizerPort, + RuntimeTurnSettledReconcileQueuePort, + RuntimeTurnSettledTargetResolverPort, +} from './RuntimeTurnSettledPorts'; + +export interface RuntimeTurnSettledIngestorDeps { + eventStore: RuntimeTurnSettledEventStorePort; + normalizer: RuntimeTurnSettledPayloadNormalizerPort; + targetResolver: RuntimeTurnSettledTargetResolverPort; + reconcileQueue: RuntimeTurnSettledReconcileQueuePort; + clock: MemberWorkSyncClockPort; + logger?: MemberWorkSyncLoggerPort; +} + +export interface RuntimeTurnSettledDrainSummary { + claimed: number; + enqueued: number; + unresolved: number; + invalid: number; + failed: number; +} + +export class RuntimeTurnSettledIngestor { + constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {} + + async drainPending(limit: number = 50): Promise { + const summary: RuntimeTurnSettledDrainSummary = { + claimed: 0, + enqueued: 0, + unresolved: 0, + invalid: 0, + failed: 0, + }; + + const payloads = await this.deps.eventStore.claimPending(limit); + summary.claimed = payloads.length; + + for (const payload of payloads) { + const processedAt = this.deps.clock.now().toISOString(); + try { + const normalized = this.deps.normalizer.normalize({ + provider: payload.provider, + raw: payload.raw, + recordedAt: payload.claimedAt, + }); + + if (!normalized.ok) { + summary.invalid += 1; + await this.deps.eventStore.markInvalid(payload, { + reason: normalized.reason, + processedAt, + }); + continue; + } + + const resolution = await this.deps.targetResolver.resolve(normalized.event); + if (!resolution.ok) { + summary.unresolved += 1; + await this.deps.eventStore.markProcessed(payload, { + event: normalized.event, + outcome: 'unresolved', + reason: resolution.reason, + processedAt, + }); + continue; + } + + this.deps.reconcileQueue.enqueueRuntimeTurnSettled({ + teamName: resolution.teamName, + memberName: resolution.memberName, + event: normalized.event, + }); + summary.enqueued += 1; + await this.deps.eventStore.markProcessed(payload, { + event: normalized.event, + teamName: resolution.teamName, + memberName: resolution.memberName, + outcome: 'enqueued', + processedAt, + }); + } catch (error) { + summary.failed += 1; + this.deps.logger?.warn('runtime turn settled ingest failed', { + filePath: payload.filePath, + provider: payload.provider, + error: String(error), + }); + } + } + + return summary; + } +} diff --git a/src/features/member-work-sync/core/application/RuntimeTurnSettledPorts.ts b/src/features/member-work-sync/core/application/RuntimeTurnSettledPorts.ts new file mode 100644 index 00000000..ededdf62 --- /dev/null +++ b/src/features/member-work-sync/core/application/RuntimeTurnSettledPorts.ts @@ -0,0 +1,64 @@ +import type { RuntimeTurnSettledEvent, RuntimeTurnSettledProvider } from '../domain'; + +export interface RuntimeTurnSettledClaimedPayload { + id: string; + filePath: string; + fileName: string; + provider: RuntimeTurnSettledProvider; + raw: string; + claimedAt: string; +} + +export interface RuntimeTurnSettledEventStorePort { + claimPending(limit: number): Promise; + markProcessed( + payload: RuntimeTurnSettledClaimedPayload, + result: RuntimeTurnSettledProcessedResult + ): Promise; + markInvalid( + payload: RuntimeTurnSettledClaimedPayload, + result: RuntimeTurnSettledInvalidResult + ): Promise; +} + +export interface RuntimeTurnSettledProcessedResult { + event: RuntimeTurnSettledEvent; + teamName?: string; + memberName?: string; + outcome: 'enqueued' | 'unresolved' | 'duplicate'; + reason?: string; + processedAt: string; +} + +export interface RuntimeTurnSettledInvalidResult { + reason: string; + processedAt: string; +} + +export type RuntimeTurnSettledPayloadNormalization = + | { ok: true; event: RuntimeTurnSettledEvent } + | { ok: false; reason: string }; + +export interface RuntimeTurnSettledPayloadNormalizerPort { + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization; +} + +export type RuntimeTurnSettledTargetResolution = + | { ok: true; teamName: string; memberName: string } + | { ok: false; reason: string }; + +export interface RuntimeTurnSettledTargetResolverPort { + resolve(event: RuntimeTurnSettledEvent): Promise; +} + +export interface RuntimeTurnSettledReconcileQueuePort { + enqueueRuntimeTurnSettled(input: { + teamName: string; + memberName: string; + event: RuntimeTurnSettledEvent; + }): void; +} diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index be3d297f..585e60eb 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -6,3 +6,5 @@ export * from './MemberWorkSyncPendingReportIntentReplayer'; export * from './MemberWorkSyncReconciler'; export * from './MemberWorkSyncReporter'; export * from './ports'; +export * from './RuntimeTurnSettledIngestor'; +export * from './RuntimeTurnSettledPorts'; diff --git a/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts b/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts new file mode 100644 index 00000000..93c8747a --- /dev/null +++ b/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts @@ -0,0 +1,31 @@ +import type { RuntimeTurnSettledProvider } from './RuntimeTurnSettledProvider'; + +export interface RuntimeTurnSettledEvent { + schemaVersion: 1; + provider: RuntimeTurnSettledProvider; + hookEventName: 'Stop'; + sourceId: string; + payloadHash: string; + recordedAt: string; + sessionId?: string; + turnId?: string; + transcriptPath?: string; + cwd?: string; +} + +export function buildRuntimeTurnSettledSourceId(input: { + provider: RuntimeTurnSettledProvider; + sessionId?: string; + turnId?: string; + transcriptPath?: string; + payloadHash: string; +}): string { + return [ + 'runtime-turn-settled', + input.provider, + input.sessionId?.trim() || 'no-session', + input.turnId?.trim() || 'no-turn', + input.transcriptPath?.trim() || 'no-transcript', + input.payloadHash, + ].join(':'); +} diff --git a/src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts b/src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts new file mode 100644 index 00000000..c3a8dc27 --- /dev/null +++ b/src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts @@ -0,0 +1,5 @@ +export type RuntimeTurnSettledProvider = 'claude' | 'codex'; + +export function isRuntimeTurnSettledProvider(value: unknown): value is RuntimeTurnSettledProvider { + return value === 'claude' || value === 'codex'; +} diff --git a/src/features/member-work-sync/core/domain/index.ts b/src/features/member-work-sync/core/domain/index.ts index 2925168c..4495aaef 100644 --- a/src/features/member-work-sync/core/domain/index.ts +++ b/src/features/member-work-sync/core/domain/index.ts @@ -5,4 +5,6 @@ export * from './memberName'; export * from './MemberWorkSyncPhase2Readiness'; export * from './MemberWorkSyncNudge'; export * from './MemberWorkSyncReportValidator'; +export * from './RuntimeTurnSettledEvent'; +export * from './RuntimeTurnSettledProvider'; export * from './SyncDecisionPolicy'; 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 24a2a899..477a8995 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts @@ -4,6 +4,12 @@ import type { } from '../../infrastructure/MemberWorkSyncEventQueue'; import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; +interface MemberTurnSettledEventPayload { + memberName?: string; + sourceId?: string; + provider?: string; +} + interface MemberWorkSyncRosterSource { loadActiveMemberNames(teamName: string): Promise; } @@ -37,6 +43,18 @@ function parseToolActivity(detail: string | undefined): ToolActivityEventPayload } } +function parseMemberTurnSettled(detail: string | undefined): MemberTurnSettledEventPayload | null { + if (!detail) { + return null; + } + try { + const parsed = JSON.parse(detail) as MemberTurnSettledEventPayload; + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + export class MemberWorkSyncTeamChangeRouter { constructor( private readonly rosterSource: MemberWorkSyncRosterSource, @@ -82,6 +100,19 @@ export class MemberWorkSyncTeamChangeRouter { return; } + if (event.type === 'member-turn-settled') { + const payload = parseMemberTurnSettled(event.detail); + const memberName = payload?.memberName?.trim(); + if (memberName) { + this.queue.enqueue({ + teamName: event.teamName, + memberName, + triggerReason: 'turn_settled', + }); + } + return; + } + if (event.type === 'inbox' || event.type === 'lead-message') { const recipient = parseInboxRecipient(event.detail); if (recipient) { diff --git a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts new file mode 100644 index 00000000..22f3ecfc --- /dev/null +++ b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts @@ -0,0 +1,174 @@ +import path from 'path'; + +import { isReservedMemberName, normalizeMemberName } from '../../../core/domain'; +import type { + RuntimeTurnSettledTargetResolution, + RuntimeTurnSettledTargetResolverPort, +} from '../../../core/application'; +import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; + +import type { RuntimeTurnSettledEvent } from '../../../core/domain'; +import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; +import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; +import type { TeamMember, TeamSummary } from '@shared/types'; + +export interface RuntimeTurnSettledTeamSource { + listTeams(): Promise; + getConfig(teamName: string): ReturnType; +} + +export interface AttributedMemberFileSource { + listAttributedMemberFiles( + teamName: string + ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]>; +} + +export interface TeamRuntimeTurnSettledTargetResolverDeps { + teamSource: RuntimeTurnSettledTeamSource; + membersMetaStore: TeamMembersMetaStore; + memberLogsFinder?: AttributedMemberFileSource; + maxTeamsToScan?: number; +} + +function memberKey(member: Pick): string { + return normalizeMemberName(member.name); +} + +function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] { + const byName = new Map(); + for (const member of configMembers) { + const key = memberKey(member); + if (key) { + byName.set(key, member); + } + } + for (const member of metaMembers) { + const key = memberKey(member); + if (key) { + byName.set(key, { ...byName.get(key), ...member }); + } + } + return [...byName.values()]; +} + +function providerForMember(member: TeamMember | undefined): string | undefined { + return ( + normalizeOptionalTeamProviderId(member?.providerId) ?? + inferTeamProviderIdFromModel(member?.model) + ); +} + +function normalizePath(value: string | undefined): string | null { + if (!value?.trim()) { + return null; + } + return path.resolve(value.trim()); +} + +export class TeamRuntimeTurnSettledTargetResolver + implements RuntimeTurnSettledTargetResolverPort +{ + private readonly memberLogsFinder: AttributedMemberFileSource; + private readonly maxTeamsToScan: number; + + constructor(private readonly deps: TeamRuntimeTurnSettledTargetResolverDeps) { + this.memberLogsFinder = deps.memberLogsFinder ?? new TeamMemberLogsFinder(); + this.maxTeamsToScan = Math.max(1, deps.maxTeamsToScan ?? 200); + } + + async resolve(event: RuntimeTurnSettledEvent): Promise { + if (event.provider !== 'claude') { + return { ok: false, reason: 'unsupported_provider' }; + } + + const transcriptPath = normalizePath(event.transcriptPath); + const sessionId = event.sessionId?.trim() || null; + if (!transcriptPath && !sessionId) { + return { ok: false, reason: 'missing_session_identity' }; + } + + const teams = (await this.deps.teamSource.listTeams()) + .filter((team) => !team.deletedAt) + .slice(0, this.maxTeamsToScan); + + const candidates: Array<{ + teamName: string; + memberName: string; + exactPath: boolean; + mtimeMs: number; + }> = []; + + for (const team of teams) { + const attributedFiles = await this.memberLogsFinder + .listAttributedMemberFiles(team.teamName) + .catch(() => []); + for (const file of attributedFiles) { + const exactPath = transcriptPath ? normalizePath(file.filePath) === transcriptPath : false; + const sessionMatch = sessionId ? file.sessionId === sessionId : false; + if (!exactPath && !sessionMatch) { + continue; + } + candidates.push({ + teamName: team.teamName, + memberName: file.memberName, + exactPath, + mtimeMs: file.mtimeMs, + }); + } + } + + const candidate = candidates.sort((left, right) => { + if (left.exactPath !== right.exactPath) { + return left.exactPath ? -1 : 1; + } + return right.mtimeMs - left.mtimeMs; + })[0]; + + if (!candidate) { + return { ok: false, reason: 'no_matching_member_session' }; + } + + const member = await this.resolveActiveMember(candidate.teamName, candidate.memberName); + if (!member) { + return { ok: false, reason: 'member_not_active' }; + } + if (isReservedMemberName(member.name)) { + return { ok: false, reason: 'reserved_member' }; + } + + const providerId = providerForMember(member); + if (providerId && providerId !== 'anthropic') { + return { ok: false, reason: 'provider_mismatch' }; + } + + return { + ok: true, + teamName: candidate.teamName, + memberName: normalizeMemberName(member.name), + }; + } + + private async resolveActiveMember( + teamName: string, + memberName: string + ): Promise { + const [config, metaMembers] = await Promise.all([ + this.deps.teamSource.getConfig(teamName), + this.deps.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + if (!config || config.deletedAt) { + return null; + } + + const normalizedTarget = normalizeMemberName(memberName); + return ( + mergeMembers(config.members ?? [], metaMembers).find( + (member) => !member.removedAt && memberKey(member) === normalizedTarget + ) ?? null + ); + } +} diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 4bac2374..5e6d1393 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -16,11 +16,16 @@ import { MemberWorkSyncReconciler, MemberWorkSyncReporter, type MemberWorkSyncReconcileContext, + RuntimeTurnSettledIngestor, + type RuntimeTurnSettledDrainSummary, } from '../../core/application'; import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown'; import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; +import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver'; +import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer'; +import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { MemberWorkSyncEventQueue, @@ -31,6 +36,10 @@ import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStoreP import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWorkSyncNudgeDispatchScheduler'; import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal'; import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; +import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler'; +import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths'; +import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller'; +import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings'; import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; @@ -39,6 +48,35 @@ import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaSt import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; import type { TeamChangeEvent } from '@shared/types'; import type { MemberWorkSyncLoggerPort } from '../../core/application'; +import type { RuntimeTurnSettledProvider } from '../../core/domain'; + +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 interface MemberWorkSyncFeatureFacade { getStatus(request: MemberWorkSyncStatusRequest): Promise; @@ -48,6 +86,10 @@ export interface MemberWorkSyncFeatureFacade { enqueueStartupScan(teamNames: string[]): Promise; replayPendingReports(teamNames: string[]): Promise; dispatchDueNudges(teamNames: string[]): Promise; + buildRuntimeTurnSettledHookSettings(input: { + provider: RuntimeTurnSettledProvider; + }): Promise | null>; + drainRuntimeTurnSettledEvents(): Promise; getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics; dispose(): Promise; } @@ -60,6 +102,7 @@ export function createMemberWorkSyncFeature(deps: { membersMetaStore: TeamMembersMetaStore; isTeamActive?: (teamName: string) => Promise | boolean; listLifecycleActiveTeamNames?: () => Promise; + nudgeSideEffectsEnabled?: boolean; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -74,18 +117,32 @@ export function createMemberWorkSyncFeature(deps: { }); const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath); const store = new JsonMemberWorkSyncStore(storePaths); + const runtimeTurnSettledSpoolPaths = new RuntimeTurnSettledSpoolPaths(deps.teamsBasePath); + const runtimeTurnSettledHookInstaller = new ShellRuntimeTurnSettledHookScriptInstaller( + runtimeTurnSettledSpoolPaths + ); + const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({ + paths: runtimeTurnSettledSpoolPaths, + }); + const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash); + const runtimeTurnSettledTargetResolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: deps.configReader, + membersMetaStore: deps.membersMetaStore, + }); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); - const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink(); const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath); const busySignal = new MemberWorkSyncToolActivityBusySignal(); + const nudgeSideEffectsEnabled = + deps.nudgeSideEffectsEnabled ?? resolveMemberWorkSyncNudgeSideEffectsEnabled(); + const inboxNudge = nudgeSideEffectsEnabled ? new TeamInboxMemberWorkSyncNudgeSink() : null; const useCaseDeps = { clock, hash, agendaSource, statusStore: store, reportStore: store, - outboxStore: store, - inboxNudge, + ...(nudgeSideEffectsEnabled ? { outboxStore: store } : {}), + ...(inboxNudge ? { inboxNudge } : {}), watchdogCooldown, busySignal, reportToken, @@ -101,16 +158,42 @@ export function createMemberWorkSyncFeature(deps: { const queue = new MemberWorkSyncEventQueue({ reconcile: async (request, context: MemberWorkSyncReconcileContext) => { await reconciler.execute(request, context); - await nudgeDispatcher.dispatchDue({ - teamNames: [request.teamName], - claimedBy: `member-work-sync:${process.pid}`, - }); + if (nudgeSideEffectsEnabled) { + await nudgeDispatcher.dispatchDue({ + teamNames: [request.teamName], + claimedBy: `member-work-sync:${process.pid}`, + }); + } }, isTeamActive: deps.isTeamActive ?? (() => true), logger: deps.logger, }); const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue); - const nudgeDispatchScheduler = deps.listLifecycleActiveTeamNames + const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({ + eventStore: runtimeTurnSettledStore, + normalizer: runtimeTurnSettledNormalizer, + targetResolver: runtimeTurnSettledTargetResolver, + reconcileQueue: { + enqueueRuntimeTurnSettled: ({ teamName, memberName, event }) => { + router.noteTeamChange({ + type: 'member-turn-settled', + teamName, + detail: JSON.stringify({ + memberName, + sourceId: event.sourceId, + provider: event.provider, + }), + }); + }, + }, + clock, + logger: deps.logger, + }); + const runtimeTurnSettledDrainScheduler = new RuntimeTurnSettledDrainScheduler({ + drain: () => runtimeTurnSettledIngestor.drainPending(), + logger: deps.logger, + }); + const nudgeDispatchScheduler = nudgeSideEffectsEnabled && deps.listLifecycleActiveTeamNames ? new MemberWorkSyncNudgeDispatchScheduler({ listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames, dispatchDue: (teamNames) => @@ -121,6 +204,7 @@ export function createMemberWorkSyncFeature(deps: { logger: deps.logger, }) : null; + runtimeTurnSettledDrainScheduler.start(); nudgeDispatchScheduler?.start(); return { @@ -151,9 +235,27 @@ export function createMemberWorkSyncFeature(deps: { ); }, dispatchDueNudges: (teamNames) => - nudgeDispatcher.dispatchDue({ teamNames, claimedBy: `member-work-sync:${process.pid}` }), + nudgeSideEffectsEnabled + ? nudgeDispatcher.dispatchDue({ + teamNames, + claimedBy: `member-work-sync:${process.pid}`, + }) + : Promise.resolve(emptyNudgeDispatchSummary()), + buildRuntimeTurnSettledHookSettings: async ({ provider }) => { + if (provider !== 'claude') { + return null; + } + const installed = await runtimeTurnSettledHookInstaller.install(); + return buildRuntimeTurnSettledHookSettings({ + scriptPath: installed.scriptPath, + spoolRoot: installed.spoolRoot, + provider, + }); + }, + drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(), getQueueDiagnostics: () => queue.getDiagnostics(), dispose: async () => { + runtimeTurnSettledDrainScheduler.dispose(); await Promise.allSettled([queue.stop(), nudgeDispatchScheduler?.dispose()]); }, }; diff --git a/src/features/member-work-sync/main/index.ts b/src/features/member-work-sync/main/index.ts index e9cd3909..b29c8658 100644 --- a/src/features/member-work-sync/main/index.ts +++ b/src/features/member-work-sync/main/index.ts @@ -2,5 +2,9 @@ export { registerMemberWorkSyncIpc, removeMemberWorkSyncIpc, } from './adapters/input/registerMemberWorkSyncIpc'; -export { createMemberWorkSyncFeature } from './composition/createMemberWorkSyncFeature'; +export { + MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, + createMemberWorkSyncFeature, + resolveMemberWorkSyncNudgeSideEffectsEnabled, +} from './composition/createMemberWorkSyncFeature'; export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature'; diff --git a/src/features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer.ts new file mode 100644 index 00000000..225b8268 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer.ts @@ -0,0 +1,86 @@ +import { + buildRuntimeTurnSettledSourceId, + type RuntimeTurnSettledProvider, +} from '../../core/domain'; +import type { + MemberWorkSyncHashPort, + RuntimeTurnSettledPayloadNormalization, + RuntimeTurnSettledPayloadNormalizerPort, +} from '../../core/application'; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getString(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value !== 'string') { + continue; + } + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return undefined; +} + +export class ClaudeStopHookPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort { + constructor(private readonly hash: MemberWorkSyncHashPort) {} + + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization { + if (input.provider !== 'claude') { + return { ok: false, reason: 'unsupported_provider' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(input.raw); + } catch { + return { ok: false, reason: 'invalid_json' }; + } + + const payload = asRecord(parsed); + if (!payload) { + return { ok: false, reason: 'payload_not_object' }; + } + + const hookEventName = getString(payload, 'hook_event_name', 'hookEventName'); + if (hookEventName !== 'Stop') { + return { ok: false, reason: 'not_stop_hook' }; + } + + const payloadHash = this.hash.sha256Hex(input.raw); + const event = { + schemaVersion: 1 as const, + provider: 'claude' as const, + hookEventName: 'Stop' as const, + payloadHash, + recordedAt: input.recordedAt, + sourceId: buildRuntimeTurnSettledSourceId({ + provider: 'claude', + sessionId: getString(payload, 'session_id', 'sessionId'), + turnId: getString(payload, 'turn_id', 'turnId'), + transcriptPath: getString(payload, 'transcript_path', 'transcriptPath'), + payloadHash, + }), + sessionId: getString(payload, 'session_id', 'sessionId'), + turnId: getString(payload, 'turn_id', 'turnId'), + transcriptPath: getString(payload, 'transcript_path', 'transcriptPath'), + cwd: getString(payload, 'cwd'), + }; + + if (!event.sessionId && !event.transcriptPath) { + return { ok: false, reason: 'missing_session_identity' }; + } + + return { ok: true, event }; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts new file mode 100644 index 00000000..fdf0bd87 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts @@ -0,0 +1,191 @@ +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'; +import path from 'path'; + +import { isRuntimeTurnSettledProvider } from '../../core/domain'; +import type { + RuntimeTurnSettledClaimedPayload, + RuntimeTurnSettledEventStorePort, + RuntimeTurnSettledInvalidResult, + RuntimeTurnSettledProcessedResult, +} from '../../core/application'; +import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths'; + +const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024; +const DEFAULT_PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000; +const DEFAULT_PROCESSED_RETENTION_COUNT = 1000; + +export interface FileRuntimeTurnSettledEventStoreDeps { + paths: RuntimeTurnSettledSpoolPaths; + maxPayloadBytes?: number; + processedRetentionMs?: number; + processedRetentionCount?: number; + now?: () => Date; +} + +function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null { + const parts = fileName.split('.'); + const provider = parts.length >= 3 ? parts[parts.length - 2] : null; + return isRuntimeTurnSettledProvider(provider) ? provider : null; +} + +function buildMetaFilePath(filePath: string): string { + return `${filePath}.meta.json`; +} + +async function moveFileBestEffort(sourcePath: string, targetPath: string): Promise { + await mkdir(path.dirname(targetPath), { recursive: true }); + try { + await rename(sourcePath, targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } +} + +export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEventStorePort { + private readonly maxPayloadBytes: number; + private readonly processedRetentionMs: number; + private readonly processedRetentionCount: number; + private readonly now: () => Date; + + constructor(private readonly deps: FileRuntimeTurnSettledEventStoreDeps) { + this.maxPayloadBytes = deps.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES; + this.processedRetentionMs = deps.processedRetentionMs ?? DEFAULT_PROCESSED_RETENTION_MS; + this.processedRetentionCount = + deps.processedRetentionCount ?? DEFAULT_PROCESSED_RETENTION_COUNT; + this.now = deps.now ?? (() => new Date()); + } + + async claimPending(limit: number): Promise { + await this.ensureDirectories(); + + const entries = await readdir(this.deps.paths.getIncomingDir(), { withFileTypes: true }).catch( + () => [] + ); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((fileName) => !fileName.startsWith('.')) + .sort() + .slice(0, Math.max(0, limit)); + + const claimed: RuntimeTurnSettledClaimedPayload[] = []; + for (const fileName of files) { + const provider = parseProviderFromFileName(fileName); + const incomingPath = path.join(this.deps.paths.getIncomingDir(), fileName); + if (!provider) { + await this.quarantineIncoming(incomingPath, fileName, 'unsupported_provider'); + continue; + } + + const processingPath = path.join(this.deps.paths.getProcessingDir(), fileName); + try { + await rename(incomingPath, processingPath); + } catch { + continue; + } + + const fileStat = await stat(processingPath).catch(() => null); + const payloadTooLarge = Boolean(fileStat?.isFile() && fileStat.size > this.maxPayloadBytes); + if (!fileStat?.isFile() || payloadTooLarge) { + await this.markInvalid( + { + id: fileName, + filePath: processingPath, + fileName, + provider, + raw: '', + claimedAt: this.now().toISOString(), + }, + { + reason: payloadTooLarge ? 'payload_too_large' : 'payload_missing', + processedAt: this.now().toISOString(), + } + ); + continue; + } + + const raw = await readFile(processingPath, 'utf8').catch(() => ''); + claimed.push({ + id: fileName, + filePath: processingPath, + fileName, + provider, + raw, + claimedAt: this.now().toISOString(), + }); + } + + return claimed; + } + + async markProcessed( + payload: RuntimeTurnSettledClaimedPayload, + result: RuntimeTurnSettledProcessedResult + ): Promise { + const processedPath = path.join(this.deps.paths.getProcessedDir(), payload.fileName); + await moveFileBestEffort(payload.filePath, processedPath); + await writeFile(buildMetaFilePath(processedPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + await this.cleanupDirectory(this.deps.paths.getProcessedDir()); + } + + async markInvalid( + payload: RuntimeTurnSettledClaimedPayload, + result: RuntimeTurnSettledInvalidResult + ): Promise { + const invalidPath = path.join(this.deps.paths.getInvalidDir(), payload.fileName); + await moveFileBestEffort(payload.filePath, invalidPath); + await writeFile(buildMetaFilePath(invalidPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + await this.cleanupDirectory(this.deps.paths.getInvalidDir()); + } + + private async ensureDirectories(): Promise { + await Promise.all([ + mkdir(this.deps.paths.getIncomingDir(), { recursive: true }), + mkdir(this.deps.paths.getProcessingDir(), { recursive: true }), + mkdir(this.deps.paths.getProcessedDir(), { recursive: true }), + mkdir(this.deps.paths.getInvalidDir(), { recursive: true }), + ]); + } + + private async quarantineIncoming( + incomingPath: string, + fileName: string, + reason: string + ): Promise { + const invalidPath = path.join(this.deps.paths.getInvalidDir(), fileName); + await moveFileBestEffort(incomingPath, invalidPath); + await writeFile( + buildMetaFilePath(invalidPath), + `${JSON.stringify({ reason, processedAt: this.now().toISOString() }, null, 2)}\n`, + 'utf8' + ); + } + + private async cleanupDirectory(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + const files = ( + await Promise.all( + entries + .filter((entry) => entry.isFile() && !entry.name.endsWith('.meta.json')) + .map(async (entry) => { + const filePath = path.join(directory, entry.name); + const fileStat = await stat(filePath).catch(() => null); + return fileStat?.isFile() ? { filePath, mtimeMs: fileStat.mtimeMs } : null; + }) + ) + ) + .filter((entry): entry is { filePath: string; mtimeMs: number } => Boolean(entry)) + .sort((left, right) => right.mtimeMs - left.mtimeMs); + + const cutoff = this.now().getTime() - this.processedRetentionMs; + const toRemove = files.filter( + (file, index) => index >= this.processedRetentionCount || file.mtimeMs < cutoff + ); + + await Promise.allSettled( + toRemove.flatMap((file) => [rm(file.filePath, { force: true }), rm(buildMetaFilePath(file.filePath), { force: true })]) + ); + } +} diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts index 6c50a694..f2eded26 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts @@ -9,6 +9,7 @@ export type MemberWorkSyncTriggerReason = | 'member_spawned' | 'tool_finished' | 'runtime_activity' + | 'turn_settled' | 'manual_refresh'; export interface MemberWorkSyncQueueDiagnostics { diff --git a/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledDrainScheduler.ts b/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledDrainScheduler.ts new file mode 100644 index 00000000..1e82dc55 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledDrainScheduler.ts @@ -0,0 +1,69 @@ +import type { + MemberWorkSyncLoggerPort, + RuntimeTurnSettledDrainSummary, +} from '../../core/application'; + +export interface RuntimeTurnSettledDrainSchedulerDeps { + drain(): Promise; + intervalMs?: number; + logger?: MemberWorkSyncLoggerPort; +} + +function unrefTimer(timer: ReturnType): void { + timer.unref?.(); +} + +export class RuntimeTurnSettledDrainScheduler { + private readonly intervalMs: number; + private timer: ReturnType | null = null; + private running = false; + private disposed = false; + + constructor(private readonly deps: RuntimeTurnSettledDrainSchedulerDeps) { + this.intervalMs = Math.max(1_000, deps.intervalMs ?? 15_000); + } + + start(): void { + if (this.disposed || this.timer) { + return; + } + this.schedule(100); + } + + async drainNow(): Promise { + if (this.running || this.disposed) { + return null; + } + + this.running = true; + try { + return await this.deps.drain(); + } catch (error) { + this.deps.logger?.warn('runtime turn settled scheduled drain failed', { + error: String(error), + }); + return null; + } finally { + this.running = false; + } + } + + dispose(): void { + this.disposed = true; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private schedule(delayMs: number = this.intervalMs): void { + if (this.disposed) { + return; + } + this.timer = setTimeout(() => { + this.timer = null; + void this.drainNow().finally(() => this.schedule()); + }, delayMs); + unrefTimer(this.timer); + } +} diff --git a/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths.ts b/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths.ts new file mode 100644 index 00000000..c93b57d8 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths.ts @@ -0,0 +1,33 @@ +import path from 'path'; + +export class RuntimeTurnSettledSpoolPaths { + constructor(private readonly teamsBasePath: string) {} + + getRootDir(): string { + return path.join(this.teamsBasePath, '.member-work-sync', 'runtime-hooks'); + } + + getBinDir(): string { + return path.join(this.getRootDir(), 'bin'); + } + + getHookScriptPath(): string { + return path.join(this.getBinDir(), 'turn-settled-hook-v1.sh'); + } + + getIncomingDir(): string { + return path.join(this.getRootDir(), 'incoming'); + } + + getProcessingDir(): string { + return path.join(this.getRootDir(), 'processing'); + } + + getProcessedDir(): string { + return path.join(this.getRootDir(), 'processed'); + } + + getInvalidDir(): string { + return path.join(this.getRootDir(), 'invalid'); + } +} diff --git a/src/features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts b/src/features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts new file mode 100644 index 00000000..fea189bb --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts @@ -0,0 +1,68 @@ +import fs from 'fs/promises'; + +import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths'; + +const HOOK_SCRIPT_CONTENT = `#!/bin/sh +set +e + +spool_root="$1" +provider="$2" +max_bytes="\${3:-262144}" + +if [ -z "$spool_root" ] || [ -z "$provider" ]; then + exit 0 +fi + +case "$provider" in + claude|codex) ;; + *) exit 0 ;; +esac + +incoming="$spool_root/incoming" +mkdir -p "$incoming" 2>/dev/null || exit 0 + +stamp="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo unknown-time)" +tmp="$(mktemp "$incoming/.turn-settled.XXXXXX" 2>/dev/null)" || exit 0 +suffix="$(basename "$tmp" | sed 's/^\\.turn-settled\\.//')" +final="$incoming/$stamp-$$-$suffix.$provider.json" + +dd bs="$max_bytes" count=1 of="$tmp" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null + exit 0 +} + +if [ ! -s "$tmp" ]; then + rm -f "$tmp" 2>/dev/null + exit 0 +fi + +mv "$tmp" "$final" 2>/dev/null || { + rm -f "$tmp" 2>/dev/null + exit 0 +} + +exit 0 +`; + +export class ShellRuntimeTurnSettledHookScriptInstaller { + constructor(private readonly paths: RuntimeTurnSettledSpoolPaths) {} + + async install(): Promise<{ scriptPath: string; spoolRoot: string }> { + await Promise.all([ + fs.mkdir(this.paths.getBinDir(), { recursive: true }), + fs.mkdir(this.paths.getIncomingDir(), { recursive: true }), + fs.mkdir(this.paths.getProcessingDir(), { recursive: true }), + fs.mkdir(this.paths.getProcessedDir(), { recursive: true }), + fs.mkdir(this.paths.getInvalidDir(), { recursive: true }), + ]); + + const scriptPath = this.paths.getHookScriptPath(); + await fs.writeFile(scriptPath, HOOK_SCRIPT_CONTENT, 'utf8'); + await fs.chmod(scriptPath, 0o755); + + return { + scriptPath, + spoolRoot: this.paths.getRootDir(), + }; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings.ts b/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings.ts new file mode 100644 index 00000000..a00274db --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings.ts @@ -0,0 +1,48 @@ +import type { RuntimeTurnSettledProvider } from '../../core/domain'; + +export const MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER = + 'agent-teams:member-work-sync-turn-settled:v1'; + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function buildRuntimeTurnSettledHookCommand(input: { + scriptPath: string; + spoolRoot: string; + provider: RuntimeTurnSettledProvider; + maxBytes?: number; +}): string { + return [ + '/bin/sh', + shellQuote(input.scriptPath), + shellQuote(input.spoolRoot), + shellQuote(input.provider), + shellQuote(String(input.maxBytes ?? 262_144)), + '#', + MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER, + ].join(' '); +} + +export function buildRuntimeTurnSettledHookSettings(input: { + scriptPath: string; + spoolRoot: string; + provider: RuntimeTurnSettledProvider; + maxBytes?: number; +}): Record { + return { + hooks: { + Stop: [ + { + matcher: '', + hooks: [ + { + type: 'command', + command: buildRuntimeTurnSettledHookCommand(input), + }, + ], + }, + ], + }, + }; +} diff --git a/src/main/index.ts b/src/main/index.ts index 109aa9e2..ca7e15d1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1249,6 +1249,11 @@ async function initializeServices(): Promise { }, logger: createLogger('Feature:MemberWorkSync'), }); + teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) => + memberWorkSyncFeature + ? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input) + : Promise.resolve(null) + ); void teamDataService .listTeams() .then(async (teams) => { diff --git a/src/main/services/runtime/cliSettingsArgs.ts b/src/main/services/runtime/cliSettingsArgs.ts index 70f0d285..6b75b7ad 100644 --- a/src/main/services/runtime/cliSettingsArgs.ts +++ b/src/main/services/runtime/cliSettingsArgs.ts @@ -24,8 +24,11 @@ function getHookEntryDedupeKey(value: unknown): string | null { return null; } - const commands = value.hooks - .map((hook) => (isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null)) + const hooks = Array.isArray(value.hooks) ? value.hooks : []; + const commands = hooks + .map((hook: unknown) => + isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null + ) .filter((command): command is string => Boolean(command)); if (commands.length === 0) { return null; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f2e42b1d..b0027afd 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2,6 +2,7 @@ import { resolveAnthropicFastMode, resolveAnthropicRuntimeSelection, } from '@features/anthropic-runtime-profile/main'; +import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/core/domain'; import { buildCodexFastModeArgs, resolveCodexFastMode, @@ -4320,6 +4321,9 @@ export class TeamProvisioningService { private inFlightResponses = new Set(); private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null; private controlApiBaseUrlResolver: (() => Promise) | null = null; + private runtimeTurnSettledHookSettingsProvider: + | ((input: { provider: RuntimeTurnSettledProvider }) => Promise | null>) + | null = null; private readonly stoppedTeamOpenCodeRuntimeCleanupInFlight = new Map>(); private readonly cleanedStoppedTeamOpenCodeRuntimeLanes = new Set(); private crossTeamSender: @@ -4381,6 +4385,36 @@ export class TeamProvisioningService { this.controlApiBaseUrlResolver = resolver; } + setRuntimeTurnSettledHookSettingsProvider( + provider: + | ((input: { + provider: RuntimeTurnSettledProvider; + }) => Promise | null>) + | null + ): void { + this.runtimeTurnSettledHookSettingsProvider = provider; + } + + private async buildRuntimeTurnSettledHookSettingsArgs( + providerId: TeamProviderId + ): Promise { + if (providerId !== 'anthropic' || !this.runtimeTurnSettledHookSettingsProvider) { + return []; + } + + try { + const settings = await this.runtimeTurnSettledHookSettingsProvider({ provider: 'claude' }); + return settings ? ['--settings', JSON.stringify(settings)] : []; + } catch (error) { + logger.warn( + `Failed to build member work sync Stop hook settings: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return []; + } + } + private async readRuntimeProviderLaunchFacts(params: { claudePath: string; cwd: string; @@ -9869,6 +9903,8 @@ export class TeamProvisioningService { input.leadName ); const bootstrapExpectedAfter = nowIso(); + const runtimeTurnSettledHookArgs = + await this.buildRuntimeTurnSettledHookSettingsArgs(providerId); const runtimeArgs = mergeJsonSettingsArgs([ '--agent-id', @@ -9894,6 +9930,7 @@ export class TeamProvisioningService { : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), ...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []), ...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []), + ...runtimeTurnSettledHookArgs, ...(provisioningEnv.providerArgs ?? []), ]); const command = buildDirectTmuxRestartCommand({ @@ -12970,6 +13007,8 @@ export class TeamProvisioningService { ); const resolvedProviderId = resolveTeamProviderId(request.providerId); const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity); + const runtimeTurnSettledHookArgs = + await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId); const spawnArgs = mergeJsonSettingsArgs([ '--input-format', 'stream-json', @@ -12995,6 +13034,7 @@ export class TeamProvisioningService { ...(launchModelArg ? ['--model', launchModelArg] : []), ...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []), ...providerFastModeArgs, + ...runtimeTurnSettledHookArgs, ...(request.worktree ? ['--worktree', request.worktree] : []), ...buildDesktopTeammateModeCliArgs(teammateModeDecision), ...parseCliArgs(request.extraCliArgs), @@ -14102,6 +14142,8 @@ export class TeamProvisioningService { ); const resolvedProviderId = resolveTeamProviderId(request.providerId); const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity); + const runtimeTurnSettledHookArgs = + await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId); if (launchModelArg) { launchArgs.push('--model', launchModelArg); } @@ -14109,6 +14151,7 @@ export class TeamProvisioningService { launchArgs.push('--effort', launchIdentity.resolvedEffort); } launchArgs.push(...providerFastModeArgs); + launchArgs.push(...runtimeTurnSettledHookArgs); if (request.worktree) { launchArgs.push('--worktree', request.worktree); } @@ -22885,6 +22928,7 @@ export class TeamProvisioningService { for (const providerId of crossProviderIds) { try { const env = await this.buildProvisioningEnv(providerId); + args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId))); if (env.providerArgs) { args.push(...env.providerArgs); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d35a58d0..5ecafbf4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1130,6 +1130,7 @@ export interface TeamChangeEvent { | 'lead-context' | 'lead-message' | 'tool-activity' + | 'member-turn-settled' | 'process' | 'member-spawn'; teamName: string; diff --git a/test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts b/test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts new file mode 100644 index 00000000..052982d9 --- /dev/null +++ b/test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts @@ -0,0 +1,89 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { FileRuntimeTurnSettledEventStore } from '@features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore'; +import { RuntimeTurnSettledSpoolPaths } from '@features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths'; + +const roots: string[] = []; + +async function makePaths(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'runtime-turn-settled-')); + roots.push(root); + return new RuntimeTurnSettledSpoolPaths(root); +} + +afterEach(async () => { + await Promise.allSettled(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('FileRuntimeTurnSettledEventStore', () => { + it('claims incoming payloads by atomically moving them to processing', async () => { + const paths = await makePaths(); + await fs.mkdir(paths.getIncomingDir(), { recursive: true }); + await fs.writeFile( + path.join(paths.getIncomingDir(), '20260429-1.claude.json'), + '{"hook_event_name":"Stop"}', + 'utf8' + ); + + const store = new FileRuntimeTurnSettledEventStore({ + paths, + now: () => new Date('2026-04-29T12:00:00.000Z'), + }); + + const claimed = await store.claimPending(10); + + expect(claimed).toHaveLength(1); + expect(claimed[0]).toMatchObject({ + fileName: '20260429-1.claude.json', + provider: 'claude', + raw: '{"hook_event_name":"Stop"}', + }); + await expect( + fs.stat(path.join(paths.getProcessingDir(), '20260429-1.claude.json')) + ).resolves.toMatchObject({ isFile: expect.any(Function) }); + await expect( + fs.stat(path.join(paths.getIncomingDir(), '20260429-1.claude.json')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('moves processed payload and writes diagnostic metadata', async () => { + const paths = await makePaths(); + await fs.mkdir(paths.getIncomingDir(), { recursive: true }); + await fs.writeFile( + path.join(paths.getIncomingDir(), '20260429-1.claude.json'), + '{"hook_event_name":"Stop"}', + 'utf8' + ); + const store = new FileRuntimeTurnSettledEventStore({ + paths, + now: () => new Date('2026-04-29T12:00:00.000Z'), + }); + const [claimed] = await store.claimPending(1); + + await store.markProcessed(claimed, { + outcome: 'enqueued', + teamName: 'team-a', + memberName: 'alice', + event: { + schemaVersion: 1, + provider: 'claude', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + }, + processedAt: '2026-04-29T12:01:00.000Z', + }); + + const processedPath = path.join(paths.getProcessedDir(), '20260429-1.claude.json'); + await expect(fs.stat(processedPath)).resolves.toMatchObject({ isFile: expect.any(Function) }); + const meta = JSON.parse(await fs.readFile(`${processedPath}.meta.json`, 'utf8')) as { + outcome?: string; + teamName?: string; + }; + expect(meta).toMatchObject({ outcome: 'enqueued', teamName: 'team-a' }); + }); +}); diff --git a/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts index f1b3421e..998ef979 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncTeamChangeRouter.test.ts @@ -67,4 +67,32 @@ describe('MemberWorkSyncTeamChangeRouter', () => { expect(queue.dropTeam).toHaveBeenCalledWith('team-a'); expect(queue.enqueue).not.toHaveBeenCalled(); }); + + it('routes member-turn-settled events to one member reconcile', () => { + const { queue, router } = createRouter(); + + router.noteTeamChange({ + type: 'member-turn-settled', + teamName: 'team-a', + detail: JSON.stringify({ memberName: 'alice', sourceId: 'source-1', provider: 'claude' }), + }); + + expect(queue.enqueue).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'alice', + triggerReason: 'turn_settled', + }); + }); + + it('ignores malformed member-turn-settled details', () => { + const { queue, router } = createRouter(); + + router.noteTeamChange({ + type: 'member-turn-settled', + teamName: 'team-a', + detail: 'not-json', + }); + + expect(queue.enqueue).not.toHaveBeenCalled(); + }); }); diff --git a/test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts b/test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts new file mode 100644 index 00000000..d6461ab8 --- /dev/null +++ b/test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts @@ -0,0 +1,86 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { RuntimeTurnSettledSpoolPaths } from '@features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths'; +import { ShellRuntimeTurnSettledHookScriptInstaller } from '@features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller'; +import { + MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER, + buildRuntimeTurnSettledHookCommand, + buildRuntimeTurnSettledHookSettings, +} from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings'; + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-turn-settled-hook-')); + tempRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('runtime turn-settled hook settings', () => { + it('builds a shell-quoted command without embedding team identity', () => { + const command = buildRuntimeTurnSettledHookCommand({ + scriptPath: "/tmp/agent team's hook.sh", + spoolRoot: '/tmp/member work sync', + provider: 'claude', + }); + + expect(command).toContain('/bin/sh'); + expect(command).toContain("'/tmp/agent team'\\''s hook.sh'"); + expect(command).toContain("'claude'"); + expect(command).toContain(MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER); + expect(command).not.toContain('--team-name'); + expect(command).not.toContain('--agent-name'); + }); + + it('builds Claude Stop hook settings with a stable marker', () => { + const settings = buildRuntimeTurnSettledHookSettings({ + scriptPath: '/tmp/hook.sh', + spoolRoot: '/tmp/spool', + provider: 'claude', + }); + + expect(settings).toEqual({ + hooks: { + Stop: [ + { + matcher: '', + hooks: [ + { + type: 'command', + command: expect.stringContaining(MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER), + }, + ], + }, + ], + }, + }); + }); + + it('installs an executable POSIX shell writer and spool directories', async () => { + const root = makeTempRoot(); + const paths = new RuntimeTurnSettledSpoolPaths(root); + const installer = new ShellRuntimeTurnSettledHookScriptInstaller(paths); + + const result = await installer.install(); + + expect(result.scriptPath).toBe(paths.getHookScriptPath()); + expect(result.spoolRoot).toBe(paths.getRootDir()); + expect(fs.existsSync(paths.getIncomingDir())).toBe(true); + expect(fs.existsSync(paths.getProcessingDir())).toBe(true); + expect(fs.existsSync(paths.getProcessedDir())).toBe(true); + expect(fs.existsSync(paths.getInvalidDir())).toBe(true); + + const stat = fs.statSync(result.scriptPath); + expect(stat.mode & 0o111).toBeGreaterThan(0); + expect(fs.readFileSync(result.scriptPath, 'utf8')).toContain('dd bs="$max_bytes"'); + }); +}); diff --git a/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts b/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts new file mode 100644 index 00000000..064799a2 --- /dev/null +++ b/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { RuntimeTurnSettledIngestor } from '@features/member-work-sync/core/application'; +import { ClaudeStopHookPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer'; +import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter'; + +import type { + RuntimeTurnSettledClaimedPayload, + RuntimeTurnSettledEventStorePort, + RuntimeTurnSettledInvalidResult, + RuntimeTurnSettledProcessedResult, + RuntimeTurnSettledTargetResolverPort, +} from '@features/member-work-sync/core/application'; + +function makePayload(raw: string): RuntimeTurnSettledClaimedPayload { + return { + id: 'event-1.claude.json', + fileName: 'event-1.claude.json', + filePath: '/tmp/event-1.claude.json', + provider: 'claude', + raw, + claimedAt: '2026-04-29T12:00:00.000Z', + }; +} + +describe('RuntimeTurnSettledIngestor', () => { + it('normalizes Claude Stop payloads and enqueues resolved member reconcile', async () => { + const payload = makePayload( + JSON.stringify({ + hook_event_name: 'Stop', + session_id: 'ses-1', + transcript_path: '/tmp/ses-1.jsonl', + }) + ); + const processed: RuntimeTurnSettledProcessedResult[] = []; + const store: RuntimeTurnSettledEventStorePort = { + claimPending: vi.fn(async () => [payload]), + markProcessed: vi.fn(async (_payload, result) => { + processed.push(result); + }), + markInvalid: vi.fn(), + }; + const resolver: RuntimeTurnSettledTargetResolverPort = { + resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'alice' })), + }; + const enqueueRuntimeTurnSettled = vi.fn(); + + const ingestor = new RuntimeTurnSettledIngestor({ + eventStore: store, + normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()), + targetResolver: resolver, + reconcileQueue: { enqueueRuntimeTurnSettled }, + clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, + }); + + await expect(ingestor.drainPending()).resolves.toEqual({ + claimed: 1, + enqueued: 1, + unresolved: 0, + invalid: 0, + failed: 0, + }); + expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'alice', + event: expect.objectContaining({ + provider: 'claude', + hookEventName: 'Stop', + sessionId: 'ses-1', + transcriptPath: '/tmp/ses-1.jsonl', + }), + }); + expect(processed[0]).toMatchObject({ + outcome: 'enqueued', + teamName: 'team-a', + memberName: 'alice', + }); + }); + + it('quarantines malformed payloads without enqueueing reconcile', async () => { + const payload = makePayload('not-json'); + const invalid: RuntimeTurnSettledInvalidResult[] = []; + const store: RuntimeTurnSettledEventStorePort = { + claimPending: vi.fn(async () => [payload]), + markProcessed: vi.fn(), + markInvalid: vi.fn(async (_payload, result) => { + invalid.push(result); + }), + }; + + const ingestor = new RuntimeTurnSettledIngestor({ + eventStore: store, + normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()), + targetResolver: { resolve: vi.fn() }, + reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() }, + clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, + }); + + await expect(ingestor.drainPending()).resolves.toMatchObject({ + claimed: 1, + invalid: 1, + enqueued: 0, + }); + expect(invalid[0]).toMatchObject({ reason: 'invalid_json' }); + }); + + it('records unresolved Stop events as processed diagnostics', async () => { + const payload = makePayload( + JSON.stringify({ hook_event_name: 'Stop', transcript_path: '/tmp/unknown.jsonl' }) + ); + const processed: RuntimeTurnSettledProcessedResult[] = []; + const store: RuntimeTurnSettledEventStorePort = { + claimPending: vi.fn(async () => [payload]), + markProcessed: vi.fn(async (_payload, result) => { + processed.push(result); + }), + markInvalid: vi.fn(), + }; + + const ingestor = new RuntimeTurnSettledIngestor({ + eventStore: store, + normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()), + targetResolver: { + resolve: vi.fn(async () => ({ + ok: false as const, + reason: 'no_matching_member_session', + })), + }, + reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() }, + clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, + }); + + await expect(ingestor.drainPending()).resolves.toMatchObject({ + claimed: 1, + unresolved: 1, + enqueued: 0, + }); + expect(processed[0]).toMatchObject({ + outcome: 'unresolved', + reason: 'no_matching_member_session', + }); + }); +}); diff --git a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts new file mode 100644 index 00000000..702bb0fb --- /dev/null +++ b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TeamRuntimeTurnSettledTargetResolver } from '@features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver'; +import type { TeamConfig } from '@shared/types'; + +describe('TeamRuntimeTurnSettledTargetResolver', () => { + it('resolves a Claude Stop transcript path to an active Anthropic teammate', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => [{ teamName: 'team-a', displayName: 'team-a' } as never]), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [{ name: 'Alice', providerId: 'anthropic' }], + }) satisfies TeamConfig), + }, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + memberLogsFinder: { + listAttributedMemberFiles: vi.fn(async () => [ + { + memberName: 'Alice', + sessionId: 'ses-1', + filePath: '/tmp/ses-1.jsonl', + mtimeMs: 1, + }, + ]), + }, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'claude', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + transcriptPath: '/tmp/ses-1.jsonl', + }) + ).resolves.toEqual({ ok: true, teamName: 'team-a', memberName: 'alice' }); + }); + + it('rejects matches for removed or non-Anthropic teammates', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => [{ teamName: 'team-a', displayName: 'team-a' } as never]), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [{ name: 'bob', providerId: 'opencode' }], + }) satisfies TeamConfig), + }, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + memberLogsFinder: { + listAttributedMemberFiles: vi.fn(async () => [ + { + memberName: 'bob', + sessionId: 'ses-1', + filePath: '/tmp/ses-1.jsonl', + mtimeMs: 1, + }, + ]), + }, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'claude', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + }) + ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); + }); +}); diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts new file mode 100644 index 00000000..1605d580 --- /dev/null +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -0,0 +1,117 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, + createMemberWorkSyncFeature, + resolveMemberWorkSyncNudgeSideEffectsEnabled, +} from '@features/member-work-sync/main'; + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'member-work-sync-feature-')); + tempRoots.push(root); + return root; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +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 () => { + const feature = createMemberWorkSyncFeature({ + teamsBasePath: makeTempRoot(), + configReader: {} as never, + taskReader: {} as never, + kanbanManager: {} as never, + membersMetaStore: {} as never, + nudgeSideEffectsEnabled: false, + }); + + try { + await expect(feature.dispatchDueNudges(['team-a'])).resolves.toEqual({ + claimed: 0, + delivered: 0, + superseded: 0, + retryable: 0, + terminal: 0, + }); + } finally { + await feature.dispose(); + } + }); + + it('builds Claude Stop hook settings without requiring nudge side effects', async () => { + const root = makeTempRoot(); + const feature = createMemberWorkSyncFeature({ + teamsBasePath: root, + configReader: {} as never, + taskReader: {} as never, + kanbanManager: {} as never, + membersMetaStore: {} as never, + nudgeSideEffectsEnabled: false, + }); + + try { + const settings = await feature.buildRuntimeTurnSettledHookSettings({ provider: 'claude' }); + expect(settings).toMatchObject({ + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: expect.stringContaining('agent-teams:member-work-sync-turn-settled:v1'), + }, + ], + }, + ], + }, + }); + await expect( + fs.promises.stat( + path.join(root, '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh') + ) + ).resolves.toMatchObject({ mode: expect.any(Number) }); + } finally { + await feature.dispose(); + } + }); +}); From 4d0cce601ac9d821cf3e8ed41cdf03efbe3b6ff5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 18:30:36 +0300 Subject: [PATCH 29/74] test: add member work sync live e2e coverage --- .../createMemberWorkSyncFeature.ts | 2 + .../MemberWorkSyncClaudeStopHook.live.test.ts | 314 ++++++++++++++++++ .../team/MemberWorkSyncCodex.live.test.ts | 284 ++++++++++++++++ .../team/memberWorkSyncLiveHarness.ts | 215 ++++++++++++ 4 files changed, 815 insertions(+) create mode 100644 test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts create mode 100644 test/main/services/team/MemberWorkSyncCodex.live.test.ts create mode 100644 test/main/services/team/memberWorkSyncLiveHarness.ts diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 5e6d1393..a917f583 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -103,6 +103,7 @@ export function createMemberWorkSyncFeature(deps: { isTeamActive?: (teamName: string) => Promise | boolean; listLifecycleActiveTeamNames?: () => Promise; nudgeSideEffectsEnabled?: boolean; + queueQuietWindowMs?: number; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -166,6 +167,7 @@ export function createMemberWorkSyncFeature(deps: { } }, isTeamActive: deps.isTeamActive ?? (() => true), + ...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}), logger: deps.logger, }); const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue); diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts new file mode 100644 index 00000000..14f98299 --- /dev/null +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -0,0 +1,314 @@ +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createMemberWorkSyncFeature, + type MemberWorkSyncFeatureFacade, +} from '../../../../src/features/member-work-sync/main'; +import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; +import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; +import { TeamKanbanManager } from '../../../../src/main/services/team/TeamKanbanManager'; +import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore'; +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; +import { TeamTaskReader } from '../../../../src/main/services/team/TeamTaskReader'; +import { + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; +import { + assertExecutable, + formatMemberWorkSyncDiagnostics, + formatProgressDump, + readRuntimeTurnSettledProcessedMetas, + restoreEnv, + startMemberWorkSyncControlServer, + type MemberWorkSyncLiveControlServer, + waitUntil, +} from './memberWorkSyncLiveHarness'; + +import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types'; + +vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () => ({ + NotificationManager: { + getInstance: () => ({ + addTeamNotification: vi.fn(async () => undefined), + }), + }, +})); + +const liveDescribe = + process.env.MEMBER_WORK_SYNC_CLAUDE_STOP_HOOK_LIVE === '1' && + Boolean(process.env.ANTHROPIC_API_KEY?.trim()) + ? describe + : describe.skip; + +const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; +const DEFAULT_MODEL = 'haiku'; + +liveDescribe('Member work sync Claude Stop hook live e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + let tempHome: string; + 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; + let previousUserProfile: string | undefined; + let svc: TeamProvisioningService | null; + let feature: MemberWorkSyncFeatureFacade | null; + let controlServer: MemberWorkSyncLiveControlServer | null; + let teamName: string | null; + + 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; + 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; + previousUserProfile = process.env.USERPROFILE; + + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + 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; + + svc = null; + feature = null; + controlServer = null; + teamName = null; + }); + + afterEach(async () => { + if (svc && teamName) { + await svc.stopTeam(teamName).catch(() => undefined); + } + svc?.setTeamChangeEmitter(null); + svc?.setControlApiBaseUrlResolver(null); + svc?.setRuntimeTurnSettledHookSettingsProvider(null); + await feature?.dispose().catch(() => undefined); + await controlServer?.close().catch(() => undefined); + + 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); + restoreEnv('USERPROFILE', previousUserProfile); + setClaudeBasePathOverride(null); + if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') { + console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it( + 'launches a real Claude teammate, accepts its work-sync report, and ingests its Stop hook event', + 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_CLAUDE_MODEL?.trim() || DEFAULT_MODEL; + const marker = `member-work-sync-claude-stop-live-${Date.now()}`; + const memberName = 'alice'; + teamName = `member-work-sync-claude-stop-${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 Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n', + 'utf8' + ); + + 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!], + nudgeSideEffectsEnabled: false, + queueQuietWindowMs: 500, + }); + activeService.setTeamChangeEmitter((event: TeamChangeEvent) => + feature!.noteTeamChange(event) + ); + activeService.setRuntimeTurnSettledHookSettingsProvider((input) => + feature!.buildRuntimeTurnSettledHookSettings(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: 'anthropic', + model, + skipPermissions: true, + 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.', + ].join(' '), + members: [ + { + name: memberName, + role: 'Developer', + providerId: 'anthropic', + model, + }, + ], + }, + (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); + + await expect( + fs.stat( + path.join( + getTeamsBasePath(), + '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh' + ) + ) + ).resolves.toMatchObject({ mode: expect.any(Number) }); + + const task = await teamDataService.createTask(teamName, { + subject: `Member work sync Claude Stop hook live lease ${marker}`, + owner: memberName, + startImmediately: true, + prompt: [ + `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.`, + '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 status = await feature!.getStatus({ teamName: teamName!, memberName }); + return ( + status.memberName === memberName && + status.providerId === 'anthropic' && + status.agenda.items.some((item) => item.taskId === task.id) && + status.shadow?.wouldNudge === true + ); + }, 30_000); + + await waitUntil(async () => { + await feature!.replayPendingReports([teamName!]); + const status = await feature!.getStatus({ teamName: teamName!, memberName }); + if (status.report?.accepted && status.report.state === 'still_working') { + return true; + } + const tasks = await new TeamTaskReader().getTasks(teamName!); + const currentTask = tasks.find((candidate) => candidate.id === task.id); + const hasMarkerComment = currentTask?.comments?.some((comment) => + comment.text.includes(`${marker}:still-working`) + ); + return Boolean(hasMarkerComment && status.report?.accepted); + }, 300_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled; + await waitUntil(async () => { + await feature!.drainRuntimeTurnSettledEvents(); + const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); + return metas.some(({ meta }) => { + const event = meta.event as Record | undefined; + return ( + meta.outcome === 'enqueued' && + meta.teamName === teamName && + meta.memberName === memberName && + event?.provider === 'claude' + ); + }); + }, 180_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + await waitUntil( + async () => feature!.getQueueDiagnostics().reconciled > beforeTurnSettledReconciled, + 30_000, + 500 + ); + + const [finalStatus, metrics] = await Promise.all([ + feature.getStatus({ teamName, memberName }), + feature.getMetrics({ teamName }), + ]); + expect(finalStatus.state).toBe('still_working'); + expect(finalStatus.report).toMatchObject({ + accepted: true, + state: 'still_working', + }); + expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); + await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + }, + 420_000 + ); +}); diff --git a/test/main/services/team/MemberWorkSyncCodex.live.test.ts b/test/main/services/team/MemberWorkSyncCodex.live.test.ts new file mode 100644 index 00000000..9664a531 --- /dev/null +++ b/test/main/services/team/MemberWorkSyncCodex.live.test.ts @@ -0,0 +1,284 @@ +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + createMemberWorkSyncFeature, + type MemberWorkSyncFeatureFacade, +} from '../../../../src/features/member-work-sync/main'; +import { + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; +import { + assertExecutable, + formatMemberWorkSyncDiagnostics, + formatProgressDump, + restoreEnv, + startMemberWorkSyncControlServer, + type MemberWorkSyncLiveControlServer, + waitUntil, +} from './memberWorkSyncLiveHarness'; + +import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types'; + +vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () => ({ + NotificationManager: { + getInstance: () => ({ + addTeamNotification: vi.fn(async () => undefined), + }), + }, +})); + +const hasCodexApiKey = Boolean( + process.env.OPENAI_API_KEY?.trim() || process.env.CODEX_API_KEY?.trim() +); +const allowConnectedChatGptAccount = + process.env.MEMBER_WORK_SYNC_CODEX_ALLOW_CONNECTED_ACCOUNT === '1'; +const liveDescribe = + process.env.MEMBER_WORK_SYNC_CODEX_LIVE === '1' && + (hasCodexApiKey || allowConnectedChatGptAccount) + ? describe + : describe.skip; + +const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; +const DEFAULT_MODEL = 'gpt-5.4-mini'; +const DEFAULT_EFFORT = 'low' as const; + +liveDescribe('Member work sync Codex live e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + let previousCliPath: string | undefined; + let previousCliFlavor: string | undefined; + let previousControlUrl: string | undefined; + let previousNudgeFlag: string | undefined; + let svc: { + stopTeam(teamName: string): Promise; + isTeamAlive(teamName: string): boolean; + hasProvisioningRun(teamName: string): boolean; + setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void; + setControlApiBaseUrlResolver(resolver: (() => Promise) | null): void; + relayInboxFileToLiveRecipient(teamName: string, inboxName: string): Promise<{ relayed: number }>; + createTeam( + request: Parameters< + InstanceType< + typeof import('../../../../src/main/services/team/TeamProvisioningService').TeamProvisioningService + >['createTeam'] + >[0], + onProgress: (progress: TeamProvisioningProgress) => void + ): Promise; + } | null; + let feature: MemberWorkSyncFeatureFacade | null; + let controlServer: MemberWorkSyncLiveControlServer | null; + let teamName: string | null; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-codex-live-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + setClaudeBasePathOverride(tempClaudeRoot); + + 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; + + 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'; + + svc = null; + feature = null; + controlServer = null; + teamName = null; + }); + + afterEach(async () => { + if (svc && teamName) { + await svc.stopTeam(teamName).catch(() => undefined); + } + svc?.setControlApiBaseUrlResolver(null); + await feature?.dispose().catch(() => undefined); + await controlServer?.close().catch(() => undefined); + + 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); + setClaudeBasePathOverride(null); + if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') { + console.info(`[MemberWorkSyncCodex.live] preserved temp dir: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it( + 'lets a real Codex teammate report still-working for the current actionable agenda without automatic nudges', + 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-live-${Date.now()}`; + teamName = `member-work-sync-codex-${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 live e2e\n\nKeep this project intentionally tiny.\n', + 'utf8' + ); + + const [ + { TeamProvisioningService }, + { TeamDataService }, + { TeamConfigReader }, + { TeamTaskReader }, + { TeamKanbanManager }, + { TeamMembersMetaStore }, + ] = 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'), + ]); + + 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!], + nudgeSideEffectsEnabled: false, + }); + activeService.setTeamChangeEmitter((event: TeamChangeEvent) => + feature!.noteTeamChange(event) + ); + 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.', + 'Before going idle with unfinished assigned work, call member_work_sync_status and member_work_sync_report.', + ].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'; + const task = await teamDataService.createTask(teamName, { + subject: `Member work sync live lease ${marker}`, + owner: memberName, + startImmediately: true, + prompt: [ + `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.`, + '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 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 () => { + await feature!.replayPendingReports([teamName!]); + const status = await feature!.getStatus({ teamName: teamName!, memberName }); + if (status.report?.accepted && status.report.state === 'still_working') { + return true; + } + const tasks = await new TeamTaskReader().getTasks(teamName!); + const currentTask = tasks.find((candidate) => candidate.id === task.id); + const hasMarkerComment = currentTask?.comments?.some((comment) => + comment.text.includes(`${marker}:still-working`) + ); + return Boolean(hasMarkerComment && status.report?.accepted); + }, 240_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + const [finalStatus, metrics] = await Promise.all([ + feature.getStatus({ teamName, memberName }), + feature.getMetrics({ teamName }), + ]); + expect(finalStatus.state).toBe('still_working'); + expect(finalStatus.report).toMatchObject({ + accepted: true, + state: 'still_working', + }); + expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); + await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + }, + 360_000 + ); +}); diff --git a/test/main/services/team/memberWorkSyncLiveHarness.ts b/test/main/services/team/memberWorkSyncLiveHarness.ts new file mode 100644 index 00000000..7696361c --- /dev/null +++ b/test/main/services/team/memberWorkSyncLiveHarness.ts @@ -0,0 +1,215 @@ +import { constants as fsConstants, promises as fs } from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; + +import type { MemberWorkSyncReportRequest } from '../../../../src/features/member-work-sync/contracts'; +import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/member-work-sync/main'; + +import type { TeamProvisioningProgress } from '../../../../src/shared/types'; + +export interface MemberWorkSyncLiveControlServer { + baseUrl: string; + close(): Promise; +} + +export async function startMemberWorkSyncControlServer( + feature: MemberWorkSyncFeatureFacade +): Promise { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent); + if ( + request.method === 'GET' && + parts.length === 5 && + parts[0] === 'api' && + parts[1] === 'teams' && + parts[3] === 'member-work-sync' + ) { + const payload = await feature.getStatus({ + teamName: parts[2], + memberName: parts[4], + }); + sendJson(response, 200, payload); + return; + } + if ( + request.method === 'POST' && + parts.length === 5 && + parts[0] === 'api' && + parts[1] === 'teams' && + parts[3] === 'member-work-sync' && + parts[4] === 'report' + ) { + const body = (await readRequestJson(request)) as MemberWorkSyncReportRequest; + const payload = await feature.report({ + ...body, + teamName: parts[2], + source: 'mcp', + }); + sendJson( + response, + payload.accepted ? 200 : 400, + payload.accepted ? payload : { error: payload.code } + ); + return; + } + sendJson(response, 404, { error: `Unhandled ${request.method} ${url.pathname}` }); + } catch (error) { + sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) }); + } + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind member work sync control server'); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +export function restoreEnv(name: string, previous: string | undefined): void { + if (previous === undefined) { + delete process.env[name]; + } else { + process.env[name] = previous; + } +} + +export async function assertExecutable(filePath: string): Promise { + await fs.access(filePath, fsConstants.X_OK); +} + +export async function waitUntil( + predicate: () => Promise, + timeoutMs: number, + pollMs = 2_000, + getDiagnostics?: () => Promise +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + if (await predicate()) { + return; + } + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + const suffix = + lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : ''; + const diagnostics = getDiagnostics ? `\n${await getDiagnostics().catch(String)}` : ''; + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${diagnostics}`); +} + +export function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string { + return progressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n'); +} + +export async function formatMemberWorkSyncDiagnostics(input: { + feature: MemberWorkSyncFeatureFacade; + teamName: string; + memberName: string; + taskId?: string; +}): Promise { + const [{ TeamTaskReader }] = await Promise.all([ + import('../../../../src/main/services/team/TeamTaskReader'), + ]); + const [status, metrics, tasks] = await Promise.all([ + input.feature.getStatus({ teamName: input.teamName, memberName: input.memberName }), + 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; + return [ + 'Member work sync live diagnostics:', + JSON.stringify( + { + state: status.state, + diagnostics: status.diagnostics, + agendaFingerprint: status.agenda.fingerprint, + agendaItems: status.agenda.items.map((item) => ({ + taskId: item.taskId, + subject: item.subject, + assignee: item.assignee, + kind: item.kind, + })), + report: status.report, + shadow: status.shadow, + queue: input.feature.getQueueDiagnostics(), + comments: task?.comments?.map((comment) => ({ + author: comment.author, + text: comment.text, + })), + recentEvents: metrics.recentEvents.slice(-12), + }, + null, + 2 + ), + ].join('\n'); +} + +export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise< + Array<{ + filePath: string; + meta: Record; + }> +> { + 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 + .filter((entry) => entry.isFile() && entry.name.endsWith('.meta.json')) + .map(async (entry) => { + const filePath = path.join(processedDir, entry.name); + const raw = await fs.readFile(filePath, 'utf8'); + return { filePath, meta: JSON.parse(raw) as Record }; + }) + ); + return metas.sort((left, right) => left.filePath.localeCompare(right.filePath)); +} + +async function readRequestJson(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const raw = Buffer.concat(chunks).toString('utf8').trim(); + return raw ? JSON.parse(raw) : {}; +} + +function sendJson(response: http.ServerResponse, statusCode: number, payload: unknown): void { + response.writeHead(statusCode, { + 'content-type': 'application/json', + }); + response.end(JSON.stringify(payload)); +} From 7cb6cddee8cf0abaed0c34b2d48f915c46b6c606 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 18:52:12 +0300 Subject: [PATCH 30/74] test: stabilize claude work sync live e2e --- .../createMemberWorkSyncFeature.ts | 12 +++-- .../MemberWorkSyncClaudeStopHook.live.test.ts | 51 ++++++++++++++++--- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index a917f583..324161b2 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -18,6 +18,7 @@ import { type MemberWorkSyncReconcileContext, RuntimeTurnSettledIngestor, type RuntimeTurnSettledDrainSummary, + type RuntimeTurnSettledTargetResolverPort, } from '../../core/application'; import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; @@ -104,6 +105,7 @@ export function createMemberWorkSyncFeature(deps: { listLifecycleActiveTeamNames?: () => Promise; nudgeSideEffectsEnabled?: boolean; queueQuietWindowMs?: number; + runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort; logger?: MemberWorkSyncLoggerPort; }): MemberWorkSyncFeatureFacade { const clock = new SystemClockAdapter(); @@ -126,10 +128,12 @@ export function createMemberWorkSyncFeature(deps: { paths: runtimeTurnSettledSpoolPaths, }); const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash); - const runtimeTurnSettledTargetResolver = new TeamRuntimeTurnSettledTargetResolver({ - teamSource: deps.configReader, - membersMetaStore: deps.membersMetaStore, - }); + const runtimeTurnSettledTargetResolver = + deps.runtimeTurnSettledTargetResolver ?? + new TeamRuntimeTurnSettledTargetResolver({ + teamSource: deps.configReader, + membersMetaStore: deps.membersMetaStore, + }); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath); const busySignal = new MemberWorkSyncToolActivityBusySignal(); diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 14f98299..7a28bc3b 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -46,7 +46,8 @@ const liveDescribe = : describe.skip; const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; -const DEFAULT_MODEL = 'haiku'; +const DEFAULT_MODEL = 'sonnet'; +const DEFAULT_EFFORT = 'low' as const; liveDescribe('Member work sync Claude Stop hook live e2e', () => { let tempDir: string; @@ -145,17 +146,40 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { svc = new TeamProvisioningService(); const activeService = svc; const teamDataService = new TeamDataService(); + const configReader = new TeamConfigReader(); + const membersMetaStore = new TeamMembersMetaStore(); feature = createMemberWorkSyncFeature({ teamsBasePath: getTeamsBasePath(), - configReader: new TeamConfigReader(), + configReader, taskReader: new TeamTaskReader(), kanbanManager: new TeamKanbanManager(), - membersMetaStore: new TeamMembersMetaStore(), + membersMetaStore, 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 + // member process to prompt. The live assertion below still uses a real Claude + // process, real MCP calls, and a real Stop hook payload; this seam keeps the + // test focused on hook ingestion instead of tmux liveness. + runtimeTurnSettledTargetResolver: { + resolve: async (event) => { + if (event.provider !== 'claude') { + return { ok: false, reason: 'unsupported_provider' }; + } + if (!teamName) { + return { ok: false, reason: 'missing_team' }; + } + const config = await configReader.getConfig(teamName); + const leadSessionId = config?.leadSessionId?.trim(); + if (!leadSessionId || event.sessionId !== leadSessionId) { + return { ok: false, reason: 'no_matching_member_session' }; + } + return { ok: true, teamName, memberName }; + }, + }, }); activeService.setTeamChangeEmitter((event: TeamChangeEvent) => feature!.noteTeamChange(event) @@ -180,11 +204,11 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { cwd: projectPath, providerId: 'anthropic', model, + effort: DEFAULT_EFFORT, skipPermissions: true, 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.', + 'Keep launch work minimal and wait for the explicit live-test instruction.', + 'Do not inspect tasks or send messages until the next user turn.', ].join(' '), members: [ { @@ -192,6 +216,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { role: 'Developer', providerId: 'anthropic', model, + effort: DEFAULT_EFFORT, }, ], }, @@ -232,8 +257,18 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { ].join('\n'), }); feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); - const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); - expect(relay.relayed).toBeGreaterThan(0); + await activeService.sendMessageToTeam( + teamName, + [ + `Live member-work-sync validation instruction. Marker: ${marker}.`, + `Use the board MCP tools as member "${memberName}" for this validation.`, + `Call task_get for taskId "${task.id}", then task_start.`, + `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 taskIds ["${task.id}"].`, + 'After that stop. Do not complete the task. Do not send a user-visible message.', + ].join('\n') + ); await waitUntil(async () => { const status = await feature!.getStatus({ teamName: teamName!, memberName }); From bfd8b30ad57939e0884a8ce04158ff215892dbb3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 19:05:32 +0300 Subject: [PATCH 31/74] test: expand claude work sync live scenarios --- .../MemberWorkSyncClaudeStopHook.live.test.ts | 495 ++++++++++-------- 1 file changed, 291 insertions(+), 204 deletions(-) diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 7a28bc3b..e6ff0e4f 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -49,6 +49,26 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_ const DEFAULT_MODEL = 'sonnet'; const DEFAULT_EFFORT = 'low' as const; +type ClaudeStopHookLiveScenarioState = 'still_working' | 'caught_up'; + +interface ClaudeStopHookLiveScenarioContext { + marker: string; + memberName: string; + teamName: string; + controlUrl: string; + taskId?: string; +} + +interface ClaudeStopHookLiveScenario { + markerSuffix: string; + subjectPrefix: string; + expectedState: ClaudeStopHookLiveScenarioState; + expectedTaskStatus: 'in_progress' | 'completed'; + expectedMarkerText(marker: string): string; + buildTaskPromptLines(context: ClaudeStopHookLiveScenarioContext): string[]; + buildInstructionLines(context: Required): string[]; +} + liveDescribe('Member work sync Claude Stop hook live e2e', () => { let tempDir: string; let tempClaudeRoot: string; @@ -124,226 +144,293 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { } }); - it( - 'launches a real Claude teammate, accepts its work-sync report, and ingests its Stop hook event', - async () => { - const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim(); - expect(orchestratorCli).toBeTruthy(); - await assertExecutable(orchestratorCli!); + async function runClaudeStopHookLiveScenario( + scenario: ClaudeStopHookLiveScenario + ): Promise { + const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim(); + expect(orchestratorCli).toBeTruthy(); + await assertExecutable(orchestratorCli!); - const model = process.env.MEMBER_WORK_SYNC_CLAUDE_MODEL?.trim() || DEFAULT_MODEL; - const marker = `member-work-sync-claude-stop-live-${Date.now()}`; - const memberName = 'alice'; - teamName = `member-work-sync-claude-stop-${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 Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n', - 'utf8' - ); + const model = process.env.MEMBER_WORK_SYNC_CLAUDE_MODEL?.trim() || DEFAULT_MODEL; + const startedAt = Date.now(); + const marker = `member-work-sync-claude-stop-live-${scenario.markerSuffix}-${startedAt}`; + const memberName = 'alice'; + teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`; + const projectPath = path.join(tempDir, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile( + path.join(projectPath, 'README.md'), + '# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n', + 'utf8' + ); - svc = new TeamProvisioningService(); - const activeService = svc; - const teamDataService = new TeamDataService(); - const configReader = new TeamConfigReader(); - const membersMetaStore = new TeamMembersMetaStore(); - feature = createMemberWorkSyncFeature({ - teamsBasePath: getTeamsBasePath(), - configReader, - taskReader: new TeamTaskReader(), - kanbanManager: new TeamKanbanManager(), - membersMetaStore, - 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 - // member process to prompt. The live assertion below still uses a real Claude - // process, real MCP calls, and a real Stop hook payload; this seam keeps the - // test focused on hook ingestion instead of tmux liveness. - runtimeTurnSettledTargetResolver: { - resolve: async (event) => { - if (event.provider !== 'claude') { - return { ok: false, reason: 'unsupported_provider' }; - } - if (!teamName) { - return { ok: false, reason: 'missing_team' }; - } - const config = await configReader.getConfig(teamName); - const leadSessionId = config?.leadSessionId?.trim(); - if (!leadSessionId || event.sessionId !== leadSessionId) { - return { ok: false, reason: 'no_matching_member_session' }; - } - return { ok: true, teamName, memberName }; - }, + svc = new TeamProvisioningService(); + const activeService = svc; + const teamDataService = new TeamDataService(); + const configReader = new TeamConfigReader(); + const membersMetaStore = new TeamMembersMetaStore(); + feature = createMemberWorkSyncFeature({ + teamsBasePath: getTeamsBasePath(), + configReader, + taskReader: new TeamTaskReader(), + kanbanManager: new TeamKanbanManager(), + membersMetaStore, + 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 + // member process to prompt. These live assertions still use a real Claude + // process, real MCP calls, and real Stop hook payloads; this seam keeps the + // tests focused on hook ingestion instead of tmux liveness. + runtimeTurnSettledTargetResolver: { + resolve: async (event) => { + if (event.provider !== 'claude') { + return { ok: false, reason: 'unsupported_provider' }; + } + if (!teamName) { + return { ok: false, reason: 'missing_team' }; + } + const config = await configReader.getConfig(teamName); + const leadSessionId = config?.leadSessionId?.trim(); + if (!leadSessionId || event.sessionId !== leadSessionId) { + return { ok: false, reason: 'no_matching_member_session' }; + } + return { ok: true, teamName, memberName }; }, - }); - activeService.setTeamChangeEmitter((event: TeamChangeEvent) => - feature!.noteTeamChange(event) - ); - activeService.setRuntimeTurnSettledHookSettingsProvider((input) => - feature!.buildRuntimeTurnSettledHookSettings(input) - ); + }, + }); + activeService.setTeamChangeEmitter((event: TeamChangeEvent) => feature!.noteTeamChange(event)); + activeService.setRuntimeTurnSettledHookSettingsProvider((input) => + feature!.buildRuntimeTurnSettledHookSettings(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' - ); + 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: 'anthropic', - model, - effort: DEFAULT_EFFORT, - skipPermissions: true, - prompt: [ - 'Keep launch work minimal and wait for the explicit live-test instruction.', - 'Do not inspect tasks or send messages until the next user turn.', - ].join(' '), - members: [ - { - name: memberName, - role: 'Developer', - providerId: 'anthropic', - model, - effort: DEFAULT_EFFORT, - }, - ], - }, - (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); - - await expect( - fs.stat( - path.join( - getTeamsBasePath(), - '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh' - ) - ) - ).resolves.toMatchObject({ mode: expect.any(Number) }); - - const task = await teamDataService.createTask(teamName, { - subject: `Member work sync Claude Stop hook live lease ${marker}`, - owner: memberName, - startImmediately: true, + const progressEvents: TeamProvisioningProgress[] = []; + await activeService.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'anthropic', + model, + effort: DEFAULT_EFFORT, + skipPermissions: true, prompt: [ + 'Keep launch work minimal and wait for the explicit live-test instruction.', + 'Do not inspect tasks or send messages until the next user turn.', + ].join(' '), + members: [ + { + name: memberName, + role: 'Developer', + providerId: 'anthropic', + model, + effort: DEFAULT_EFFORT, + }, + ], + }, + (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); + + await expect( + fs.stat( + path.join( + getTeamsBasePath(), + '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh' + ) + ) + ).resolves.toMatchObject({ mode: expect.any(Number) }); + + const taskPromptContext: ClaudeStopHookLiveScenarioContext = { + marker, + memberName, + teamName, + controlUrl: controlServer.baseUrl, + }; + const task = await teamDataService.createTask(teamName, { + subject: `${scenario.subjectPrefix} ${marker}`, + owner: memberName, + startImmediately: true, + prompt: scenario.buildTaskPromptLines(taskPromptContext).join('\n'), + }); + feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); + + await waitUntil(async () => { + const status = await feature!.getStatus({ teamName: teamName!, memberName }); + return ( + status.memberName === memberName && + status.providerId === 'anthropic' && + status.agenda.items.some((item) => item.taskId === task.id) && + status.shadow?.wouldNudge === true + ); + }, 30_000); + + await activeService.sendMessageToTeam( + teamName, + scenario + .buildInstructionLines({ + ...taskPromptContext, + taskId: task.id, + }) + .join('\n') + ); + + await waitUntil(async () => { + await feature!.replayPendingReports([teamName!]); + const [status, tasks] = await Promise.all([ + feature!.getStatus({ teamName: teamName!, memberName }), + new TeamTaskReader().getTasks(teamName!), + ]); + const currentTask = tasks.find((candidate) => candidate.id === task.id); + const expectedMarker = scenario.expectedMarkerText(marker); + const hasMarkerComment = currentTask?.comments?.some( + (comment) => comment.author === memberName && comment.text.includes(expectedMarker) + ); + return Boolean( + hasMarkerComment && + currentTask?.status === scenario.expectedTaskStatus && + status.report?.accepted && + status.report.state === scenario.expectedState + ); + }, 300_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled; + await waitUntil(async () => { + await feature!.drainRuntimeTurnSettledEvents(); + const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); + return metas.some(({ meta }) => { + const event = meta.event as Record | undefined; + return ( + meta.outcome === 'enqueued' && + meta.teamName === teamName && + meta.memberName === memberName && + event?.provider === 'claude' + ); + }); + }, 180_000, 2_000, async () => + formatMemberWorkSyncDiagnostics({ + feature: feature!, + teamName: teamName!, + memberName, + taskId: task.id, + }) + ); + + await waitUntil( + async () => feature!.getQueueDiagnostics().reconciled > beforeTurnSettledReconciled, + 30_000, + 500 + ); + + const [finalStatus, metrics] = await Promise.all([ + feature.getStatus({ teamName, memberName }), + feature.getMetrics({ teamName }), + ]); + expect(finalStatus.state).toBe(scenario.expectedState); + expect(finalStatus.report).toMatchObject({ + accepted: true, + state: scenario.expectedState, + }); + if (scenario.expectedState === 'caught_up') { + expect(finalStatus.agenda.items).toHaveLength(0); + } else { + expect(finalStatus.agenda.items.some((item) => item.taskId === task.id)).toBe(true); + } + expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); + await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ + claimed: 0, + delivered: 0, + }); + } + + it( + 'launches a real Claude teammate, accepts a still-working report, and ingests its Stop hook event', + async () => + runClaudeStopHookLiveScenario({ + markerSuffix: 'lease', + subjectPrefix: 'Member work sync Claude Stop hook live lease', + expectedState: 'still_working', + expectedTaskStatus: 'in_progress', + expectedMarkerText: (marker) => `${marker}:still-working`, + buildTaskPromptLines: ({ marker, memberName, teamName, controlUrl }) => [ `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.`, + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`, 'After that stop. Do not send a user-visible message.', - ].join('\n'), - }); - feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); - await activeService.sendMessageToTeam( - teamName, - [ + ], + buildInstructionLines: ({ marker, memberName, teamName, controlUrl, taskId }) => [ `Live member-work-sync validation instruction. Marker: ${marker}.`, `Use the board MCP tools as member "${memberName}" for this validation.`, - `Call task_get for taskId "${task.id}", then task_start.`, + `Call task_get for taskId "${taskId}", then task_start.`, `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 taskIds ["${task.id}"].`, + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and taskIds ["${taskId}"].`, 'After that stop. Do not complete the task. Do not send a user-visible message.', - ].join('\n') - ); + ], + }), + 420_000 + ); - await waitUntil(async () => { - const status = await feature!.getStatus({ teamName: teamName!, memberName }); - return ( - status.memberName === memberName && - status.providerId === 'anthropic' && - status.agenda.items.some((item) => item.taskId === task.id) && - status.shadow?.wouldNudge === true - ); - }, 30_000); - - await waitUntil(async () => { - await feature!.replayPendingReports([teamName!]); - const status = await feature!.getStatus({ teamName: teamName!, memberName }); - if (status.report?.accepted && status.report.state === 'still_working') { - return true; - } - const tasks = await new TeamTaskReader().getTasks(teamName!); - const currentTask = tasks.find((candidate) => candidate.id === task.id); - const hasMarkerComment = currentTask?.comments?.some((comment) => - comment.text.includes(`${marker}:still-working`) - ); - return Boolean(hasMarkerComment && status.report?.accepted); - }, 300_000, 2_000, async () => - formatMemberWorkSyncDiagnostics({ - feature: feature!, - teamName: teamName!, - memberName, - taskId: task.id, - }) - ); - - const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled; - await waitUntil(async () => { - await feature!.drainRuntimeTurnSettledEvents(); - const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); - return metas.some(({ meta }) => { - const event = meta.event as Record | undefined; - return ( - meta.outcome === 'enqueued' && - meta.teamName === teamName && - meta.memberName === memberName && - event?.provider === 'claude' - ); - }); - }, 180_000, 2_000, async () => - formatMemberWorkSyncDiagnostics({ - feature: feature!, - teamName: teamName!, - memberName, - taskId: task.id, - }) - ); - - await waitUntil( - async () => feature!.getQueueDiagnostics().reconciled > beforeTurnSettledReconciled, - 30_000, - 500 - ); - - const [finalStatus, metrics] = await Promise.all([ - feature.getStatus({ teamName, memberName }), - feature.getMetrics({ teamName }), - ]); - expect(finalStatus.state).toBe('still_working'); - expect(finalStatus.report).toMatchObject({ - accepted: true, - state: 'still_working', - }); - expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); - await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ - claimed: 0, - delivered: 0, - }); - }, + it( + 'launches a real Claude teammate, completes work, reports caught-up, and ingests its Stop hook event', + async () => + runClaudeStopHookLiveScenario({ + markerSuffix: 'caught-up', + subjectPrefix: 'Member work sync Claude Stop hook live caught-up', + expectedState: 'caught_up', + expectedTaskStatus: 'completed', + expectedMarkerText: (marker) => `${marker}:completed`, + buildTaskPromptLines: ({ marker, memberName, teamName, controlUrl }) => [ + `This is a live member-work-sync caught-up validation task. Marker: ${marker}.`, + 'Do not edit files.', + 'Call task_start for this task.', + `Add one task comment containing exactly: ${marker}:completed.`, + 'Then call task_complete for this task.', + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "caught_up", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and no taskIds.`, + 'After that stop. Do not send a user-visible message.', + ], + buildInstructionLines: ({ marker, memberName, teamName, controlUrl, taskId }) => [ + `Live member-work-sync caught-up validation instruction. Marker: ${marker}.`, + `Use the board MCP tools as member "${memberName}" for this validation.`, + `Call task_get for taskId "${taskId}", then task_start.`, + `Add one task comment containing exactly: ${marker}:completed.`, + `Call task_complete for taskId "${taskId}".`, + `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, + `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "caught_up", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and no taskIds.`, + 'After that stop. Do not send a user-visible message.', + ], + }), 420_000 ); }); From 10188109ebc552cbbace7e0770240b08d435c9ea Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 19:06:31 +0300 Subject: [PATCH 32/74] fix(team-mcp): route agent teams mcp to app state --- .../member-work-sync-control-plane-plan.md | 9 +++++---- src/main/services/team/TeamProvisioningService.ts | 6 +++++- .../services/team/MemberWorkSyncCodex.live.test.ts | 11 +++++++++++ .../main/services/team/TeamMcpConfigBuilder.test.ts | 13 +++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) 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 46f0aa61..218314d2 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 readiness-gated Phase 2 nudge outbox/dispatcher/scheduler implemented +**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and opt-in 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` @@ -36,9 +36,10 @@ Current implementation note: - 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. -- 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. +- 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. - 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. 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 only when nudge side effects are enabled. 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. @@ -1363,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. +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`. `wouldNudge` is true only when all are true: diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b0027afd..ca4d17db 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -24556,7 +24556,11 @@ export class TeamProvisioningService { throwIfCancelled(); child = spawnCli(launchSpec.command, launchSpec.args, { cwd: launchSpec.cwd ?? cwd, - env: { ...env, ...launchSpec.env }, + env: { + ...env, + ...launchSpec.env, + AGENT_TEAMS_MCP_CLAUDE_DIR: fixture.claudeDir, + }, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }); diff --git a/test/main/services/team/MemberWorkSyncCodex.live.test.ts b/test/main/services/team/MemberWorkSyncCodex.live.test.ts index 9664a531..3e416fe3 100644 --- a/test/main/services/team/MemberWorkSyncCodex.live.test.ts +++ b/test/main/services/team/MemberWorkSyncCodex.live.test.ts @@ -54,6 +54,8 @@ liveDescribe('Member work sync Codex live e2e', () => { let previousCliFlavor: string | undefined; let previousControlUrl: string | undefined; let previousNudgeFlag: string | undefined; + let previousCodexHome: string | undefined; + let codexHomeDir: string; let svc: { stopTeam(teamName: string): Promise; isTeamAlive(teamName: string): boolean; @@ -84,11 +86,17 @@ liveDescribe('Member work sync Codex live e2e', () => { 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 codexHomeRoot = path.resolve('temp', 'member-work-sync-codex-live'); + await fs.mkdir(codexHomeRoot, { recursive: true }); + codexHomeDir = await fs.mkdtemp(path.join(codexHomeRoot, 'codex-home-')); 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; svc = null; feature = null; @@ -108,11 +116,14 @@ liveDescribe('Member work sync Codex live e2e', () => { 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') { console.info(`[MemberWorkSyncCodex.live] preserved temp dir: ${tempDir}`); + console.info(`[MemberWorkSyncCodex.live] preserved CODEX_HOME: ${codexHomeDir}`); } else { await fs.rm(tempDir, { recursive: true, force: true }); + await fs.rm(codexHomeDir, { recursive: true, force: true }); } }); diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 91f68eaa..273b2c82 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -240,6 +240,19 @@ describe('TeamMcpConfigBuilder', () => { expectTsxEntry(server, sourceEntry); }); + it('pins the MCP controller to the active Claude base path', async () => { + const claudeDir = path.join(tempAppData, 'custom-claude-root'); + setClaudeBasePathOverride(claudeDir); + mockSourceWorkspaceEntryAvailable(); + const builder = new TeamMcpConfigBuilder(); + + const configPath = await builder.writeConfigFile(); + createdPaths.push(configPath); + + const server = readGeneratedServer(configPath); + expect(server?.env?.AGENT_TEAMS_MCP_CLAUDE_DIR).toBe(claudeDir); + }); + it('falls back to the built workspace MCP entry when source execution is unavailable', async () => { const builtEntry = mockBuiltWorkspaceEntryAvailable(); const builder = new TeamMcpConfigBuilder(); From 0d83f94537766005151ce72dae3748a4552eab2d Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 19:13:17 +0300 Subject: [PATCH 33/74] test: require post-validation claude stop hook --- .../MemberWorkSyncClaudeStopHook.live.test.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index e6ff0e4f..318610cd 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -285,6 +285,14 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { ); }, 30_000); + const processedMetasBeforeValidation = await readRuntimeTurnSettledProcessedMetas( + getTeamsBasePath() + ); + const processedMetaPathsBeforeValidation = new Set( + processedMetasBeforeValidation.map(({ filePath }) => filePath) + ); + const validationSentAt = Date.now(); + await activeService.sendMessageToTeam( teamName, scenario @@ -325,13 +333,18 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { await waitUntil(async () => { await feature!.drainRuntimeTurnSettledEvents(); const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); - return metas.some(({ meta }) => { + return metas.some(({ filePath, meta }) => { const event = meta.event as Record | undefined; + const recordedAt = + typeof event?.recordedAt === 'string' ? Date.parse(event.recordedAt) : Number.NaN; return ( + !processedMetaPathsBeforeValidation.has(filePath) && meta.outcome === 'enqueued' && meta.teamName === teamName && meta.memberName === memberName && - event?.provider === 'claude' + event?.provider === 'claude' && + Number.isFinite(recordedAt) && + recordedAt >= validationSentAt ); }); }, 180_000, 2_000, async () => @@ -386,7 +399,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { `Add one task comment containing exactly: ${marker}:still-working.`, `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`, - 'After that stop. Do not send a user-visible message.', + `After that, finish the turn with exactly: ${marker}:hook-settled.`, ], buildInstructionLines: ({ marker, memberName, teamName, controlUrl, taskId }) => [ `Live member-work-sync validation instruction. Marker: ${marker}.`, @@ -395,7 +408,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { `Add one task comment containing exactly: ${marker}:still-working.`, `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and taskIds ["${taskId}"].`, - 'After that stop. Do not complete the task. Do not send a user-visible message.', + `Do not complete the task. After that, finish the turn with exactly: ${marker}:hook-settled.`, ], }), 420_000 @@ -418,7 +431,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { 'Then call task_complete for this task.', `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "caught_up", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and no taskIds.`, - 'After that stop. Do not send a user-visible message.', + `After that, finish the turn with exactly: ${marker}:hook-settled.`, ], buildInstructionLines: ({ marker, memberName, teamName, controlUrl, taskId }) => [ `Live member-work-sync caught-up validation instruction. Marker: ${marker}.`, @@ -428,7 +441,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { `Call task_complete for taskId "${taskId}".`, `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlUrl}".`, `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlUrl}", state "caught_up", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and no taskIds.`, - 'After that stop. Do not send a user-visible message.', + `After that, finish the turn with exactly: ${marker}:hook-settled.`, ], }), 420_000 From dbec466c143ba2f0449fba36b4ba37ac03770e57 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 19:18:19 +0300 Subject: [PATCH 34/74] test: fail fast on claude live api errors --- .../MemberWorkSyncClaudeStopHook.live.test.ts | 13 +++ .../team/memberWorkSyncLiveHarness.ts | 92 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 318610cd..98eb083e 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -25,6 +25,7 @@ import { readRuntimeTurnSettledProcessedMetas, restoreEnv, startMemberWorkSyncControlServer, + throwIfClaudeTranscriptApiError, type MemberWorkSyncLiveControlServer, waitUntil, } from './memberWorkSyncLiveHarness'; @@ -251,6 +252,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { } return last?.state === 'ready'; }, 240_000); + await throwIfClaudeTranscriptApiError({ + claudeRoot: tempClaudeRoot, + context: 'Claude team launch', + }); await expect( fs.stat( @@ -304,6 +309,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { ); await waitUntil(async () => { + await throwIfClaudeTranscriptApiError({ + claudeRoot: tempClaudeRoot, + context: 'Claude validation turn', + }); await feature!.replayPendingReports([teamName!]); const [status, tasks] = await Promise.all([ feature!.getStatus({ teamName: teamName!, memberName }), @@ -331,6 +340,10 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { const beforeTurnSettledReconciled = feature.getQueueDiagnostics().reconciled; await waitUntil(async () => { + await throwIfClaudeTranscriptApiError({ + claudeRoot: tempClaudeRoot, + context: 'Claude Stop hook turn', + }); await feature!.drainRuntimeTurnSettledEvents(); const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath()); return metas.some(({ filePath, meta }) => { diff --git a/test/main/services/team/memberWorkSyncLiveHarness.ts b/test/main/services/team/memberWorkSyncLiveHarness.ts index 7696361c..7a68f805 100644 --- a/test/main/services/team/memberWorkSyncLiveHarness.ts +++ b/test/main/services/team/memberWorkSyncLiveHarness.ts @@ -7,6 +7,13 @@ import type { MemberWorkSyncFeatureFacade } from '../../../../src/features/membe import type { TeamProvisioningProgress } from '../../../../src/shared/types'; +export class FatalWaitError extends Error { + constructor(message: string) { + super(message); + this.name = 'FatalWaitError'; + } +} + export interface MemberWorkSyncLiveControlServer { baseUrl: string; close(): Promise; @@ -102,6 +109,9 @@ export async function waitUntil( return; } } catch (error) { + if (error instanceof FatalWaitError) { + throw error; + } lastError = error; } await new Promise((resolve) => setTimeout(resolve, pollMs)); @@ -173,6 +183,57 @@ export async function formatMemberWorkSyncDiagnostics(input: { ].join('\n'); } +export async function throwIfClaudeTranscriptApiError(input: { + claudeRoot: string; + context: string; +}): Promise { + const transcriptFiles = await findJsonlFiles(path.join(input.claudeRoot, 'projects')); + const apiErrors: Array<{ filePath: string; error: string; text: string }> = []; + for (const filePath of transcriptFiles) { + const raw = await fs.readFile(filePath, 'utf8').catch(() => ''); + for (const line of raw.split(/\r?\n/)) { + if (!line.includes('"isApiErrorMessage"') && !line.includes('"error"')) { + continue; + } + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + let parsed: Record; + try { + parsed = JSON.parse(trimmed) as Record; + } catch { + continue; + } + if (parsed.isApiErrorMessage !== true && typeof parsed.error !== 'string') { + continue; + } + const message = parsed.message as Record | undefined; + apiErrors.push({ + filePath, + error: typeof parsed.error === 'string' ? parsed.error : 'api_error', + text: extractClaudeMessageText(message), + }); + } + } + + if (apiErrors.length === 0) { + return; + } + + const latest = apiErrors.at(-1)!; + throw new FatalWaitError( + [ + `${input.context}: Claude API error detected in live transcript.`, + `error=${latest.error}`, + latest.text ? `message=${latest.text}` : undefined, + `transcript=${latest.filePath}`, + ] + .filter(Boolean) + .join('\n') + ); +} + export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string): Promise< Array<{ filePath: string; @@ -198,6 +259,37 @@ export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string return metas.sort((left, right) => left.filePath.localeCompare(right.filePath)); } +async function findJsonlFiles(rootPath: string): Promise { + const entries = await fs.readdir(rootPath, { withFileTypes: true }).catch(() => []); + const nested = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + return findJsonlFiles(entryPath); + } + return entry.isFile() && entry.name.endsWith('.jsonl') ? [entryPath] : []; + }) + ); + return nested.flat().sort((left, right) => left.localeCompare(right)); +} + +function extractClaudeMessageText(message: Record | undefined): string { + const content = message?.content; + if (!Array.isArray(content)) { + return ''; + } + return content + .map((part) => { + if (!part || typeof part !== 'object') { + return ''; + } + const text = (part as { text?: unknown }).text; + return typeof text === 'string' ? text : ''; + }) + .filter(Boolean) + .join('\n'); +} + async function readRequestJson(request: http.IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of request) { From ad331b131fbc9acae75bade02dba46c72839530b Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 19:36:12 +0300 Subject: [PATCH 35/74] test: preserve opencode semantic live artifacts --- .../services/team/OpenCodeSemanticMessaging.live.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts index 61df3548..a1e09ef5 100644 --- a/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts +++ b/test/main/services/team/OpenCodeSemanticMessaging.live.test.ts @@ -39,7 +39,11 @@ liveDescribe('OpenCode semantic messaging live e2e', () => { afterEach(async () => { setClaudeBasePathOverride(null); - await fs.rm(tempDir, { recursive: true, force: true }); + if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') { + console.info(`[OpenCodeSemanticMessaging.live] preserved temp dir: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); it( From 88d9b7c0c5a8ce029981c1b6184d556ccb71fb92 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 19:44:28 +0300 Subject: [PATCH 36/74] test(member-work-sync): fail fast on codex live runtime errors --- .../team/MemberWorkSyncCodex.live.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/main/services/team/MemberWorkSyncCodex.live.test.ts b/test/main/services/team/MemberWorkSyncCodex.live.test.ts index 3e416fe3..63a89b94 100644 --- a/test/main/services/team/MemberWorkSyncCodex.live.test.ts +++ b/test/main/services/team/MemberWorkSyncCodex.live.test.ts @@ -14,6 +14,7 @@ import { } from '../../../../src/main/utils/pathDecoder'; import { assertExecutable, + FatalWaitError, formatMemberWorkSyncDiagnostics, formatProgressDump, restoreEnv, @@ -255,6 +256,10 @@ liveDescribe('Member work sync Codex live e2e', () => { }, 30_000); 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 }); if (status.report?.accepted && status.report.state === 'still_working') { @@ -293,3 +298,41 @@ liveDescribe('Member work sync Codex live e2e', () => { 360_000 ); }); + +async function readFatalRuntimeMessage(teamName: string): Promise { + const sentMessagesPath = path.join(getTeamsBasePath(), teamName, 'sentMessages.json'); + let raw: string; + try { + raw = await fs.readFile(sentMessagesPath, 'utf8'); + } catch { + return null; + } + + let messages: unknown; + try { + messages = JSON.parse(raw); + } catch { + return null; + } + if (!Array.isArray(messages)) { + return null; + } + + for (const message of messages) { + if (!message || typeof message !== 'object') { + continue; + } + const text = (message as { text?: unknown }).text; + if (typeof text !== 'string') { + continue; + } + if ( + text.includes('Codex native exec exited') || + text.includes('Codex native error:') || + text.includes('Codex native turn failed:') + ) { + return text; + } + } + return null; +} From b8cbe40c015d6ebe1981ea7771854d6e62cfa9ed Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 19:52:56 +0300 Subject: [PATCH 37/74] test: stabilize claude stop hook live report check --- .../MemberWorkSyncClaudeStopHook.live.test.ts | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 98eb083e..6116e207 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -70,6 +70,19 @@ interface ClaudeStopHookLiveScenario { buildInstructionLines(context: Required): string[]; } +function hasAcceptedReportForScenario(input: { + metrics: Awaited>; + memberName: string; + expectedState: ClaudeStopHookLiveScenarioState; +}): boolean { + return input.metrics.recentEvents.some( + (event) => + event.kind === 'report_accepted' && + event.memberName === input.memberName && + event.reportState === input.expectedState + ); +} + liveDescribe('Member work sync Claude Stop hook live e2e', () => { let tempDir: string; let tempClaudeRoot: string; @@ -314,8 +327,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { context: 'Claude validation turn', }); await feature!.replayPendingReports([teamName!]); - const [status, tasks] = await Promise.all([ + const [status, metrics, tasks] = await Promise.all([ feature!.getStatus({ teamName: teamName!, memberName }), + feature!.getMetrics({ teamName: teamName! }), new TeamTaskReader().getTasks(teamName!), ]); const currentTask = tasks.find((candidate) => candidate.id === task.id); @@ -323,11 +337,17 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { const hasMarkerComment = currentTask?.comments?.some( (comment) => comment.author === memberName && comment.text.includes(expectedMarker) ); + const reportAccepted = + (status.report?.accepted === true && status.report.state === scenario.expectedState) || + hasAcceptedReportForScenario({ + metrics, + memberName, + expectedState: scenario.expectedState, + }); return Boolean( hasMarkerComment && currentTask?.status === scenario.expectedTaskStatus && - status.report?.accepted && - status.report.state === scenario.expectedState + reportAccepted ); }, 300_000, 2_000, async () => formatMemberWorkSyncDiagnostics({ @@ -379,17 +399,34 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { feature.getStatus({ teamName, memberName }), feature.getMetrics({ teamName }), ]); - expect(finalStatus.state).toBe(scenario.expectedState); - expect(finalStatus.report).toMatchObject({ - accepted: true, - state: scenario.expectedState, - }); + const finalReportAccepted = + (finalStatus.report?.accepted === true && + finalStatus.report.state === scenario.expectedState) || + hasAcceptedReportForScenario({ + metrics, + memberName, + expectedState: scenario.expectedState, + }); + expect(finalReportAccepted).toBe(true); + if (finalStatus.state !== 'inactive') { + expect(finalStatus.state).toBe(scenario.expectedState); + expect(finalStatus.report).toMatchObject({ + accepted: true, + state: scenario.expectedState, + }); + } if (scenario.expectedState === 'caught_up') { expect(finalStatus.agenda.items).toHaveLength(0); } else { expect(finalStatus.agenda.items.some((item) => item.taskId === task.id)).toBe(true); } - expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); + expect( + hasAcceptedReportForScenario({ + metrics, + memberName, + expectedState: scenario.expectedState, + }) + ).toBe(true); await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ claimed: 0, delivered: 0, From adb2b5c351e96dd50b8a6192be1d60de9dd6bcb1 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 19:59:39 +0300 Subject: [PATCH 38/74] test(member-work-sync): support codex connected-account live runs --- .../team/MemberWorkSyncCodex.live.test.ts | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/test/main/services/team/MemberWorkSyncCodex.live.test.ts b/test/main/services/team/MemberWorkSyncCodex.live.test.ts index 63a89b94..27d58a20 100644 --- a/test/main/services/team/MemberWorkSyncCodex.live.test.ts +++ b/test/main/services/team/MemberWorkSyncCodex.live.test.ts @@ -57,6 +57,14 @@ liveDescribe('Member work sync Codex live e2e', () => { let previousNudgeFlag: string | undefined; let previousCodexHome: string | undefined; let codexHomeDir: string; + let ownsCodexHomeDir: boolean; + let codexAccountFeature: { + getSnapshot(): Promise; + dispose(): Promise; + } | null; + let providerConnectionService: { + setCodexAccountFeature(feature: { getSnapshot(): Promise } | null): void; + } | null; let svc: { stopTeam(teamName: string): Promise; isTeamAlive(teamName: string): boolean; @@ -89,9 +97,17 @@ liveDescribe('Member work sync Codex live e2e', () => { previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED; previousCodexHome = process.env.CODEX_HOME; - const codexHomeRoot = path.resolve('temp', 'member-work-sync-codex-live'); - await fs.mkdir(codexHomeRoot, { recursive: true }); - codexHomeDir = await fs.mkdtemp(path.join(codexHomeRoot, 'codex-home-')); + const shouldUseConnectedAccountHome = allowConnectedChatGptAccount && !hasLiveCodexApiKey(); + if (shouldUseConnectedAccountHome) { + codexHomeDir = resolveConnectedCodexHome(previousCodexHome); + ownsCodexHomeDir = false; + await fs.access(codexHomeDir); + } else { + const codexHomeRoot = path.resolve('temp', 'member-work-sync-codex-live'); + await fs.mkdir(codexHomeRoot, { recursive: true }); + codexHomeDir = await fs.mkdtemp(path.join(codexHomeRoot, 'codex-home-')); + ownsCodexHomeDir = true; + } process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; @@ -99,6 +115,8 @@ liveDescribe('Member work sync Codex live e2e', () => { process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0'; process.env.CODEX_HOME = codexHomeDir; + codexAccountFeature = null; + providerConnectionService = null; svc = null; feature = null; controlServer = null; @@ -110,7 +128,9 @@ liveDescribe('Member work sync Codex live e2e', () => { await svc.stopTeam(teamName).catch(() => undefined); } svc?.setControlApiBaseUrlResolver(null); + providerConnectionService?.setCodexAccountFeature(null); await feature?.dispose().catch(() => undefined); + await codexAccountFeature?.dispose().catch(() => undefined); await controlServer?.close().catch(() => undefined); restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); @@ -124,7 +144,9 @@ liveDescribe('Member work sync Codex live e2e', () => { console.info(`[MemberWorkSyncCodex.live] preserved CODEX_HOME: ${codexHomeDir}`); } else { await fs.rm(tempDir, { recursive: true, force: true }); - await fs.rm(codexHomeDir, { recursive: true, force: true }); + if (ownsCodexHomeDir) { + await fs.rm(codexHomeDir, { recursive: true, force: true }); + } } }); @@ -155,6 +177,8 @@ liveDescribe('Member work sync Codex live e2e', () => { { TeamTaskReader }, { TeamKanbanManager }, { TeamMembersMetaStore }, + { createCodexAccountFeature }, + { ProviderConnectionService }, ] = await Promise.all([ import('../../../../src/main/services/team/TeamProvisioningService'), import('../../../../src/main/services/team/TeamDataService'), @@ -162,8 +186,29 @@ liveDescribe('Member work sync Codex live e2e', () => { 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(); @@ -336,3 +381,19 @@ async function readFatalRuntimeMessage(teamName: string): Promise } return null; } + +function hasLiveCodexApiKey(): boolean { + return Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.CODEX_API_KEY?.trim()); +} + +function resolveConnectedCodexHome(previousCodexHome: string | undefined): string { + const explicit = process.env.MEMBER_WORK_SYNC_CODEX_CONNECTED_HOME?.trim(); + if (explicit) { + return path.resolve(explicit); + } + const previous = previousCodexHome?.trim(); + if (previous) { + return path.resolve(previous); + } + return path.join(os.userInfo().homedir, '.codex'); +} From 8e7f7bea43aa3845d1858dcc90807c5396218222 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 20:43:29 +0300 Subject: [PATCH 39/74] feat(member-work-sync): ingest codex turn settled events --- .../core/domain/RuntimeTurnSettledEvent.ts | 5 + .../TeamRuntimeTurnSettledTargetResolver.ts | 33 +++++ .../createMemberWorkSyncFeature.ts | 21 +++- ...CodexNativeTurnSettledPayloadNormalizer.ts | 113 ++++++++++++++++++ ...siteRuntimeTurnSettledPayloadNormalizer.ts | 32 +++++ .../runtimeTurnSettledEnvironment.ts | 16 +++ src/main/index.ts | 5 + .../services/team/TeamProvisioningService.ts | 33 +++++ ...NativeTurnSettledPayloadNormalizer.test.ts | 64 ++++++++++ ...amRuntimeTurnSettledTargetResolver.test.ts | 59 +++++++++ .../main/createMemberWorkSyncFeature.test.ts | 28 +++++ .../TeamProvisioningServicePrepare.test.ts | 16 +++ 12 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts create mode 100644 src/features/member-work-sync/main/infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer.ts create mode 100644 src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts create mode 100644 test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts diff --git a/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts b/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts index 93c8747a..7cfd7936 100644 --- a/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts +++ b/src/features/member-work-sync/core/domain/RuntimeTurnSettledEvent.ts @@ -11,6 +11,11 @@ export interface RuntimeTurnSettledEvent { turnId?: string; transcriptPath?: string; cwd?: string; + teamName?: string; + memberName?: string; + agentId?: string; + threadId?: string; + outcome?: string; } export function buildRuntimeTurnSettledSourceId(input: { diff --git a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts index 22f3ecfc..1cd69e24 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts @@ -81,6 +81,10 @@ export class TeamRuntimeTurnSettledTargetResolver } async resolve(event: RuntimeTurnSettledEvent): Promise { + if (event.provider === 'codex') { + return this.resolveCodexEvent(event); + } + if (event.provider !== 'claude') { return { ok: false, reason: 'unsupported_provider' }; } @@ -152,6 +156,35 @@ export class TeamRuntimeTurnSettledTargetResolver }; } + private async resolveCodexEvent( + event: RuntimeTurnSettledEvent + ): Promise { + const teamName = event.teamName?.trim(); + const memberName = event.memberName?.trim(); + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const member = await this.resolveActiveMember(teamName, memberName); + if (!member) { + return { ok: false, reason: 'member_not_active' }; + } + if (isReservedMemberName(member.name)) { + return { ok: false, reason: 'reserved_member' }; + } + + const providerId = providerForMember(member); + if (providerId && providerId !== 'codex') { + return { ok: false, reason: 'provider_mismatch' }; + } + + return { + ok: true, + teamName, + memberName: normalizeMemberName(member.name), + }; + } + private async resolveActiveMember( teamName: string, memberName: string diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 324161b2..5fb88636 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -26,6 +26,8 @@ import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTas import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver'; import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer'; +import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer'; +import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer'; import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { @@ -40,6 +42,7 @@ import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler'; import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths'; import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller'; +import { buildRuntimeTurnSettledEnvironment } from '../infrastructure/runtimeTurnSettledEnvironment'; import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings'; import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; @@ -90,6 +93,9 @@ export interface MemberWorkSyncFeatureFacade { buildRuntimeTurnSettledHookSettings(input: { provider: RuntimeTurnSettledProvider; }): Promise | null>; + buildRuntimeTurnSettledEnvironment(input: { + provider: RuntimeTurnSettledProvider; + }): Promise | null>; drainRuntimeTurnSettledEvents(): Promise; getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics; dispose(): Promise; @@ -127,7 +133,10 @@ export function createMemberWorkSyncFeature(deps: { const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({ paths: runtimeTurnSettledSpoolPaths, }); - const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash); + const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([ + new ClaudeStopHookPayloadNormalizer(hash), + new CodexNativeTurnSettledPayloadNormalizer(hash), + ]); const runtimeTurnSettledTargetResolver = deps.runtimeTurnSettledTargetResolver ?? new TeamRuntimeTurnSettledTargetResolver({ @@ -258,6 +267,16 @@ export function createMemberWorkSyncFeature(deps: { provider, }); }, + buildRuntimeTurnSettledEnvironment: async ({ provider }) => { + if (provider !== 'codex') { + return null; + } + const installed = await runtimeTurnSettledHookInstaller.install(); + return buildRuntimeTurnSettledEnvironment({ + provider, + spoolRoot: installed.spoolRoot, + }); + }, drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(), getQueueDiagnostics: () => queue.getDiagnostics(), dispose: async () => { diff --git a/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts new file mode 100644 index 00000000..89e4c577 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts @@ -0,0 +1,113 @@ +import { + buildRuntimeTurnSettledSourceId, + type RuntimeTurnSettledProvider, +} from '../../core/domain'; +import type { + MemberWorkSyncHashPort, + RuntimeTurnSettledPayloadNormalization, + RuntimeTurnSettledPayloadNormalizerPort, +} from '../../core/application'; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getString(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value !== 'string') { + continue; + } + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return undefined; +} + +export class CodexNativeTurnSettledPayloadNormalizer + implements RuntimeTurnSettledPayloadNormalizerPort +{ + constructor(private readonly hash: MemberWorkSyncHashPort) {} + + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization { + if (input.provider !== 'codex') { + return { ok: false, reason: 'unsupported_provider' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(input.raw); + } catch { + return { ok: false, reason: 'invalid_json' }; + } + + const payload = asRecord(parsed); + if (!payload) { + return { ok: false, reason: 'payload_not_object' }; + } + + const provider = getString(payload, 'provider'); + if (provider !== 'codex') { + return { ok: false, reason: 'provider_mismatch' }; + } + const source = getString(payload, 'source'); + if (source !== 'agent-teams-orchestrator-codex-native') { + return { ok: false, reason: 'source_mismatch' }; + } + + const eventName = getString(payload, 'eventName', 'event_name'); + const hookEventName = getString(payload, 'hookEventName', 'hook_event_name'); + if (eventName !== 'runtime_turn_settled' && hookEventName !== 'Stop') { + return { ok: false, reason: 'not_turn_settled_event' }; + } + + const sessionId = getString(payload, 'sessionId', 'session_id'); + const teamName = getString(payload, 'teamName', 'team_name'); + const memberName = getString(payload, 'memberName', 'member_name', 'agentName', 'agent_name'); + if (!sessionId) { + return { ok: false, reason: 'missing_session_identity' }; + } + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const payloadHash = this.hash.sha256Hex(input.raw); + const threadId = getString(payload, 'threadId', 'thread_id'); + const turnId = getString(payload, 'turnId', 'turn_id') ?? threadId; + const cwd = getString(payload, 'cwd'); + const agentId = getString(payload, 'agentId', 'agent_id'); + const outcome = getString(payload, 'outcome'); + return { + ok: true, + event: { + schemaVersion: 1, + provider: 'codex', + hookEventName: 'Stop', + payloadHash, + recordedAt: getString(payload, 'recordedAt', 'recorded_at') ?? input.recordedAt, + sourceId: buildRuntimeTurnSettledSourceId({ + provider: 'codex', + sessionId, + turnId, + payloadHash, + }), + sessionId, + ...(turnId ? { turnId } : {}), + ...(cwd ? { cwd } : {}), + teamName, + memberName, + ...(agentId ? { agentId } : {}), + ...(threadId ? { threadId } : {}), + ...(outcome ? { outcome } : {}), + }, + }; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer.ts new file mode 100644 index 00000000..6ebb6e18 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer.ts @@ -0,0 +1,32 @@ +import type { + RuntimeTurnSettledPayloadNormalization, + RuntimeTurnSettledPayloadNormalizerPort, +} from '../../core/application'; +import type { RuntimeTurnSettledProvider } from '../../core/domain'; + +export class CompositeRuntimeTurnSettledPayloadNormalizer + implements RuntimeTurnSettledPayloadNormalizerPort +{ + constructor( + private readonly normalizers: readonly RuntimeTurnSettledPayloadNormalizerPort[] + ) {} + + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization { + let lastUnsupportedReason = 'unsupported_provider'; + for (const normalizer of this.normalizers) { + const result = normalizer.normalize(input); + if (result.ok) { + return result; + } + if (result.reason !== 'unsupported_provider') { + return result; + } + lastUnsupportedReason = result.reason; + } + return { ok: false, reason: lastUnsupportedReason }; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts b/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts new file mode 100644 index 00000000..13a24693 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts @@ -0,0 +1,16 @@ +import type { RuntimeTurnSettledProvider } from '../../core/domain'; + +export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = + 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT'; + +export function buildRuntimeTurnSettledEnvironment(input: { + provider: RuntimeTurnSettledProvider; + spoolRoot: string; +}): Record | null { + if (input.provider !== 'codex') { + return null; + } + return { + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot, + }; +} diff --git a/src/main/index.ts b/src/main/index.ts index ca7e15d1..e28e364f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1254,6 +1254,11 @@ async function initializeServices(): Promise { ? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input) : Promise.resolve(null) ); + teamProvisioningService.setRuntimeTurnSettledEnvironmentProvider((input) => + memberWorkSyncFeature + ? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input) + : Promise.resolve(null) + ); void teamDataService .listTeams() .then(async (teams) => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ca4d17db..096cd4f5 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4324,6 +4324,9 @@ export class TeamProvisioningService { private runtimeTurnSettledHookSettingsProvider: | ((input: { provider: RuntimeTurnSettledProvider }) => Promise | null>) | null = null; + private runtimeTurnSettledEnvironmentProvider: + | ((input: { provider: RuntimeTurnSettledProvider }) => Promise | null>) + | null = null; private readonly stoppedTeamOpenCodeRuntimeCleanupInFlight = new Map>(); private readonly cleanedStoppedTeamOpenCodeRuntimeLanes = new Set(); private crossTeamSender: @@ -4395,6 +4398,16 @@ export class TeamProvisioningService { this.runtimeTurnSettledHookSettingsProvider = provider; } + setRuntimeTurnSettledEnvironmentProvider( + provider: + | ((input: { + provider: RuntimeTurnSettledProvider; + }) => Promise | null>) + | null + ): void { + this.runtimeTurnSettledEnvironmentProvider = provider; + } + private async buildRuntimeTurnSettledHookSettingsArgs( providerId: TeamProviderId ): Promise { @@ -4415,6 +4428,25 @@ export class TeamProvisioningService { } } + private async buildRuntimeTurnSettledEnvironment( + providerId: TeamProviderId + ): Promise> { + if (providerId !== 'codex' || !this.runtimeTurnSettledEnvironmentProvider) { + return {}; + } + + try { + return (await this.runtimeTurnSettledEnvironmentProvider({ provider: 'codex' })) ?? {}; + } catch (error) { + logger.warn( + `Failed to build member work sync runtime turn-settled environment: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return {}; + } + } + private async readRuntimeProviderLaunchFacts(params: { claudePath: string; cwd: string; @@ -22816,6 +22848,7 @@ export class TeamProvisioningService { }); const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId]; const providerEnv = providerEnvResult.env; + Object.assign(providerEnv, await this.buildRuntimeTurnSettledEnvironment(resolvedProviderId)); const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); if (controlApiBaseUrl) { diff --git a/test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts b/test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts new file mode 100644 index 00000000..e581e683 --- /dev/null +++ b/test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { CodexNativeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer'; +import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter'; + +describe('CodexNativeTurnSettledPayloadNormalizer', () => { + it('normalizes orchestrator-native Codex turn-settled payloads', () => { + const normalizer = new CodexNativeTurnSettledPayloadNormalizer(new NodeHashAdapter()); + + const result = normalizer.normalize({ + provider: 'codex', + raw: JSON.stringify({ + schemaVersion: 1, + provider: 'codex', + source: 'agent-teams-orchestrator-codex-native', + eventName: 'runtime_turn_settled', + hookEventName: 'Stop', + sessionId: 'ses-1', + threadId: 'thread-1', + agentId: 'jack@team-a', + agentName: 'jack', + teamName: 'team-a', + cwd: '/tmp/project', + outcome: 'success', + recordedAt: '2026-04-29T12:00:00.000Z', + }), + recordedAt: '2026-04-29T12:00:01.000Z', + }); + + expect(result).toEqual({ + ok: true, + event: expect.objectContaining({ + provider: 'codex', + hookEventName: 'Stop', + sessionId: 'ses-1', + turnId: 'thread-1', + threadId: 'thread-1', + teamName: 'team-a', + memberName: 'jack', + agentId: 'jack@team-a', + cwd: '/tmp/project', + outcome: 'success', + recordedAt: '2026-04-29T12:00:00.000Z', + }), + }); + }); + + it('rejects Codex payloads without durable team/member identity', () => { + const normalizer = new CodexNativeTurnSettledPayloadNormalizer(new NodeHashAdapter()); + + expect( + normalizer.normalize({ + provider: 'codex', + raw: JSON.stringify({ + provider: 'codex', + source: 'agent-teams-orchestrator-codex-native', + eventName: 'runtime_turn_settled', + sessionId: 'ses-1', + }), + recordedAt: '2026-04-29T12:00:01.000Z', + }) + ).toEqual({ ok: false, reason: 'missing_team_member_identity' }); + }); +}); diff --git a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts index 702bb0fb..81af0c74 100644 --- a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts +++ b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts @@ -74,4 +74,63 @@ describe('TeamRuntimeTurnSettledTargetResolver', () => { }) ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); }); + + it('resolves Codex native turn-settled payloads from durable team/member identity', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => { + throw new Error('codex path should not scan attributed files'); + }), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [{ name: 'Jack', providerId: 'codex' }], + }) satisfies TeamConfig), + }, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + memberLogsFinder: { + listAttributedMemberFiles: vi.fn(async () => []), + }, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'codex', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + teamName: 'team-a', + memberName: 'jack', + }) + ).resolves.toEqual({ ok: true, teamName: 'team-a', memberName: 'jack' }); + }); + + it('rejects Codex native events for non-Codex teammates', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => []), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [{ name: 'Jack', providerId: 'anthropic' }], + }) satisfies TeamConfig), + }, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'codex', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + teamName: 'team-a', + memberName: 'jack', + }) + ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); + }); }); diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 1605d580..c31cad0d 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -8,6 +8,7 @@ import { createMemberWorkSyncFeature, resolveMemberWorkSyncNudgeSideEffectsEnabled, } from '@features/member-work-sync/main'; +import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment'; const tempRoots: string[] = []; @@ -114,4 +115,31 @@ describe('createMemberWorkSyncFeature composition', () => { await feature.dispose(); } }); + + it('builds Codex turn-settled environment without requiring nudge side effects', async () => { + const root = makeTempRoot(); + const feature = createMemberWorkSyncFeature({ + teamsBasePath: root, + configReader: {} as never, + taskReader: {} as never, + kanbanManager: {} as never, + membersMetaStore: {} as never, + nudgeSideEffectsEnabled: false, + }); + + try { + const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' }); + expect(env).toEqual({ + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( + root, + '.member-work-sync/runtime-hooks' + ), + }); + await expect( + fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) + ).resolves.toMatchObject({ mode: expect.any(Number) }); + } finally { + await feature.dispose(); + } + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index e19e0b9b..ec69b507 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -149,6 +149,8 @@ const REQUIRED_MOCK_AGENT_TEAMS_TOOLS = [ 'lead_briefing', 'member_briefing', 'message_send', + 'member_work_sync_report', + 'member_work_sync_status', 'process_list', 'process_register', 'process_stop', @@ -1954,6 +1956,20 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.env.ANTHROPIC_API_KEY).toBe('real-key'); }); + it('adds member-work-sync turn-settled spool env for Codex provisioning', async () => { + const svc = new TeamProvisioningService(); + svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => + provider === 'codex' + ? { AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/runtime-hooks' } + : null + ); + + const result = await (svc as any).buildProvisioningEnv('codex'); + + expect(result.authSource).toBe('codex_runtime'); + expect(result.env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBe('/tmp/runtime-hooks'); + }); + it('allows help-env resolution to continue even when provisioning env warns', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ From 3ce183a2290e93f21b1acf1685a2ede1cc96776d Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 20:55:40 +0300 Subject: [PATCH 40/74] fix(member-work-sync): pass codex turn settled env to mixed teams --- .../services/team/TeamProvisioningService.ts | 33 ++++++++++++++++++ .../TeamProvisioningServicePrepare.test.ts | 34 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 096cd4f5..1293c686 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4447,6 +4447,25 @@ export class TeamProvisioningService { } } + private async buildRuntimeTurnSettledEnvironmentForMembers( + primaryProviderId: TeamProviderId | undefined, + memberSpecs: TeamCreateRequest['members'] + ): Promise> { + const resolvedPrimaryProviderId = resolveTeamProviderId(primaryProviderId); + const needsCodexTurnSettledEnv = memberSpecs.some((member) => { + const memberProviderId = resolveTeamProviderId( + normalizeTeamMemberProviderId(member.providerId) ?? resolvedPrimaryProviderId + ); + return memberProviderId === 'codex'; + }); + + if (!needsCodexTurnSettledEnv) { + return {}; + } + + return this.buildRuntimeTurnSettledEnvironment('codex'); + } + private async readRuntimeProviderLaunchFacts(params: { claudePath: string; cwd: string; @@ -12861,6 +12880,13 @@ export class TeamProvisioningService { leadProviderId: request.providerId, members: materializedMemberSpecs, }); + Object.assign( + shellEnv, + await this.buildRuntimeTurnSettledEnvironmentForMembers( + request.providerId, + allEffectiveMemberSpecs + ) + ); const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs); const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name)); const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => @@ -13916,6 +13942,13 @@ export class TeamProvisioningService { leadProviderId: request.providerId, members: materializedMemberSpecs, }); + Object.assign( + shellEnv, + await this.buildRuntimeTurnSettledEnvironmentForMembers( + request.providerId, + allEffectiveMemberSpecs + ) + ); const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs); const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name)); const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index ec69b507..3631dfae 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1970,6 +1970,40 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(result.env.AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT).toBe('/tmp/runtime-hooks'); }); + it('adds Codex turn-settled env when Codex is only a secondary member provider', async () => { + const svc = new TeamProvisioningService(); + svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => + provider === 'codex' + ? { AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/runtime-hooks' } + : null + ); + + const result = await (svc as any).buildRuntimeTurnSettledEnvironmentForMembers('anthropic', [ + { name: 'alice', providerId: 'anthropic' }, + { name: 'jack', providerId: 'codex' }, + ]); + + expect(result).toEqual({ + AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/runtime-hooks', + }); + }); + + it('does not add Codex turn-settled env when no member uses Codex', async () => { + const svc = new TeamProvisioningService(); + const provider = vi.fn(async () => ({ + AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/runtime-hooks', + })); + svc.setRuntimeTurnSettledEnvironmentProvider(provider); + + const result = await (svc as any).buildRuntimeTurnSettledEnvironmentForMembers('anthropic', [ + { name: 'alice', providerId: 'anthropic' }, + { name: 'bob', providerId: 'gemini' }, + ]); + + expect(result).toEqual({}); + expect(provider).not.toHaveBeenCalled(); + }); + it('allows help-env resolution to continue even when provisioning env warns', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ From 55ffb185c3ecdb473d073e47d33bbbaa3c43f380 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 21:12:17 +0300 Subject: [PATCH 41/74] fix(member-work-sync): reclaim stale turn settled payloads --- .../FileRuntimeTurnSettledEventStore.ts | 39 ++++++++++++++- .../FileRuntimeTurnSettledEventStore.test.ts | 48 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts index fdf0bd87..bd85fc94 100644 --- a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts +++ b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts @@ -13,12 +13,14 @@ import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPath const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024; const DEFAULT_PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000; const DEFAULT_PROCESSED_RETENTION_COUNT = 1000; +const DEFAULT_PROCESSING_STALE_MS = 5 * 60 * 1000; export interface FileRuntimeTurnSettledEventStoreDeps { paths: RuntimeTurnSettledSpoolPaths; maxPayloadBytes?: number; processedRetentionMs?: number; processedRetentionCount?: number; + processingStaleMs?: number; now?: () => Date; } @@ -47,6 +49,7 @@ export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEvent private readonly maxPayloadBytes: number; private readonly processedRetentionMs: number; private readonly processedRetentionCount: number; + private readonly processingStaleMs: number; private readonly now: () => Date; constructor(private readonly deps: FileRuntimeTurnSettledEventStoreDeps) { @@ -54,11 +57,13 @@ export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEvent this.processedRetentionMs = deps.processedRetentionMs ?? DEFAULT_PROCESSED_RETENTION_MS; this.processedRetentionCount = deps.processedRetentionCount ?? DEFAULT_PROCESSED_RETENTION_COUNT; + this.processingStaleMs = deps.processingStaleMs ?? DEFAULT_PROCESSING_STALE_MS; this.now = deps.now ?? (() => new Date()); } async claimPending(limit: number): Promise { await this.ensureDirectories(); + await this.recoverStaleProcessingPayloads(); const entries = await readdir(this.deps.paths.getIncomingDir(), { withFileTypes: true }).catch( () => [] @@ -149,6 +154,35 @@ export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEvent ]); } + private async recoverStaleProcessingPayloads(): Promise { + const cutoff = this.now().getTime() - this.processingStaleMs; + const entries = await readdir(this.deps.paths.getProcessingDir(), { + withFileTypes: true, + }).catch(() => []); + + await Promise.allSettled( + entries + .filter( + (entry) => + entry.isFile() && + !entry.name.startsWith('.') && + !entry.name.endsWith('.meta.json') + ) + .map(async (entry) => { + const processingPath = path.join(this.deps.paths.getProcessingDir(), entry.name); + const fileStat = await stat(processingPath).catch(() => null); + if (!fileStat?.isFile() || fileStat.mtimeMs > cutoff) { + return; + } + + await moveFileBestEffort( + processingPath, + path.join(this.deps.paths.getIncomingDir(), entry.name) + ); + }) + ); + } + private async quarantineIncoming( incomingPath: string, fileName: string, @@ -185,7 +219,10 @@ export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEvent ); await Promise.allSettled( - toRemove.flatMap((file) => [rm(file.filePath, { force: true }), rm(buildMetaFilePath(file.filePath), { force: true })]) + toRemove.flatMap((file) => [ + rm(file.filePath, { force: true }), + rm(buildMetaFilePath(file.filePath), { force: true }), + ]) ); } } diff --git a/test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts b/test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts index 052982d9..b349d399 100644 --- a/test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts +++ b/test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts @@ -86,4 +86,52 @@ describe('FileRuntimeTurnSettledEventStore', () => { }; expect(meta).toMatchObject({ outcome: 'enqueued', teamName: 'team-a' }); }); + + it('reclaims stale processing payloads before claiming pending events', async () => { + const paths = await makePaths(); + await fs.mkdir(paths.getProcessingDir(), { recursive: true }); + const filePath = path.join(paths.getProcessingDir(), '20260429-1.codex.json'); + await fs.writeFile(filePath, '{"eventName":"runtime_turn_settled"}', 'utf8'); + await fs.utimes( + filePath, + new Date('2026-04-29T11:00:00.000Z'), + new Date('2026-04-29T11:00:00.000Z') + ); + const store = new FileRuntimeTurnSettledEventStore({ + paths, + now: () => new Date('2026-04-29T12:00:00.000Z'), + processingStaleMs: 60_000, + }); + + const claimed = await store.claimPending(10); + + expect(claimed).toHaveLength(1); + expect(claimed[0]).toMatchObject({ + fileName: '20260429-1.codex.json', + provider: 'codex', + raw: '{"eventName":"runtime_turn_settled"}', + }); + }); + + it('does not reclaim fresh processing payloads from an active drain', async () => { + const paths = await makePaths(); + await fs.mkdir(paths.getProcessingDir(), { recursive: true }); + const filePath = path.join(paths.getProcessingDir(), '20260429-1.codex.json'); + await fs.writeFile(filePath, '{"eventName":"runtime_turn_settled"}', 'utf8'); + await fs.utimes( + filePath, + new Date('2026-04-29T11:59:45.000Z'), + new Date('2026-04-29T11:59:45.000Z') + ); + const store = new FileRuntimeTurnSettledEventStore({ + paths, + now: () => new Date('2026-04-29T12:00:00.000Z'), + processingStaleMs: 60_000, + }); + + const claimed = await store.claimPending(10); + + expect(claimed).toHaveLength(0); + await expect(fs.stat(filePath)).resolves.toMatchObject({ isFile: expect.any(Function) }); + }); }); From 27a38034cca07644c6b0372bfc36c877e5fd71b7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 21:18:48 +0300 Subject: [PATCH 42/74] fix(member-work-sync): infer codex settled env from model --- .../services/team/TeamProvisioningService.ts | 9 ++++++--- .../TeamProvisioningServicePrepare.test.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1293c686..cb13b9bb 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4453,10 +4453,13 @@ export class TeamProvisioningService { ): Promise> { const resolvedPrimaryProviderId = resolveTeamProviderId(primaryProviderId); const needsCodexTurnSettledEnv = memberSpecs.some((member) => { - const memberProviderId = resolveTeamProviderId( - normalizeTeamMemberProviderId(member.providerId) ?? resolvedPrimaryProviderId + const configuredProviderId = normalizeTeamMemberProviderId(member.providerId); + const inferredProviderId = inferTeamProviderIdFromModel(member.model); + return ( + resolvedPrimaryProviderId === 'codex' || + configuredProviderId === 'codex' || + inferredProviderId === 'codex' ); - return memberProviderId === 'codex'; }); if (!needsCodexTurnSettledEnv) { diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 3631dfae..82ef140b 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1988,6 +1988,24 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); }); + it('adds Codex turn-settled env when a secondary member infers Codex from model', async () => { + const svc = new TeamProvisioningService(); + svc.setRuntimeTurnSettledEnvironmentProvider(async ({ provider }) => + provider === 'codex' + ? { AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/runtime-hooks' } + : null + ); + + const result = await (svc as any).buildRuntimeTurnSettledEnvironmentForMembers('anthropic', [ + { name: 'alice', providerId: 'anthropic' }, + { name: 'jack', model: 'gpt-5.4' }, + ]); + + expect(result).toEqual({ + AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT: '/tmp/runtime-hooks', + }); + }); + it('does not add Codex turn-settled env when no member uses Codex', async () => { const svc = new TeamProvisioningService(); const provider = vi.fn(async () => ({ From 7fa71019b2a5fe78d4cb713abc5f7cfe50646396 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 22:42:53 +0300 Subject: [PATCH 43/74] fix: launch dev mcp server via node --- .../services/team/TeamMcpConfigBuilder.ts | 65 +++++++++++++++---- .../team/TeamMcpConfigBuilder.test.ts | 44 ++++++++----- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index f4d7ebd4..21ec5c63 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -75,13 +75,24 @@ function getSourceServerEntry(): string { return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts'); } -function getWorkspaceTsxBinCandidates(): string[] { +function getWorkspaceTsxPackageJsonCandidates(): string[] { return [ - path.join(getWorkspaceMcpServerDir(), 'node_modules', '.bin', 'tsx'), - path.join(getWorkspaceRoot(), 'node_modules', '.bin', 'tsx'), + path.join(getWorkspaceMcpServerDir(), 'node_modules', 'tsx', 'package.json'), + path.join(getWorkspaceRoot(), 'node_modules', 'tsx', 'package.json'), ]; } +function resolvePackageBin( + packageJsonPath: string, + binName: string, + packageJsonRaw: string +): string | null { + const packageJson = JSON.parse(packageJsonRaw) as { bin?: string | Record }; + const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.[binName]; + if (!bin) return null; + return path.resolve(path.dirname(packageJsonPath), bin); +} + async function pathExists(targetPath: string): Promise { try { await fs.promises.access(targetPath, fs.constants.F_OK); @@ -91,6 +102,40 @@ async function pathExists(targetPath: string): Promise { } } +async function resolveWorkspaceTsxCli(checked: string[]): Promise { + for (const packageJsonPath of getWorkspaceTsxPackageJsonCandidates()) { + checked.push(packageJsonPath); + if (!(await pathExists(packageJsonPath))) { + continue; + } + + try { + const tsxCli = resolvePackageBin( + packageJsonPath, + 'tsx', + await fs.promises.readFile(packageJsonPath, 'utf8') + ); + if (!tsxCli) { + logger.warn(`tsx package has no bin.tsx entry at ${packageJsonPath}`); + continue; + } + + checked.push(tsxCli); + if (await pathExists(tsxCli)) { + return tsxCli; + } + } catch (error) { + logger.warn( + `Failed to resolve tsx CLI from ${packageJsonPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + return null; +} + function shouldRetryMcpConfigRemoval(error: NodeJS.ErrnoException): boolean { return error.code === 'EPERM' || error.code === 'EBUSY'; } @@ -236,14 +281,12 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise { const sourceEntry = getSourceServerEntry(); checked.push(sourceEntry); if (await pathExists(sourceEntry)) { - for (const tsxBin of getWorkspaceTsxBinCandidates()) { - checked.push(tsxBin); - if (await pathExists(tsxBin)) { - return { - command: tsxBin, - args: [sourceEntry], - }; - } + const tsxCli = await resolveWorkspaceTsxCli(checked); + if (tsxCli) { + return { + command: await resolveNodePath(), + args: [tsxCli, sourceEntry], + }; } } diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 273b2c82..01b50283 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -80,7 +80,10 @@ describe('TeamMcpConfigBuilder', () => { ): { command?: string; args?: string[]; env?: Record } | undefined { const raw = fs.readFileSync(configPath, 'utf8'); const parsed = JSON.parse(raw) as { - mcpServers?: Record }>; + mcpServers?: Record< + string, + { command?: string; args?: string[]; env?: Record } + >; }; return parsed.mcpServers?.['agent-teams']; } @@ -93,12 +96,13 @@ describe('TeamMcpConfigBuilder', () => { expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); } - function expectTsxEntry( + function expectNodeTsxSourceEntry( server: { command?: string; args?: string[] } | undefined, - entry: string + tsxCli: string, + sourceEntry: string ): void { - expect(server?.args).toEqual([entry]); - expect(server?.command).toMatch(/[\\/]tsx(?:\.cmd)?$/); + expect(server?.args).toEqual([tsxCli, sourceEntry]); + expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); } function getBuiltWorkspaceEntry(): string { @@ -109,8 +113,12 @@ describe('TeamMcpConfigBuilder', () => { return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'); } - function getWorkspaceTsxBin(): string { - return path.join(process.cwd(), 'mcp-server', 'node_modules', '.bin', 'tsx'); + function getWorkspaceTsxPackageJson(): string { + return path.join(process.cwd(), 'mcp-server', 'node_modules', 'tsx', 'package.json'); + } + + function getWorkspaceTsxCli(): string { + return path.join(process.cwd(), 'mcp-server', 'node_modules', 'tsx', 'dist', 'cli.mjs'); } function mockPathExists(existingPaths: string[], options: { strict?: boolean } = {}): void { @@ -138,14 +146,16 @@ describe('TeamMcpConfigBuilder', () => { function mockSourceWorkspaceEntryAvailable(): { sourceEntry: string; - tsxBin: string; + tsxPackageJson: string; + tsxCli: string; builtEntry: string; } { const sourceEntry = getSourceWorkspaceEntry(); - const tsxBin = getWorkspaceTsxBin(); + const tsxPackageJson = getWorkspaceTsxPackageJson(); + const tsxCli = getWorkspaceTsxCli(); const builtEntry = getBuiltWorkspaceEntry(); - mockPathExists([sourceEntry, tsxBin, builtEntry], { strict: true }); - return { sourceEntry, tsxBin, builtEntry }; + mockPathExists([sourceEntry, tsxPackageJson, tsxCli, builtEntry], { strict: true }); + return { sourceEntry, tsxPackageJson, tsxCli, builtEntry }; } function mockBuiltWorkspaceEntryAvailable(): string { @@ -225,7 +235,7 @@ describe('TeamMcpConfigBuilder', () => { }); it('prefers the source workspace MCP entry in dev mode when available', async () => { - const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); + const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const builder = new TeamMcpConfigBuilder(); const configPath = await builder.writeConfigFile(); @@ -237,7 +247,7 @@ describe('TeamMcpConfigBuilder', () => { }; const server = parsed.mcpServers?.['agent-teams']; - expectTsxEntry(server, sourceEntry); + expectNodeTsxSourceEntry(server, tsxCli, sourceEntry); }); it('pins the MCP controller to the active Claude base path', async () => { @@ -355,7 +365,7 @@ describe('TeamMcpConfigBuilder', () => { }); it('generated agent-teams server ignores same-named user MCP entry', async () => { - const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); + const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); createdDirs.push(homeDir); mockHomeDir = homeDir; @@ -381,7 +391,7 @@ describe('TeamMcpConfigBuilder', () => { mcpServers: Record; }; - expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry); + expectNodeTsxSourceEntry(parsed.mcpServers['agent-teams'], tsxCli, sourceEntry); }); it('passes the configured Claude root to the MCP server', async () => { @@ -626,7 +636,7 @@ describe('TeamMcpConfigBuilder', () => { }); it('packaged mode falls back to the source workspace MCP entry when resourcesPath bundle is missing', async () => { - const { sourceEntry } = mockSourceWorkspaceEntryAvailable(); + const { sourceEntry, tsxCli } = mockSourceWorkspaceEntryAvailable(); setPackagedMode(true, '6.0.0'); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); createdDirs.push(resourcesDir); @@ -636,6 +646,6 @@ describe('TeamMcpConfigBuilder', () => { const configPath = await builder.writeConfigFile(); createdPaths.push(configPath); - expectTsxEntry(readGeneratedServer(configPath), sourceEntry); + expectNodeTsxSourceEntry(readGeneratedServer(configPath), tsxCli, sourceEntry); }); }); From e634895a081f85ac6b2a4af50a18734405908f4d Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 23:28:23 +0300 Subject: [PATCH 44/74] chore(runtime): bump orchestrator lock to 0.0.14 --- runtime.lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index 56440b59..82581de0 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.13", - "sourceRef": "v0.0.13", + "version": "0.0.14", + "sourceRef": "v0.0.14", "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.13.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.14.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.13.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.14.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.13.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.14.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.13.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.14.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } From 7d08205a2350e3ade90b980e168a91a54e188305 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 00:05:58 +0300 Subject: [PATCH 45/74] fix(ci): clean member work sync validation --- .../member-work-sync/contracts/index.ts | 2 +- .../member-work-sync/contracts/types.ts | 4 +- .../MemberWorkSyncDiagnosticsReader.ts | 3 +- .../MemberWorkSyncMetricsReader.ts | 3 +- .../MemberWorkSyncNudgeOutboxPlanner.ts | 3 +- ...mberWorkSyncPendingReportIntentReplayer.ts | 3 +- .../application/MemberWorkSyncReconciler.ts | 4 +- .../application/MemberWorkSyncReporter.ts | 20 ++-- .../core/application/index.ts | 4 +- .../core/application/ports.ts | 4 +- .../core/domain/ActionableWorkAgenda.ts | 11 +- .../core/domain/AgendaFingerprint.ts | 4 +- .../domain/MemberWorkSyncReportValidator.ts | 3 +- .../member-work-sync/core/domain/index.ts | 2 +- .../input/registerMemberWorkSyncIpc.ts | 3 +- .../TeamInboxMemberWorkSyncNudgeSink.ts | 1 + .../TeamRuntimeTurnSettledTargetResolver.ts | 22 ++-- .../adapters/output/TeamTaskAgendaSource.ts | 23 ++-- .../TeamTaskStallJournalWorkSyncCooldown.ts | 6 +- .../createMemberWorkSyncFeature.ts | 107 +++++++++--------- src/features/member-work-sync/main/index.ts | 5 +- .../ClaudeStopHookPayloadNormalizer.ts | 1 + ...CodexNativeTurnSettledPayloadNormalizer.ts | 5 +- .../FileRuntimeTurnSettledEventStore.ts | 11 +- .../HmacMemberWorkSyncReportTokenAdapter.ts | 2 +- .../infrastructure/JsonMemberWorkSyncStore.ts | 24 ++-- .../MemberWorkSyncEventQueue.ts | 2 +- .../MemberWorkSyncToolActivityBusySignal.ts | 1 - ...llRuntimeTurnSettledHookScriptInstaller.ts | 3 +- .../renderer/hooks/useMemberWorkSyncStatus.ts | 5 +- .../renderer/ui/MemberWorkSyncBadge.tsx | 17 ++- .../renderer/ui/MemberWorkSyncDetails.tsx | 10 +- .../renderer/ui/MemberWorkSyncStatusPanel.tsx | 9 +- src/main/http/index.ts | 2 +- src/main/index.ts | 18 +-- .../services/team/TaskChangeLedgerReader.ts | 2 +- .../services/team/TeamProvisioningService.ts | 2 +- .../team/members/MemberDetailDialog.tsx | 2 +- src/shared/types/api.ts | 4 +- .../RuntimeTurnSettledHookSettings.test.ts | 6 +- 40 files changed, 194 insertions(+), 169 deletions(-) diff --git a/src/features/member-work-sync/contracts/index.ts b/src/features/member-work-sync/contracts/index.ts index 14aed138..06a75889 100644 --- a/src/features/member-work-sync/contracts/index.ts +++ b/src/features/member-work-sync/contracts/index.ts @@ -1,2 +1,2 @@ export * from './ipc'; -export * from './types'; +export type * from './types'; diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index c6daa966..f7ddc2c0 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -221,11 +221,11 @@ export interface MemberWorkSyncNudgePayload { source: 'member-work-sync'; actionMode: 'do'; text: string; - taskRefs: Array<{ + taskRefs: { taskId: string; displayId: string; teamName: string; - }>; + }[]; } export interface MemberWorkSyncOutboxItem { diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts index e907568a..7e70b52f 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncDiagnosticsReader.ts @@ -1,5 +1,6 @@ -import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; + +import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; export class MemberWorkSyncDiagnosticsReader { diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts index 702cb81a..513a17b0 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncMetricsReader.ts @@ -1,5 +1,6 @@ -import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts'; import { assessMemberWorkSyncPhase2Readiness } from '../domain'; + +import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics { diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts index 8281a87a..a042d0bf 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts @@ -1,5 +1,6 @@ -import type { MemberWorkSyncStatus } from '../../contracts'; import { buildMemberWorkSyncOutboxEnsureInput } from '../domain'; + +import type { MemberWorkSyncStatus } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; export interface MemberWorkSyncNudgeOutboxPlanResult { diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncPendingReportIntentReplayer.ts b/src/features/member-work-sync/core/application/MemberWorkSyncPendingReportIntentReplayer.ts index bf6ae37c..8c6bce96 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncPendingReportIntentReplayer.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncPendingReportIntentReplayer.ts @@ -1,5 +1,6 @@ -import type { MemberWorkSyncReportIntentStatus } from '../../contracts'; import { MemberWorkSyncReporter } from './MemberWorkSyncReporter'; + +import type { MemberWorkSyncReportIntentStatus } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; export interface MemberWorkSyncPendingReportReplaySummary { diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index 3ff508a1..3043f90b 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -4,9 +4,11 @@ import { decideMemberWorkSyncStatus, formatAgendaFingerprint, } from '../domain'; + +import { MemberWorkSyncNudgeOutboxPlanner } from './MemberWorkSyncNudgeOutboxPlanner'; + import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports'; -import { MemberWorkSyncNudgeOutboxPlanner } from './MemberWorkSyncNudgeOutboxPlanner'; export interface MemberWorkSyncReconcileContext { reconciledBy?: 'request' | 'queue'; diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts index de2bd018..2076adf4 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -1,15 +1,17 @@ +import { validateMemberWorkSyncReport } from '../domain'; + +import { + attachMemberWorkSyncReportToken, + finalizeMemberWorkSyncAgenda, + MemberWorkSyncReconciler, +} from './MemberWorkSyncReconciler'; + import type { MemberWorkSyncReport, MemberWorkSyncReportRequest, MemberWorkSyncReportResult, MemberWorkSyncStatus, } from '../../contracts'; -import { validateMemberWorkSyncReport } from '../domain'; -import { - attachMemberWorkSyncReportToken, - finalizeMemberWorkSyncAgenda, - MemberWorkSyncReconciler, -} from './MemberWorkSyncReconciler'; import type { MemberWorkSyncUseCaseDeps } from './ports'; export class MemberWorkSyncReporter { @@ -28,7 +30,11 @@ export class MemberWorkSyncReporter { : true; if (!teamActive) { const status = await this.reconciler.execute(request); - const rejectedStatus = await this.recordRejectedReport(status, request, 'team_runtime_inactive'); + const rejectedStatus = await this.recordRejectedReport( + status, + request, + 'team_runtime_inactive' + ); return { accepted: false, code: 'team_runtime_inactive', diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 585e60eb..74021af2 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -5,6 +5,6 @@ export * from './MemberWorkSyncNudgeOutboxPlanner'; export * from './MemberWorkSyncPendingReportIntentReplayer'; export * from './MemberWorkSyncReconciler'; export * from './MemberWorkSyncReporter'; -export * from './ports'; +export type * from './ports'; export * from './RuntimeTurnSettledIngestor'; -export * from './RuntimeTurnSettledPorts'; +export type * from './RuntimeTurnSettledPorts'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 8cb0eeeb..4ac83fec 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -1,7 +1,5 @@ import type { MemberWorkSyncAgenda, - MemberWorkSyncTeamMetrics, - MemberWorkSyncProviderId, MemberWorkSyncOutboxClaimInput, MemberWorkSyncOutboxCountRecentDeliveredInput, MemberWorkSyncOutboxEnsureInput, @@ -10,11 +8,13 @@ import type { MemberWorkSyncOutboxMarkDeliveredInput, MemberWorkSyncOutboxMarkFailedInput, MemberWorkSyncOutboxMarkSupersededInput, + MemberWorkSyncProviderId, MemberWorkSyncReport, MemberWorkSyncReportIntent, MemberWorkSyncReportIntentStatus, MemberWorkSyncReportRequest, MemberWorkSyncStatus, + MemberWorkSyncTeamMetrics, } from '../../contracts'; export interface MemberWorkSyncClockPort { diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index 4904f6d7..c29a018c 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -1,8 +1,3 @@ -import type { - MemberWorkSyncActionableWorkItem, - MemberWorkSyncAgenda, - MemberWorkSyncProviderId, -} from '../../contracts'; import { buildAgendaFingerprintPayload, canonicalizeAgendaFingerprintPayload, @@ -11,6 +6,12 @@ import { import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle'; import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; +import type { + MemberWorkSyncActionableWorkItem, + MemberWorkSyncAgenda, + MemberWorkSyncProviderId, +} from '../../contracts'; + export interface MemberWorkSyncTaskLike { id: string; displayId?: string; diff --git a/src/features/member-work-sync/core/domain/AgendaFingerprint.ts b/src/features/member-work-sync/core/domain/AgendaFingerprint.ts index 736aa636..8a66a7bd 100644 --- a/src/features/member-work-sync/core/domain/AgendaFingerprint.ts +++ b/src/features/member-work-sync/core/domain/AgendaFingerprint.ts @@ -19,7 +19,7 @@ export interface AgendaFingerprintPayload { version: 1; teamName: string; memberName: string; - items: Array<{ + items: { taskId: string; displayId?: string; subject: string; @@ -27,7 +27,7 @@ export interface AgendaFingerprintPayload { assignee: string; priority: string; evidence: MemberWorkSyncActionableWorkItem['evidence']; - }>; + }[]; sourceRevision?: string; } diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts index 29b34c2a..9aa26760 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts @@ -1,9 +1,10 @@ +import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; + import type { MemberWorkSyncAgenda, MemberWorkSyncReportRequest, MemberWorkSyncReportState, } from '../../contracts'; -import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName'; export interface MemberWorkSyncReportValidation { ok: boolean; diff --git a/src/features/member-work-sync/core/domain/index.ts b/src/features/member-work-sync/core/domain/index.ts index 4495aaef..4ac73ffa 100644 --- a/src/features/member-work-sync/core/domain/index.ts +++ b/src/features/member-work-sync/core/domain/index.ts @@ -2,8 +2,8 @@ export * from './ActionableWorkAgenda'; export * from './AgendaFingerprint'; export * from './currentReviewCycle'; export * from './memberName'; -export * from './MemberWorkSyncPhase2Readiness'; export * from './MemberWorkSyncNudge'; +export * from './MemberWorkSyncPhase2Readiness'; export * from './MemberWorkSyncReportValidator'; export * from './RuntimeTurnSettledEvent'; export * from './RuntimeTurnSettledProvider'; 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 821f1ee2..57b9463f 100644 --- a/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts +++ b/src/features/member-work-sync/main/adapters/input/registerMemberWorkSyncIpc.ts @@ -1,3 +1,5 @@ +import { createLogger } from '@shared/utils/logger'; + import { MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, @@ -9,7 +11,6 @@ import { type MemberWorkSyncStatusRequest, type MemberWorkSyncTeamMetrics, } from '../../../contracts'; -import { createLogger } from '@shared/utils/logger'; import type { MemberWorkSyncFeatureFacade } from '../../composition/createMemberWorkSyncFeature'; import type { IpcMain } from 'electron'; diff --git a/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts b/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts index fc2e9778..15cd418d 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink.ts @@ -1,5 +1,6 @@ import { TeamInboxReader } from '@main/services/team/TeamInboxReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; + import type { MemberWorkSyncInboxNudgePort } from '../../../core/application'; export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudgePort { diff --git a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts index 1cd69e24..0358ef8c 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts @@ -1,16 +1,16 @@ -import path from 'path'; - -import { isReservedMemberName, normalizeMemberName } from '../../../core/domain'; -import type { - RuntimeTurnSettledTargetResolution, - RuntimeTurnSettledTargetResolverPort, -} from '../../../core/application'; import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import { inferTeamProviderIdFromModel, normalizeOptionalTeamProviderId, } from '@shared/utils/teamProvider'; +import path from 'path'; +import { isReservedMemberName, normalizeMemberName } from '../../../core/domain'; + +import type { + RuntimeTurnSettledTargetResolution, + RuntimeTurnSettledTargetResolverPort, +} from '../../../core/application'; import type { RuntimeTurnSettledEvent } from '../../../core/domain'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; @@ -69,9 +69,7 @@ function normalizePath(value: string | undefined): string | null { return path.resolve(value.trim()); } -export class TeamRuntimeTurnSettledTargetResolver - implements RuntimeTurnSettledTargetResolverPort -{ +export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledTargetResolverPort { private readonly memberLogsFinder: AttributedMemberFileSource; private readonly maxTeamsToScan: number; @@ -99,12 +97,12 @@ export class TeamRuntimeTurnSettledTargetResolver .filter((team) => !team.deletedAt) .slice(0, this.maxTeamsToScan); - const candidates: Array<{ + const candidates: { teamName: string; memberName: string; exactPath: boolean; mtimeMs: number; - }> = []; + }[] = []; for (const team of teams) { const attributedFiles = await this.memberLogsFinder diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts index 8293f4cd..e8d79580 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskAgendaSource.ts @@ -1,19 +1,20 @@ -import { - buildActionableWorkAgenda, - isReservedMemberName, - normalizeMemberName, - type MemberWorkSyncMemberLike, -} from '../../../core/domain'; -import type { - MemberWorkSyncAgendaSourcePort, - MemberWorkSyncAgendaSourceResult, - MemberWorkSyncHashPort, -} from '../../../core/application'; import { inferTeamProviderIdFromModel, normalizeOptionalTeamProviderId, } from '@shared/utils/teamProvider'; +import { + buildActionableWorkAgenda, + isReservedMemberName, + type MemberWorkSyncMemberLike, + normalizeMemberName, +} from '../../../core/domain'; + +import type { + MemberWorkSyncAgendaSourcePort, + MemberWorkSyncAgendaSourceResult, + MemberWorkSyncHashPort, +} from '../../../core/application'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; diff --git a/src/features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown.ts b/src/features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown.ts index 2b0ad90d..699c97b6 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamTaskStallJournalWorkSyncCooldown.ts @@ -1,5 +1,6 @@ import { readFile } from 'fs/promises'; import { join } from 'path'; + import type { MemberWorkSyncWatchdogCooldownPort } from '../../../core/application'; const DEFAULT_WATCHDOG_COOLDOWN_MS = 10 * 60_000; @@ -54,10 +55,7 @@ export class TeamTaskStallJournalWorkSyncCooldown implements MemberWorkSyncWatch return alertedAt != null && now - alertedAt <= this.cooldownMs; }); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return false; - } - return true; + return (error as NodeJS.ErrnoException).code !== 'ENOENT'; } } } diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 5fb88636..4c7539f7 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -1,3 +1,43 @@ +import { + MemberWorkSyncDiagnosticsReader, + MemberWorkSyncMetricsReader, + MemberWorkSyncNudgeDispatcher, + type MemberWorkSyncNudgeDispatchSummary, + MemberWorkSyncPendingReportIntentReplayer, + type MemberWorkSyncPendingReportReplaySummary, + type MemberWorkSyncReconcileContext, + MemberWorkSyncReconciler, + MemberWorkSyncReporter, + type RuntimeTurnSettledDrainSummary, + RuntimeTurnSettledIngestor, + type RuntimeTurnSettledTargetResolverPort, +} from '../../core/application'; +import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; +import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; +import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver'; +import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; +import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown'; +import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer'; +import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer'; +import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer'; +import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; +import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; +import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; +import { + MemberWorkSyncEventQueue, + type MemberWorkSyncQueueDiagnostics, +} from '../infrastructure/MemberWorkSyncEventQueue'; +import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWorkSyncNudgeDispatchScheduler'; +import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; +import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal'; +import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; +import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler'; +import { buildRuntimeTurnSettledEnvironment } from '../infrastructure/runtimeTurnSettledEnvironment'; +import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings'; +import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths'; +import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller'; +import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; + import type { MemberWorkSyncMetricsRequest, MemberWorkSyncReportRequest, @@ -6,53 +46,13 @@ import type { MemberWorkSyncStatusRequest, MemberWorkSyncTeamMetrics, } from '../../contracts'; -import { - MemberWorkSyncDiagnosticsReader, - MemberWorkSyncMetricsReader, - MemberWorkSyncNudgeDispatcher, - type MemberWorkSyncNudgeDispatchSummary, - MemberWorkSyncPendingReportIntentReplayer, - type MemberWorkSyncPendingReportReplaySummary, - MemberWorkSyncReconciler, - MemberWorkSyncReporter, - type MemberWorkSyncReconcileContext, - RuntimeTurnSettledIngestor, - type RuntimeTurnSettledDrainSummary, - type RuntimeTurnSettledTargetResolverPort, -} from '../../core/application'; -import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; -import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; -import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown'; -import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; -import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver'; -import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer'; -import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer'; -import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer'; -import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; -import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; -import { - MemberWorkSyncEventQueue, - type MemberWorkSyncQueueDiagnostics, -} from '../infrastructure/MemberWorkSyncEventQueue'; -import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; -import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; -import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWorkSyncNudgeDispatchScheduler'; -import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal'; -import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; -import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler'; -import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths'; -import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller'; -import { buildRuntimeTurnSettledEnvironment } from '../infrastructure/runtimeTurnSettledEnvironment'; -import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings'; -import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; - +import type { MemberWorkSyncLoggerPort } from '../../core/application'; +import type { RuntimeTurnSettledProvider } from '../../core/domain'; import type { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager'; import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore'; import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; import type { TeamChangeEvent } from '@shared/types'; -import type { MemberWorkSyncLoggerPort } from '../../core/application'; -import type { RuntimeTurnSettledProvider } from '../../core/domain'; export const MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV = 'CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED'; @@ -208,17 +208,18 @@ 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 = + nudgeSideEffectsEnabled && 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(); diff --git a/src/features/member-work-sync/main/index.ts b/src/features/member-work-sync/main/index.ts index b29c8658..cc2f4c52 100644 --- a/src/features/member-work-sync/main/index.ts +++ b/src/features/member-work-sync/main/index.ts @@ -1,10 +1,11 @@ +export type { RuntimeTurnSettledProvider } from '../core/domain'; export { registerMemberWorkSyncIpc, removeMemberWorkSyncIpc, } from './adapters/input/registerMemberWorkSyncIpc'; +export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature'; export { - MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, createMemberWorkSyncFeature, + MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, resolveMemberWorkSyncNudgeSideEffectsEnabled, } from './composition/createMemberWorkSyncFeature'; -export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature'; diff --git a/src/features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer.ts index 225b8268..864bcaff 100644 --- a/src/features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer.ts +++ b/src/features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer.ts @@ -2,6 +2,7 @@ import { buildRuntimeTurnSettledSourceId, type RuntimeTurnSettledProvider, } from '../../core/domain'; + import type { MemberWorkSyncHashPort, RuntimeTurnSettledPayloadNormalization, diff --git a/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts index 89e4c577..6926c0cc 100644 --- a/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts +++ b/src/features/member-work-sync/main/infrastructure/CodexNativeTurnSettledPayloadNormalizer.ts @@ -2,6 +2,7 @@ import { buildRuntimeTurnSettledSourceId, type RuntimeTurnSettledProvider, } from '../../core/domain'; + import type { MemberWorkSyncHashPort, RuntimeTurnSettledPayloadNormalization, @@ -28,9 +29,7 @@ function getString(record: Record, ...keys: string[]): string | return undefined; } -export class CodexNativeTurnSettledPayloadNormalizer - implements RuntimeTurnSettledPayloadNormalizerPort -{ +export class CodexNativeTurnSettledPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort { constructor(private readonly hash: MemberWorkSyncHashPort) {} normalize(input: { diff --git a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts index bd85fc94..29676835 100644 --- a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts +++ b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts @@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promis import path from 'path'; import { isRuntimeTurnSettledProvider } from '../../core/domain'; + import type { RuntimeTurnSettledClaimedPayload, RuntimeTurnSettledEventStorePort, @@ -131,7 +132,11 @@ export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEvent ): Promise { const processedPath = path.join(this.deps.paths.getProcessedDir(), payload.fileName); await moveFileBestEffort(payload.filePath, processedPath); - await writeFile(buildMetaFilePath(processedPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + await writeFile( + buildMetaFilePath(processedPath), + `${JSON.stringify(result, null, 2)}\n`, + 'utf8' + ); await this.cleanupDirectory(this.deps.paths.getProcessedDir()); } @@ -164,9 +169,7 @@ export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEvent entries .filter( (entry) => - entry.isFile() && - !entry.name.startsWith('.') && - !entry.name.endsWith('.meta.json') + entry.isFile() && !entry.name.startsWith('.') && !entry.name.endsWith('.meta.json') ) .map(async (entry) => { const processingPath = path.join(this.deps.paths.getProcessingDir(), entry.name); diff --git a/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts b/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts index ad211162..c8d5bc23 100644 --- a/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts +++ b/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts @@ -6,8 +6,8 @@ import { atomicWriteAsync } from '@main/utils/atomicWrite'; import type { MemberWorkSyncReportTokenCreateInput, MemberWorkSyncReportTokenPort, - MemberWorkSyncReportTokenVerifyInput, MemberWorkSyncReportTokenVerification, + MemberWorkSyncReportTokenVerifyInput, } from '../../core/application'; import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index 3a5b7c86..1b702de8 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -1,8 +1,10 @@ +import { withFileLock } from '@main/services/team/fileLock'; import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { createHash } from 'crypto'; import { mkdir, readFile, rename } from 'fs/promises'; -import { withFileLock } from '@main/services/team/fileLock'; +import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain'; + import type { MemberWorkSyncMetricEvent, MemberWorkSyncOutboxClaimInput, @@ -19,7 +21,6 @@ import type { MemberWorkSyncStatusState, MemberWorkSyncTeamMetrics, } from '../../contracts'; -import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain'; import type { MemberWorkSyncOutboxStorePort, MemberWorkSyncReportStorePort, @@ -288,13 +289,10 @@ export class JsonMemberWorkSyncStore stateCounts, actionableItemCount, wouldNudgeCount: recentEvents.filter((event) => event.kind === 'would_nudge').length, - fingerprintChangeCount: recentEvents.filter( - (event) => event.kind === 'fingerprint_changed' - ).length, - reportAcceptedCount: recentEvents.filter((event) => event.kind === 'report_accepted') - .length, - reportRejectedCount: recentEvents.filter((event) => event.kind === 'report_rejected') + fingerprintChangeCount: recentEvents.filter((event) => event.kind === 'fingerprint_changed') .length, + reportAcceptedCount: recentEvents.filter((event) => event.kind === 'report_accepted').length, + reportRejectedCount: recentEvents.filter((event) => event.kind === 'report_rejected').length, recentEvents, }; return { @@ -349,7 +347,7 @@ export class JsonMemberWorkSyncStore await withFileLock(this.paths.getPendingReportsPath(teamName), async () => { const existing = await this.readPendingFile(teamName); const current = existing.intents[id]; - if (!current || current.status !== 'pending') { + if (current?.status !== 'pending') { return; } existing.intents[id] = { @@ -471,7 +469,7 @@ export class JsonMemberWorkSyncStore async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise { await this.updateOutboxItem(input.teamName, input.id, (current) => { - if (!current || current.attemptGeneration !== input.attemptGeneration) { + if (current?.attemptGeneration !== input.attemptGeneration) { return current; } const next: MemberWorkSyncOutboxItem = { @@ -501,7 +499,7 @@ export class JsonMemberWorkSyncStore async markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise { await this.updateOutboxItem(input.teamName, input.id, (current) => { - if (!current || current.attemptGeneration !== input.attemptGeneration) { + if (current?.attemptGeneration !== input.attemptGeneration) { return current; } const next: MemberWorkSyncOutboxItem = { @@ -589,9 +587,7 @@ export class JsonMemberWorkSyncStore private async updateOutboxItem( teamName: string, id: string, - updater: ( - current: MemberWorkSyncOutboxItem | undefined - ) => MemberWorkSyncOutboxItem | undefined + updater: (current: MemberWorkSyncOutboxItem | undefined) => MemberWorkSyncOutboxItem | undefined ): Promise { await this.enqueue(teamName, async () => { await withFileLock(this.paths.getOutboxPath(teamName), async () => { diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts index f2eded26..87c963a5 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts @@ -1,5 +1,5 @@ -import type { MemberWorkSyncReconcileContext } from '../../core/application/MemberWorkSyncReconciler'; import type { MemberWorkSyncLoggerPort } from '../../core/application'; +import type { MemberWorkSyncReconcileContext } from '../../core/application/MemberWorkSyncReconciler'; export type MemberWorkSyncTriggerReason = | 'startup_scan' diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal.ts index 37acd736..45512013 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncToolActivityBusySignal.ts @@ -1,5 +1,4 @@ import type { MemberWorkSyncBusySignalPort } from '../../core/application'; - import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; const DEFAULT_TOOL_ACTIVITY_BUSY_GRACE_MS = 90_000; diff --git a/src/features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts b/src/features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts index fea189bb..7db73b1f 100644 --- a/src/features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts +++ b/src/features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller.ts @@ -1,6 +1,6 @@ import fs from 'fs/promises'; -import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths'; +import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths'; const HOOK_SCRIPT_CONTENT = `#!/bin/sh set +e @@ -58,6 +58,7 @@ export class ShellRuntimeTurnSettledHookScriptInstaller { const scriptPath = this.paths.getHookScriptPath(); await fs.writeFile(scriptPath, HOOK_SCRIPT_CONTENT, 'utf8'); + // eslint-disable-next-line sonarjs/file-permissions -- the hook script must be executable on POSIX hosts. await fs.chmod(scriptPath, 0o755); return { diff --git a/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts b/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts index 3c6a71ef..dc862626 100644 --- a/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts +++ b/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts @@ -2,12 +2,13 @@ import { useEffect, useState } from 'react'; import { api } from '@renderer/api'; -import type { MemberWorkSyncStatus } from '../../contracts'; import { - toMemberWorkSyncStatusViewModel, type MemberWorkSyncStatusViewModel, + toMemberWorkSyncStatusViewModel, } from '../adapters/memberWorkSyncStatusViewModel'; +import type { MemberWorkSyncStatus } from '../../contracts'; + export interface UseMemberWorkSyncStatusOptions { teamName?: string | null; memberName?: string | null; diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx index 000b8bc9..5eeb0870 100644 --- a/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx @@ -1,18 +1,19 @@ import { Badge } from '@renderer/components/ui/badge'; import { cn } from '@renderer/lib/utils'; -import type React from 'react'; -import type { MemberWorkSyncStatus } from '../../contracts'; import { - toMemberWorkSyncStatusViewModel, type MemberWorkSyncStatusViewModel, + toMemberWorkSyncStatusViewModel, } from '../adapters/memberWorkSyncStatusViewModel'; -interface MemberWorkSyncBadgeProps { +import type { MemberWorkSyncStatus } from '../../contracts'; +import type React from 'react'; + +type MemberWorkSyncBadgeProps = Readonly<{ status?: MemberWorkSyncStatus | null; viewModel?: MemberWorkSyncStatusViewModel; className?: string; -} +}>; const toneClassName: Record = { neutral: 'border-[var(--color-border)] text-[var(--color-text-muted)]', @@ -32,7 +33,11 @@ export function MemberWorkSyncBadge({ return ( {resolved.label} diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx index 81921c5d..5f21e65f 100644 --- a/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx @@ -1,12 +1,14 @@ -import { MemberWorkSyncBadge } from './MemberWorkSyncBadge'; -import type { MemberWorkSyncStatus } from '../../contracts'; import { toMemberWorkSyncStatusViewModel } from '../adapters/memberWorkSyncStatusViewModel'; + +import { MemberWorkSyncBadge } from './MemberWorkSyncBadge'; + +import type { MemberWorkSyncStatus } from '../../contracts'; import type React from 'react'; -interface MemberWorkSyncDetailsProps { +type MemberWorkSyncDetailsProps = Readonly<{ status: MemberWorkSyncStatus | null; showDiagnostics?: boolean; -} +}>; function shortFingerprint(fingerprint?: string): string { if (!fingerprint) { diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx index 6a4d1392..919fcfa3 100644 --- a/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx @@ -1,15 +1,16 @@ -import type React from 'react'; +import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus'; import { MemberWorkSyncBadge } from './MemberWorkSyncBadge'; import { MemberWorkSyncDetails } from './MemberWorkSyncDetails'; -import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus'; -interface MemberWorkSyncStatusPanelProps { +import type React from 'react'; + +type MemberWorkSyncStatusPanelProps = Readonly<{ teamName: string; memberName: string; enabled?: boolean; showDiagnostics?: boolean; -} +}>; export function MemberWorkSyncStatusPanel({ teamName, diff --git a/src/main/http/index.ts b/src/main/http/index.ts index 09f7c0c6..d7c27aa7 100644 --- a/src/main/http/index.ts +++ b/src/main/http/index.ts @@ -9,7 +9,6 @@ import { type RecentProjectsFeatureFacade, registerRecentProjectsHttp, } from '@features/recent-projects/main'; -import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main'; import { createLogger } from '@shared/utils/logger'; import { registerConfigRoutes } from './config'; @@ -36,6 +35,7 @@ import type { import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager'; import type { TeamDataService } from '../services/team/TeamDataService'; import type { TeamProvisioningService } from '../services/team/TeamProvisioningService'; +import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main'; import type { FastifyInstance } from 'fastify'; const logger = createLogger('HTTP:routes'); diff --git a/src/main/index.ts b/src/main/index.ts index e28e364f..ebc0dc2e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,18 +30,18 @@ import { type CodexModelCatalogFeatureFacade, createCodexModelCatalogFeature, } from '@features/codex-model-catalog/main'; -import { - createRecentProjectsFeature, - type RecentProjectsFeatureFacade, - registerRecentProjectsIpc, - removeRecentProjectsIpc, -} from '@features/recent-projects/main'; import { createMemberWorkSyncFeature, type MemberWorkSyncFeatureFacade, registerMemberWorkSyncIpc, removeMemberWorkSyncIpc, } from '@features/member-work-sync/main'; +import { + createRecentProjectsFeature, + type RecentProjectsFeatureFacade, + registerRecentProjectsIpc, + removeRecentProjectsIpc, +} from '@features/recent-projects/main'; import { createRuntimeProviderManagementFeature, registerRuntimeProviderManagementIpc, @@ -179,19 +179,19 @@ import { SshConnectionManager, TaskBoundaryParser, TeamDataService, + TeamKanbanManager, TeamLogSourceTracker, TeammateToolTracker, TeamMemberLogsFinder, + TeamMembersMetaStore, TeamProvisioningService, TeamRuntimeAdapterRegistry, - TeamKanbanManager, - TeamMembersMetaStore, + TeamTaskReader, TeamTaskStallJournal, TeamTaskStallMonitor, TeamTaskStallNotifier, TeamTaskStallPolicy, TeamTaskStallSnapshotSource, - TeamTaskReader, UpdaterService, } from './services'; diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 644f58ca..3670c56d 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -1152,7 +1152,7 @@ export class TaskChangeLedgerReader { string, { event: LedgerEvent; index: number; rank: number } >(); - const passthrough: Array<{ event: LedgerEvent; index: number }> = []; + const passthrough: { event: LedgerEvent; index: number }[] = []; events.forEach((event, index) => { const sourceImportKey = this.sourceImportKeyForEvent(event); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index cb13b9bb..9d55b060 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2,7 +2,6 @@ import { resolveAnthropicFastMode, resolveAnthropicRuntimeSelection, } from '@features/anthropic-runtime-profile/main'; -import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/core/domain'; import { buildCodexFastModeArgs, resolveCodexFastMode, @@ -239,6 +238,7 @@ import type { TeamRuntimePrepareResult, TeamRuntimeStopInput, } from './runtime'; +import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main'; /** * Kill a team CLI process using SIGKILL (uncatchable). diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 1b3c5166..eed114d4 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,12 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; +import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useStore } from '@renderer/store'; import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; -import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; import { buildMemberLaunchDiagnosticsPayload, getMemberLaunchDiagnosticsErrorMessage, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index c55a31bf..b76e809a 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -95,8 +95,6 @@ import type { TerminalAPI } from './terminal'; import type { TmuxAPI } from './tmux'; import type { WaterfallData } from './visualization'; import type { CodexAccountElectronApi } from '@features/codex-account/contracts'; -import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; -import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; import type { MemberWorkSyncMetricsRequest, MemberWorkSyncReportRequest, @@ -105,6 +103,8 @@ import type { MemberWorkSyncStatusRequest, MemberWorkSyncTeamMetrics, } from '@features/member-work-sync/contracts'; +import type { RecentProjectsElectronApi } from '@features/recent-projects/contracts'; +import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; import type { ConversationGroup, FileChangeEvent, diff --git a/test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts b/test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts index d6461ab8..2ad9a67c 100644 --- a/test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts +++ b/test/features/member-work-sync/main/RuntimeTurnSettledHookSettings.test.ts @@ -79,8 +79,10 @@ describe('runtime turn-settled hook settings', () => { expect(fs.existsSync(paths.getProcessedDir())).toBe(true); expect(fs.existsSync(paths.getInvalidDir())).toBe(true); - const stat = fs.statSync(result.scriptPath); - expect(stat.mode & 0o111).toBeGreaterThan(0); + if (process.platform !== 'win32') { + const stat = fs.statSync(result.scriptPath); + expect(stat.mode & 0o111).toBeGreaterThan(0); + } expect(fs.readFileSync(result.scriptPath, 'utf8')).toContain('dd bs="$max_bytes"'); }); }); From bab17e91ba0ea07cdf66efb2e297bd5d4f897ebc Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 30 Apr 2026 11:32:41 +0300 Subject: [PATCH 46/74] test: launch dev mcp preflight through tsx bin --- .../TeamProvisioningServicePrepare.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 82ef140b..cfc6d44a 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -104,6 +104,30 @@ import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } { const workspaceRoot = process.cwd(); + const sourceEntry = path.join(workspaceRoot, 'mcp-server', 'src', 'index.ts'); + const tsxPackageJson = path.join( + workspaceRoot, + 'mcp-server', + 'node_modules', + 'tsx', + 'package.json' + ); + if (fs.existsSync(sourceEntry) && fs.existsSync(tsxPackageJson)) { + const packageJson = JSON.parse(fs.readFileSync(tsxPackageJson, 'utf8')) as { + bin?: string | Record; + }; + const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.tsx; + if (bin) { + const tsxCli = path.resolve(path.dirname(tsxPackageJson), bin); + if (fs.existsSync(tsxCli)) { + return { + command: process.execPath, + args: [tsxCli, sourceEntry], + }; + } + } + } + const distEntry = path.join(workspaceRoot, 'mcp-server', 'dist', 'index.js'); if (fs.existsSync(distEntry)) { return { From 62a1b0e865fee03956423f57fa5dfb4ac8d57626 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 30 Apr 2026 22:21:29 +0300 Subject: [PATCH 47/74] fix: show effective Anthropic API key status --- .../runtime/ProviderConnectionService.ts | 30 ++++++ .../runtime/ProviderRuntimeSettingsDialog.tsx | 32 +++--- .../runtime/providerConnectionUi.ts | 28 +++++ .../runtime/ProviderConnectionService.test.ts | 101 ++++++++++++++++++ .../ProviderRuntimeSettingsDialog.test.ts | 48 +++++++++ .../runtime/providerConnectionUi.test.ts | 30 ++++++ 6 files changed, 253 insertions(+), 16 deletions(-) diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index de23f11c..2d7bf80f 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -423,6 +423,10 @@ export class ProviderConnectionService { connection: await this.getConnectionInfo(provider.providerId), }; + if (provider.providerId === 'anthropic') { + return this.enrichAnthropicProviderStatus(withConnection); + } + if (provider.providerId !== 'codex') { return withConnection; } @@ -480,6 +484,32 @@ export class ProviderConnectionService { } } + private enrichAnthropicProviderStatus(provider: CliProviderStatus): CliProviderStatus { + const connection = provider.connection; + if (connection?.configuredAuthMode !== 'api_key') { + return provider; + } + + if (connection.apiKeyConfigured) { + return { + ...provider, + authenticated: true, + authMethod: 'api_key', + verificationState: + provider.verificationState === 'error' ? provider.verificationState : 'verified', + statusMessage: 'Connected via API key', + }; + } + + return { + ...provider, + authenticated: false, + authMethod: null, + verificationState: provider.verificationState === 'error' ? 'error' : 'unknown', + statusMessage: 'API key mode is selected, but no Anthropic API credential is available yet.', + }; + } + async enrichProviderStatuses(providers: CliProviderStatus[]): Promise { return Promise.all(providers.map((provider) => this.enrichProviderStatus(provider))); } diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 8e6db338..108e2155 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -265,6 +265,19 @@ function getConnectionAlert(provider: CliProviderStatus): string | null { return null; } +function getProviderUsageLabel(provider: CliProviderStatus): string { + if ( + provider.providerId === 'anthropic' && + provider.connection?.configuredAuthMode === 'api_key' + ) { + return provider.connection.apiKeyConfigured ? 'Using API key' : 'API key required'; + } + + return provider.authenticated + ? `Using ${formatProviderAuthMethodLabelForProvider(provider.providerId, provider.authMethod)}` + : provider.statusMessage || 'Not connected'; +} + function getCodexAccountPanelHint( provider: CliProviderStatus | null, configuredAuthMode: CliProviderAuthMode | undefined @@ -818,10 +831,7 @@ export const ProviderRuntimeSettingsDialog = ({ let connectionStatusLabel: string | null = null; if (selectedProvider) { if (!hideConnectionMethodMeta && selectedProvider.authenticated) { - connectionStatusLabel = `Using ${formatProviderAuthMethodLabelForProvider( - selectedProvider.providerId, - selectedProvider.authMethod - )}`; + connectionStatusLabel = getProviderUsageLabel(selectedProvider); } else if (!hideConnectionMethodMeta) { connectionStatusLabel = 'Not connected'; } @@ -1129,22 +1139,12 @@ export const ProviderRuntimeSettingsDialog = ({ className="text-xs" style={{ color: getProviderStatusColor( - selectedProvider.authenticated - ? `Using ${formatProviderAuthMethodLabelForProvider( - selectedProvider.providerId, - selectedProvider.authMethod - )}` - : selectedProvider.statusMessage || 'Not connected', + getProviderUsageLabel(selectedProvider), selectedProvider.authenticated ), }} > - {selectedProvider.authenticated - ? `Using ${formatProviderAuthMethodLabelForProvider( - selectedProvider.providerId, - selectedProvider.authMethod - )}` - : selectedProvider.statusMessage || 'Not connected'} + {getProviderUsageLabel(selectedProvider)} {managedRuntimeSummary && !hideConnectionMethodMeta ? ( diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index 6735d371..cf011708 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -119,6 +119,22 @@ function getCodexApiKeyAvailabilitySummary(provider: CliProviderStatus): string return provider.connection.apiKeySourceLabel ?? 'API key is configured'; } +function isAnthropicApiKeyModeReady(provider: CliProviderStatus): boolean { + return ( + provider.providerId === 'anthropic' && + provider.connection?.configuredAuthMode === 'api_key' && + provider.connection.apiKeyConfigured === true + ); +} + +function isAnthropicApiKeyModeMissingCredential(provider: CliProviderStatus): boolean { + return ( + provider.providerId === 'anthropic' && + provider.connection?.configuredAuthMode === 'api_key' && + provider.connection.apiKeyConfigured !== true + ); +} + function getCodexMissingManagedAccountStatus(provider: CliProviderStatus): string | null { if (provider.providerId !== 'codex') { return null; @@ -241,6 +257,14 @@ export function formatProviderStatusText(provider: CliProviderStatus): string { return provider.statusMessage ?? 'Unavailable in current runtime'; } + if (isAnthropicApiKeyModeReady(provider)) { + return 'Connected via API key'; + } + + if (isAnthropicApiKeyModeMissingCredential(provider)) { + return 'API key mode selected, but no API key is configured'; + } + if (provider.authenticated) { return `Connected via ${formatProviderAuthMethodLabelForProvider( provider.providerId, @@ -292,6 +316,10 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin return null; } + if (isAnthropicApiKeyModeReady(provider)) { + return provider.connection?.apiKeySourceLabel ?? 'API key is configured'; + } + if ( provider.providerId === 'anthropic' && provider.connection.apiKeySource === 'stored' && diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 2b801564..e4e7292b 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -211,6 +211,107 @@ describe('ProviderConnectionService', () => { }); }); + it('surfaces stored Anthropic API key mode as the effective provider auth status', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + const status = await service.enrichProviderStatus({ + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + statusMessage: 'Connected via Anthropic subscription', + models: ['claude-sonnet-4-6'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { mcp: 'unsupported', skills: 'unsupported', plugins: 'unsupported' }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + } as never); + + expect(status).toMatchObject({ + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + statusMessage: 'Connected via API key', + connection: { + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + }, + }); + }); + + it('does not treat a subscription session as connected when Anthropic API key mode has no key', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never + ); + + const status = await service.enrichProviderStatus({ + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + statusMessage: 'Connected via Anthropic subscription', + models: ['claude-sonnet-4-6'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { mcp: 'unsupported', skills: 'unsupported', plugins: 'unsupported' }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + } as never); + + expect(status).toMatchObject({ + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'API key mode is selected, but no Anthropic API credential is available yet.', + connection: { + configuredAuthMode: 'api_key', + apiKeyConfigured: false, + }, + }); + }); + it('exposes Codex as native-only API-key runtime', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index 71691049..ab3af948 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -577,6 +577,54 @@ describe('ProviderRuntimeSettingsDialog', () => { expect(onRefreshProvider).toHaveBeenCalledWith('anthropic'); }); + it('shows Anthropic API key usage when API key mode is selected even if the local CLI has a subscription session', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + storeState.appConfig.providerConnections.anthropic.authMode = 'api_key'; + storeState.apiKeys = [ + { + id: 'anthropic-key', + envVarName: 'ANTHROPIC_API_KEY', + scope: 'user', + name: 'Anthropic API Key', + maskedValue: 'sk-ant-...', + }, + ]; + + await act(async () => { + root.render( + React.createElement(ProviderRuntimeSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + providers: [ + createAnthropicProvider({ + authenticated: true, + authMethod: 'claude.ai', + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + }), + ], + initialProviderId: 'anthropic', + onSelectBackend: vi.fn(), + onRefreshProvider: vi.fn(() => Promise.resolve(undefined)), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Using API key'); + expect(host.textContent).not.toContain('Using Anthropic subscription'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + host.remove(); + }); + it('accepts and saves a typed Anthropic API key from provider settings', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index 7e63ab0b..a89682c1 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -184,6 +184,36 @@ describe('providerConnectionUi', () => { expect(getProviderConnectionModeSummary(provider)).toBeNull(); }); + it('shows Anthropic API key as the effective connection when API key mode is pinned', () => { + const provider = createAnthropicProvider({ + authenticated: true, + authMethod: 'claude.ai', + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + }); + + expect(formatProviderStatusText(provider)).toBe('Connected via API key'); + expect(getProviderCredentialSummary(provider)).toBe('Stored in app'); + }); + + it('does not describe Anthropic API key mode as subscription connected when the key is missing', () => { + const provider = createAnthropicProvider({ + authenticated: true, + authMethod: 'claude.ai', + configuredAuthMode: 'api_key', + apiKeyConfigured: false, + apiKeySource: null, + apiKeySourceLabel: null, + }); + + expect(formatProviderStatusText(provider)).toBe( + 'API key mode selected, but no API key is configured' + ); + expect(getProviderCredentialSummary(provider)).toBeNull(); + }); + it('shows Anthropic preferred auth summary when a pinned mode is selected but not connected', () => { const provider = createAnthropicProvider({ authenticated: false, From 0af9994c9a9f95d39d025da31b3467fd572e298a Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 00:36:25 +0300 Subject: [PATCH 48/74] feat(member-work-sync): ingest opencode turn-settled events --- ...er-work-sync-opencode-turn-settled-plan.md | 2699 +++++++++++++++++ .../core/domain/RuntimeTurnSettledProvider.ts | 4 +- .../TeamRuntimeTurnSettledTargetResolver.ts | 13 +- .../createMemberWorkSyncFeature.ts | 48 +- src/features/member-work-sync/main/index.ts | 1 + .../FileRuntimeTurnSettledEventStore.ts | 4 +- .../OpenCodeTurnSettledPayloadNormalizer.ts | 123 + .../RuntimeTurnSettledSpoolInitializer.ts | 47 + .../runtimeTurnSettledEnvironment.ts | 5 +- src/main/index.ts | 16 + .../FileRuntimeTurnSettledEventStore.test.ts | 23 + ...enCodeTurnSettledPayloadNormalizer.test.ts | 83 + ...amRuntimeTurnSettledTargetResolver.test.ts | 59 + .../main/createMemberWorkSyncFeature.test.ts | 47 + 14 files changed, 3131 insertions(+), 41 deletions(-) create mode 100644 docs/team-management/member-work-sync-opencode-turn-settled-plan.md create mode 100644 src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts create mode 100644 src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts create mode 100644 test/features/member-work-sync/main/OpenCodeTurnSettledPayloadNormalizer.test.ts 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 new file mode 100644 index 00000000..7a5ef71c --- /dev/null +++ b/docs/team-management/member-work-sync-opencode-turn-settled-plan.md @@ -0,0 +1,2699 @@ +# Member Work Sync OpenCode Turn-Settled Plan + +- **Status:** implemented in `feat/member-work-sync-opencode-turn-settled` +- **Scope:** `member-work-sync`, OpenCode runtime turn-settled signal, OpenCode SSE observer +- **Primary repo:** `claude_team` +- **Secondary repo:** `agent_teams_orchestrator` +- **Feature name:** `member-work-sync` +- **Recommended cut:** provider-neutral runtime turn-settled pipeline with OpenCode SSE adapter + +Implemented verification: + +- `claude_team`: `pnpm exec vitest run test/features/member-work-sync/main/OpenCodeTurnSettledPayloadNormalizer.test.ts test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts` +- `claude_team`: `pnpm typecheck --pretty false` +- `agent_teams_orchestrator`: `bun test src/services/opencode/OpenCodeSseEventStream.test.ts src/services/opencode/OpenCodeTurnSettledObserver.test.ts src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.test.ts src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.test.ts src/services/opencode/OpenCodeSessionBridge.test.ts src/services/opencode/OpenCodeBridgeCommandHandler.test.ts` +- `agent_teams_orchestrator`: `bun run build` +- both repos: `git diff --check` + +--- + +## 1. Summary + +Add OpenCode support to the existing `member-work-sync` runtime turn-settled pipeline. + +The goal is not to make OpenCode "answer better" directly. The goal is to make the app know when an OpenCode teammate turn has settled, so the existing `MemberWorkSyncReconciler` can re-check authoritative work state: + +```text +OpenCode prompt_async accepted +-> app-owned SSE observer watches same session +-> observer returns bounded turn evidence: idle / error / timeout / stream unavailable +-> bridge command also uses existing reconcile/preview evidence +-> OpenCode turn-settled coordinator chooses one final outcome +-> orchestrator writes one durable runtime_turn_settled event to spool +-> claude_team drains event +-> OpenCode normalizer validates payload +-> resolver validates active team/member/provider +-> existing MemberWorkSyncEventQueue enqueues reconcile +-> existing policy decides no-op / status update / future nudge outbox +``` + +Recommended implementation: + +**OpenCode SSE turn-settled observer + bounded bridge-command settlement + error-aware spool event** + +`šŸŽÆ 9 šŸ›”ļø 9 🧠 6`, roughly `850-1250 LOC`. + +Why this is the right cut: + +- OpenCode already exposes reliable-enough SSE lifecycle events on `/event`. +- The current app already has a durable runtime turn-settled spool for Claude and Codex. +- OpenCode does not need user/project plugin config mutation. +- `session.idle` is a wake-up signal, not proof of success. The existing member-work-sync agenda remains authoritative. +- This integrates with watchdog by queueing the same reconcile signal, not by adding a second watchdog. +- The OpenCode bridge command is short-lived, so the observer cannot be a fire-and-forget background task. It must be awaited with a small bounded settlement budget inside the bridge command, then return `timeout` evidence if the turn is still not terminal. +- The observer must collect evidence only. A small emission coordinator writes exactly one spool event after the path has also used existing reconcile/preview evidence. This avoids premature `timeout` events when post-prompt reconcile proves activity. + +--- + +## 2. Evidence From Live Prototype + +Prototype environment: + +- installed OpenCode version: `1.14.19` +- local server: `opencode serve --hostname 127.0.0.1 --port ` +- API used: + - `POST /session` + - `GET /event` + - `POST /session/:id/prompt_async` + - `GET /session/:id/message` + +Observed with `opencode/gpt-5-nano`: + +```text +prompt_async accepted: true +session activity observed after prompt: true +idle observed: true +assistant text: OK +``` + +Observed event shape: + +```text +server.connected +message.updated user +message.part.updated user text +session.status busy +message.updated assistant +message.part.updated step-start/reasoning/text +message.part.delta text +message.part.updated step-finish +session.status idle +session.idle +``` + +Observed with `openai/gpt-5.1-codex-mini`: + +```text +prompt_async accepted: true +session.error observed: true +session.status idle observed after error: true +session.idle observed after error: true +``` + +Important conclusions: + +- The observer must subscribe before `prompt_async`, otherwise fast turns can be missed. +- OpenCode `messageID` must start with `msg`; UUID-only IDs get `400`. +- `session.idle` means "turn ended", not "turn succeeded". +- `session.error` must produce an error outcome, but should still wake member-work-sync to reconcile. +- Both `session.status idle` and `session.idle` can arrive for the same turn, so emission must be idempotent. +- The orchestrator bridge command exits after the command returns. A background observer can be killed before it writes the spool event. This invalidates a pure "start observer and return" design. + +### 2.1 External Research Notes + +Official OpenCode docs confirm the API surface this plan relies on: + +- OpenCode server docs document `opencode serve` and `GET /event` as a server-sent events stream: https://dev.opencode.ai/docs/server/ +- OpenCode SDK docs document `event.subscribe()` and `session.prompt(...)`: https://opencode.ai/docs/sdk/ +- OpenCode plugin docs list `session.idle`, `session.status`, `session.error`, `message.updated`, and `message.part.updated` event types: https://open-code.ai/en/docs/plugins +- The generated OpenCode SDK types define `EventSessionIdle`, `EventSessionStatus`, `EventSessionError`, `EventMessageUpdated`, `EventMessagePartUpdated`, and `GlobalEvent { directory, payload }`: https://github.com/anomalyco/opencode/blob/dev/packages/sdk/js/src/gen/types.gen.ts +- The generated OpenCode SDK types define `/session/{id}/prompt_async` with optional `body.messageID?: string` and `204 Prompt accepted`: https://github.com/anomalyco/opencode/blob/dev/packages/sdk/js/src/gen/types.gen.ts +- Current OpenCode prompt implementation uses `input.messageID ?? MessageID.ascending()` when creating the user message, so a custom `messageID` is a real OpenCode message identity, not opaque metadata: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/prompt.ts +- Current OpenCode `prompt_async` handler schedules `SessionPrompt.prompt(...)` in an async runtime and returns `204` immediately; later failures are logged and published as `session.error`: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +- Current OpenCode `/event` handler sends `server.connected`, heartbeat events every 10 seconds, and no SSE `id` field for replay: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/server/routes/instance/event.ts +- Current OpenCode session status source defines `session.status` with object status and marks `session.idle` as deprecated, while still publishing idle for compatibility: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/status.ts +- Current OpenCode ID schema requires message IDs to start with `msg`: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/id/id.ts +- Current OpenCode message event schemas carry `message.updated.properties.info.role` and `message.part.updated.properties.part.messageID/sessionID`, so the observer can distinguish prompt persistence from assistant/runtime activity: https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/message-v2.ts + +Design impact: + +- The observer should parse both plain events and global events wrapped under `payload`. +- For `/global/event`, the observer should ignore events whose `directory` is known and does not match the session record `projectPath`. +- The terminal state must be session-scoped and post-prompt, not host-scoped. +- Error and idle can both be emitted for the same turn, so error wins over later idle. +- `prompt_async` `204` means "scheduled/accepted by endpoint", not "assistant turn succeeded"; `session.error` after `204` must still be captured. +- `prompt_async` returns before the assistant is done, so the bridge command must wait a bounded amount for `session.status idle` / `session.idle` if it wants a reliable turn-settled file. +- `session.status idle` is the primary terminal event; deprecated `session.idle` is a compatibility fallback. +- A custom `messageID` must be generated per OpenCode prompt attempt and should not be reused as the app-level delivery retry key. +- Heartbeat and `server.connected` events are stream health, not session activity. +- `message.updated user` and user text part events prove only that the prompt was persisted. They are not assistant-turn activity and must not make observer outcome `success`. +- Assistant-turn activity should be limited to `session.status busy`, assistant `message.updated`, assistant-owned `message.part.updated` / `message.part.delta`, or tool/step/reasoning parts associated with assistant messages. +- Because `/event` has no replay IDs, missing a fast event must be handled by reconcile/preview proof rather than by reconnect replay. + +Additional source-audit notes from current docs: + +- OpenCode server docs say `/event` starts with `server.connected`, then bus events. Do not treat `server.connected` as session activity. +- OpenCode server docs expose `/global/event` separately. It is useful as fallback, but only with directory filtering. +- OpenCode SDK docs expose `event.subscribe()` as the official stream abstraction. Our fetch-based reader should stay compatible with the same event shapes, not invent a separate schema. +- OpenCode SDK generated types show `EventSessionStatus.properties.status` as a structured status object in current versions. Runtime captures can still be strings, so support both. +- OpenCode SDK generated types show `GlobalEvent.directory`. That confirms the need to pass `projectPath` into both new turn-settled observer and existing preview observer. + +### 2.2 Deep Review Corrections + +The original version of this plan had two unsafe assumptions. They are fixed below. + +1. **No unbounded background observer in orchestrator CLI.** `OpenCodeBridgeCommandClient` launches `agent_teams_orchestrator runtime opencode-command ...` as a short-lived process. If `runSendMessage()` returns immediately after `prompt_async`, a background SSE observer may be terminated with the process. The implementation must await observer settlement with a bounded timeout, keep the evidence, and only then continue to final emission. +2. **Do not mutate global `promptAsync()` semantics.** Existing OpenCode prompt callers should keep old behavior. Add an opt-in method such as `promptAsyncWithTurnSettled()` or a small wrapper service around `promptAsync()` so only launch/delivery paths that explicitly request turn-settled telemetry get message IDs and bounded observation. +3. **Do not add a second SSE stream for launch unless live evidence requires it.** `runLaunch()` already calls `observePreview()` per prompted member in the concurrent settle phase. For launch v1, derive final turn-settled outcome from existing preview + reconcile summaries instead of starting a second observer. The new observe-around-prompt path is most valuable for delivery prompts. +4. **Normalize `session.status` as object or string.** Current OpenCode SDK types model status as `{ type: "idle" | "busy" | "retry" }`, while older/live shapes can appear as strings. Shared status parsing must accept both, and `OpenCodePreviewObserver` should be updated as part of helper extraction. +5. **Do not let observation outlive the retained host scope accidentally.** `OpenCodeSessionBridge.withSessionHost()` calls `releaseHost()` in `finally`. Observed prompt APIs must either keep observe/prompt/settle inside the retained scope or explicitly hold an observation lease until `waitForSettled()`/`dispose()` completes. +6. **Emit after final local evidence, not directly from observer.** `runSendMessage()` already reconciles after prompt. If the SSE observer times out but reconcile sees new messages/tool calls, the final event should be `success`, not an already-written `timeout`. The observer returns evidence; the coordinator emits once. + +--- + +## 3. Goals + +- Emit OpenCode `runtime_turn_settled` events into the existing durable spool exactly once per observed prompt path. +- Keep OpenCode support provider-specific at the adapter boundary and provider-neutral in `member-work-sync` core. +- Make the observer fail-soft: delivery success still depends on `prompt_async`, but the bridge command waits only a bounded telemetry budget and returns `timeout` evidence rather than hanging. +- Preserve existing OpenCode delivery, watchdog, ledger, MCP readiness, and task-stall semantics. +- Avoid modifying OpenCode user config, project plugins, or profile settings. +- Avoid frontend changes in v1. +- Make live validation possible with cheap models and without long-running model matrix tests. + +--- + +## 4. Non-Goals + +This plan does not: + +- add OpenCode plugin installation; +- add a new MCP tool; +- synthesize replies; +- auto-complete tasks; +- change `TeamTaskStallMonitor` behavior; +- mark messages read; +- change OpenCode prompt text except optional deterministic message IDs; +- rely on model text like "done" as proof; +- expose new UI controls. + +--- + +## 5. Architecture Principles + +### 5.1 Clean Architecture + +Follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`. + +In `claude_team`: + +```text +src/features/member-work-sync/ + core/domain/ + core/application/ + main/adapters/output/ + main/infrastructure/ + main/composition/ +``` + +In `agent_teams_orchestrator`: + +```text +src/services/opencode/ + OpenCodeTurnSettledObserver.ts + OpenCodeRuntimeTurnSettledEmitter.ts +``` + +The boundary is explicit: + +- orchestrator knows OpenCode SSE and session protocol; +- orchestrator writes raw provider event files; +- `claude_team` owns agenda, fingerprint, leases, queue, nudge policy, and watchdog separation. + +### 5.2 SOLID + +- **SRP:** observer watches OpenCode events; coordinator derives final outcome; emitter writes spool files; normalizer validates payload; resolver validates team/member. +- **OCP:** adding OpenCode means adding a normalizer/resolver branch and provider env support, not rewriting `RuntimeTurnSettledIngestor`. +- **LSP:** tests can substitute fake observer, fake emitter, fake resolver. +- **ISP:** ports stay small: `OpenCodeTurnSettledEmitterPort`, `RuntimeTurnSettledPayloadNormalizerPort`, `RuntimeTurnSettledTargetResolverPort`. +- **DIP:** application layer depends on ports, not `fetch`, filesystem, OpenCode client, or Electron. + +### 5.3 Watchdog Separation + +OpenCode turn-settled is a fast wake-up signal: + +```text +"a runtime turn ended, recompute current agenda" +``` + +Task stall watchdog remains semantic and delayed: + +```text +"a task has not had meaningful progress for too long" +``` + +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. + +--- + +## 6. Recommended Design + +### 6.1 Provider Signal Source + +Use OpenCode SSE, not plugin hooks. + +```text +GET /event +``` + +Fallback: + +```text +GET /global/event +``` + +Reasons: + +- no project config mutation; +- no user OpenCode plugin pollution; +- app already has the session record and host URL; +- observer can be started before `prompt_async`; +- compatible with existing `OpenCodePreviewObserver` experience. + +### 6.2 Turn Boundary + +A turn-settled observer starts immediately before the prompt is submitted and is awaited with a bounded settlement budget inside the same bridge command: + +```text +observeTurnSettled(record, context) +-> waitUntilReady(max 500ms) +-> markPromptSubmitting() +-> prompt_async(record, prompt) +-> markPromptAcceptedByEndpoint() +-> waitForSettled(max 8-12s for delivery) +-> existing post-send reconcile / response observation where already present +-> coordinator derives final outcome from observer evidence + reconcile evidence + response proof +-> coordinator emits one success/error/timeout/stream_unavailable event +-> return bridge command result +``` + +Why this is required: + +- the orchestrator OpenCode command is not a long-lived daemon; +- a fire-and-forget observer can be killed when the command exits; +- a bounded wait gives a durable signal without making delivery depend on perfect SSE behavior. +- the observer must enter `submitting` before the HTTP call, not after the accepted response, because fast OpenCode turns can emit message/activity/idle events while `prompt_async` is still in flight. +- `prompt_async` `204` only means the endpoint scheduled the turn. It does not prove the prompt finished or even that model/tool execution succeeded. Later `session.error` still wins unless reconcile/response proof upgrades the outcome. + +No-reply guard: + +- If `noReply === true`, do not emit a runtime turn-settled event. There is no assistant turn to settle. +- The command can still reconcile for delivery bookkeeping, but `member-work-sync` should not treat a no-reply prompt as an agent idle signal. +- Add a test that `noReply` delivery preserves existing behavior and does not enqueue OpenCode work sync. + +Important default: + +```ts +const OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS = 12_000; +const OPENCODE_SEND_TURN_SETTLED_IDLE_TIMEOUT_MS = 2_500; +``` + +These are telemetry budgets. If they expire, delivery can still be accepted and the observer outcome is `timeout`. The emitted outcome can still become `success` if later reconcile/response evidence proves assistant-turn activity. + +Do not let the observer write the spool file directly. It should return evidence: + +```ts +type OpenCodeTurnSettledEvidence = { + readiness: 'connected' | 'fallback' | 'timeout'; + promptLifecycle: 'accepted_by_endpoint' | 'rejected_by_endpoint' | 'unknown'; + outcome: OpenCodeTurnSettledOutcome; + sawAssistantTurnActivity: boolean; + sawError: boolean; + diagnostics: string[]; +}; +``` + +Then the command path derives the final event: + +```text +response observation proves visible/tool reply -> success with diagnostic response_observation_proved_activity +reconcile cursor advanced -> success with diagnostic reconcile_advanced_after_prompt +observer error -> error unless reconcile/response proof shows a later successful turn +observer success -> success +observer idle_without_assistant_activity -> idle_without_assistant_activity unless reconcile/response proof upgrades it +observer timeout + reconcile failed/no activity -> timeout +stream unavailable + reconcile failed/no activity -> stream_unavailable +``` + +This avoids writing a premature `timeout` immediately before existing reconcile proves that the turn actually completed. + +Prompt submission race rule: + +```text +before HTTP prompt_async request -> markPromptSubmitting() +HTTP 204 returned -> markPromptAcceptedByEndpoint() +HTTP rejected/throws -> markPromptRejectedByEndpoint(), dispose observer, do not emit runtime_turn_settled +events seen after submitting are buffered as candidate evidence +candidate evidence becomes valid only after endpoint acceptance +session.error after endpoint acceptance is still a failed turn signal +``` + +This avoids both bad outcomes: + +- missing a very fast turn that finishes while the HTTP request is in flight; +- emitting a turn-settled event for a prompt that OpenCode rejected. + +There are two integration shapes: + +1. **Single prompt wrapper for delivery.** + + ```text + promptAsyncWithTurnSettled() + -> begin observation + -> prompt_async + -> bounded wait + -> return accepted + telemetry outcome + ``` + +2. **Preview-derived launch event.** + + ```text + promptAsync(record) + existing observePreview(record) in concurrent settle phase + existing reconcileSession(record) + coordinator emits turn-settled from preview + reconcile summary + ``` + +Launch should not open a second SSE stream in v1. If live tests prove preview misses too many fast bootstrap turns, add split observe-around-prompt later. + +### 6.3 Event Outcome + +Allowed OpenCode outcomes: + +```ts +export type OpenCodeTurnSettledOutcome = + | 'success' + | 'error' + | 'timeout' + | 'stream_unavailable' + | 'idle_without_assistant_activity'; +``` + +Interpretation: + +- `success`: assistant-turn activity was observed and no `session.error` happened before idle. +- `error`: `session.error` happened before idle or stream termination. +- `timeout`: observer connected but did not see a terminal event within budget. +- `stream_unavailable`: SSE could not be opened. +- `idle_without_assistant_activity`: an idle signal was seen after prompt submission, but no assistant-turn session/message/tool activity was observed. This still wakes reconcile, but diagnostics should flag weak correlation. + +All outcomes can still enqueue reconcile, because even an error can leave board state changed through earlier tool calls. + +### 6.4 Idempotency + +Use one deterministic source identity: + +```text +runtime-turn-settled:opencode:::no-transcript: +``` + +The file store already dedupes by source ID after normalization. + +OpenCode emission coordinator must also avoid duplicate writes for the same prompt path: + +```ts +let emitted = false; + +async function emitOnce(outcome: OpenCodeTurnSettledOutcome) { + if (emitted) return; + emitted = true; + await emitter.emit(buildEvent({ outcome })); +} +``` + +### 6.5 Message ID + +If we add explicit `messageID` to `prompt_async`, it must be OpenCode-compatible: + +```ts +function buildOpenCodePromptMessageId(input: { + teamId: string; + memberName: string; + sessionId: string; + purpose: string; + nonce: string; +}): string { + const hash = createHash('sha256') + .update(JSON.stringify(input)) + .digest('hex') + .slice(0, 32); + return `msg_${hash}`; +} +``` + +In v1, `turnId` can be generated by our observer even if `messageID` is not passed to OpenCode. However, passing a compatible `messageID` improves correlation and should be done if it does not break existing tests. + +Important source-backed constraint: + +- OpenCode SDK generated types expose `SessionPromptAsyncData.body.messageID?: string` for `/session/{id}/prompt_async`. +- OpenCode session prompt implementation uses `input.messageID ?? MessageID.ascending()` as the user message ID. + +Therefore `messageID` is not just telemetry metadata. It becomes the OpenCode user-message identifier. Treat it as a per-prompt attempt ID, not as a long-lived delivery retry key. + +Rules: + +- Generate a fresh OpenCode prompt `messageID` for each accepted `prompt_async` attempt. +- Keep Agent Teams delivery idempotency in the existing `messageId` / ledger / relay fields, not by reusing OpenCode `messageID`. +- Do not retry a failed `prompt_async` with the same text and same `messageID` unless a targeted live test proves OpenCode dedupes that exact case safely. +- Store the generated OpenCode prompt `messageID` in diagnostics and turn-settled payload as `runtimePromptMessageId` for correlation. +- If OpenCode rejects the custom ID shape, fail the prompt normally in v1. Do not silently resend without `messageID`, because that can create duplicate user messages. A later compatibility fallback can be added only with explicit single-send guarantees. + +--- + +## 7. Cross-Repo Contract + +### 7.1 Spool Environment + +Existing env variable: + +```ts +export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = + 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT'; +``` + +Current `claude_team` behavior only returns this env for `codex`. Extend it to OpenCode: + +```ts +export function buildRuntimeTurnSettledEnvironment(input: { + provider: RuntimeTurnSettledProvider; + spoolRoot: string; +}): Record | null { + if (input.provider !== 'codex' && input.provider !== 'opencode') { + return null; + } + + return { + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot, + }; +} +``` + +### 7.2 OpenCode Runtime Event Payload + +Add this orchestrator payload: + +```ts +export interface OpenCodeRuntimeTurnSettledEvent { + schemaVersion: 1; + provider: 'opencode'; + eventName: 'runtime_turn_settled'; + hookEventName: 'Stop'; + source: 'agent-teams-orchestrator-opencode'; + recordedAt: string; + sessionId: string; + turnId: string; + teamName: string; + memberName: string; + cwd?: string; + runtimePid?: number; + outcome: + | 'success' + | 'error' + | 'timeout' + | 'stream_unavailable' + | 'idle_without_assistant_activity'; + detail?: string; + diagnostics?: string[]; +} +``` + +Why keep `hookEventName: 'Stop'`: + +- `RuntimeTurnSettledEvent` currently models provider "turn settled" as a Stop-like lifecycle signal. +- This avoids changing core semantics. +- It does not imply OpenCode has a real Claude Stop hook. + +If desired later, rename the domain field to `eventKind`. Do not do that in this patch. + +### 7.3 File Naming + +Use the same atomic spool pattern as Codex: + +```text +/incoming/-.turn-settled.-.opencode.json +``` + +Do not append to shared JSONL. + +--- + +## 8. Orchestrator Implementation Plan + +Repo: + +```text +/Users/belief/dev/projects/claude/_worktrees/agent_teams_orchestrator_opencode_turn_settled +``` + +### 8.1 Add OpenCode Runtime Turn-Settled Emitter + +File: + +```text +src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.ts +``` + +Example: + +```ts +import { mkdir, rename, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = + 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT'; + +export type OpenCodeRuntimeTurnSettledOutcome = + | 'success' + | 'error' + | 'timeout' + | 'stream_unavailable' + | 'idle_without_assistant_activity'; + +export interface OpenCodeRuntimeTurnSettledEvent { + schemaVersion: 1; + provider: 'opencode'; + eventName: 'runtime_turn_settled'; + hookEventName: 'Stop'; + source: 'agent-teams-orchestrator-opencode'; + recordedAt: string; + sessionId: string; + turnId: string; + teamName: string; + memberName: string; + cwd?: string; + runtimePid?: number; + outcome: OpenCodeRuntimeTurnSettledOutcome; + detail?: string; + diagnostics?: string[]; +} + +export interface OpenCodeRuntimeTurnSettledEmitterPort { + emit(event: OpenCodeRuntimeTurnSettledEvent): Promise; +} + +export class FileOpenCodeRuntimeTurnSettledEmitter + implements OpenCodeRuntimeTurnSettledEmitterPort +{ + constructor(private readonly env: NodeJS.ProcessEnv = process.env) {} + + async emit(event: OpenCodeRuntimeTurnSettledEvent): Promise { + const spoolRoot = this.env[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]?.trim(); + if (!spoolRoot) return; + + const incomingDir = join(spoolRoot, 'incoming'); + await mkdir(incomingDir, { recursive: true }); + + const stamp = event.recordedAt.replace(/[-:.]/g, ''); + const suffix = `${process.pid}-${randomUUID()}`; + const tempPath = join(incomingDir, `.turn-settled.${suffix}`); + const finalPath = join(incomingDir, `${stamp}-${basename(tempPath)}.opencode.json`); + + await writeFile(tempPath, `${JSON.stringify(event)}\n`, 'utf8'); + await rename(tempPath, finalPath); + } +} + +export async function emitOpenCodeTurnSettledBestEffort( + event: OpenCodeRuntimeTurnSettledEvent, + emitter: OpenCodeRuntimeTurnSettledEmitterPort, +): Promise { + try { + await emitter.emit(event); + } catch { + // Runtime turn-settled telemetry must never fail OpenCode delivery. + } +} +``` + +### 8.2 Add OpenCode Turn-Settled Observer + +File: + +```text +src/services/opencode/OpenCodeTurnSettledObserver.ts +``` + +Responsibilities: + +- open SSE before prompt; +- filter events by `sessionID`; +- mark assistant-turn activity only after `start()` is called; +- capture `session.error`; +- return terminal evidence on `session.idle` or `session.status idle`; +- timeout gracefully; +- never throw into delivery path; +- never write the spool directly. + +Example interface: + +```ts +export interface OpenCodeTurnSettledObservation { + turnId: string; + waitUntilReady(input: { timeoutMs: number }): Promise<'connected' | 'fallback' | 'timeout'>; + markPromptSubmitting(): void; + markPromptAcceptedByEndpoint(): void; + markPromptRejectedByEndpoint(reason: string): void; + waitForSettled(input: { timeoutMs: number }): Promise; + dispose(): void; +} + +export interface OpenCodeTurnSettledEvidence { + readiness: 'connected' | 'fallback' | 'timeout'; + promptLifecycle: 'accepted_by_endpoint' | 'rejected_by_endpoint' | 'unknown'; + outcome: OpenCodeRuntimeTurnSettledOutcome; + sawAssistantTurnActivity: boolean; + sawError: boolean; + diagnostics: string[]; +} + +export interface OpenCodeTurnSettledObserverPort { + observe(input: OpenCodeTurnSettledObserveInput): OpenCodeTurnSettledObservation; +} + +export interface OpenCodeTurnSettledObserveInput { + baseUrl: string; + sessionId: string; + teamName: string; + memberName: string; + selectedModel: string; + projectPath?: string | null; + runtimePid?: number | null; + turnId: string; + timeoutMs?: number; +} +``` + +Status and session identity rules: + +```ts +function sessionIdFromEvent(event: OpenCodeSseEvent): string | null { + const properties = event.properties; + if (event.type === 'session.error') { + return asString(properties.sessionID); + } + return ( + asString(properties.sessionID) + ?? asString(asRecord(properties.info)?.sessionID) + ?? asString(asRecord(properties.part)?.sessionID) + ); +} + +function isCurrentSessionEvent(event: OpenCodeSseEvent, sessionId: string): boolean { + const eventSessionId = sessionIdFromEvent(event); + return eventSessionId === sessionId; +} + +function getOpenCodeSessionStatusType(value: unknown): string | null { + if (typeof value === 'string') return value; + const record = asRecord(value); + return asString(record?.type); +} +``` + +Do not infer `session.error` from `info.sessionID` or `part.sessionID`; current generated SDK shape uses `properties.sessionID?` for session error. If missing, record a diagnostic and do not classify the turn as error without later matched session evidence. + +Core event handling: + +```ts +function isRelevantDirectory(event: OpenCodeSseEvent, projectPath?: string | null): boolean { + if (!projectPath || !event.directory) return true; + return normalizePathForCompare(event.directory) === normalizePathForCompare(projectPath); +} + +function isTerminalIdle(event: OpenCodeSseEvent): boolean { + if (event.type === 'session.status') { + return getOpenCodeSessionStatusType(event.properties.status) === 'idle'; + } + return event.type === 'session.idle'; // Deprecated in OpenCode source, kept as legacy fallback. +} + +const assistantMessageIds = new Set(); + +function isAssistantTurnActivityEvent(event: OpenCodeSseEvent, runtimePromptMessageId: string): boolean { + if (event.type === 'session.status') { + return getOpenCodeSessionStatusType(event.properties.status) === 'busy'; + } + + if (event.type === 'message.updated') { + const info = asRecord(event.properties.info); + const messageId = asString(info?.id); + const role = asString(info?.role); + if (messageId && role === 'assistant') { + assistantMessageIds.add(messageId); + return true; + } + return false; // user message persistence is not assistant-turn activity. + } + + if (event.type === 'message.part.updated' || event.type === 'message.part.delta') { + const part = asRecord(event.properties.part); + const messageId = asString(part?.messageID) ?? asString(event.properties.messageID); + if (!messageId || messageId === runtimePromptMessageId) return false; + if (assistantMessageIds.has(messageId)) return true; + const partType = asString(part?.type); + return partType === 'tool' + || partType === 'step-start' + || partType === 'step-finish' + || partType === 'reasoning'; + } + + return false; +} +``` + +Prompt lifecycle behavior: + +```ts +let promptLifecycle: + | 'pending' + | 'submitting' + | 'accepted_by_endpoint' + | 'rejected_by_endpoint' = 'pending'; +let candidateAssistantTurnActivity = false; +let candidateTerminalIdle: OpenCodeSseEvent | null = null; +let candidateSessionError = false; + +function markPromptSubmitting() { + if (promptLifecycle === 'pending') { + promptLifecycle = 'submitting'; + } +} + +function markPromptAcceptedByEndpoint() { + if (promptLifecycle !== 'rejected_by_endpoint') { + promptLifecycle = 'accepted_by_endpoint'; + if (candidateAssistantTurnActivity) sawAssistantTurnActivity = true; + if (candidateSessionError) { + resolveTerminalEvidence('error'); + return; + } + if (candidateTerminalIdle) resolveFromIdle(candidateTerminalIdle); + } +} + +function markPromptRejectedByEndpoint(reason: string) { + promptLifecycle = 'rejected_by_endpoint'; + diagnostics.push(`OpenCode prompt_async rejected before turn-settled emission: ${reason}`); +} +``` + +Core event behavior: + +```ts +if (!isRelevantDirectory(event, input.projectPath)) return; + +if (event.type === 'session.error' && !sessionIdFromEvent(event)) { + diagnostics.push('OpenCode session.error observed without matching session identity'); + return; +} + +if (!isCurrentSessionEvent(event, input.sessionId)) return; + +if (isAssistantTurnActivityEvent(event, turnId)) { + if (promptLifecycle === 'submitting') { + candidateAssistantTurnActivity = true; + } else if (promptLifecycle === 'accepted_by_endpoint') { + sawAssistantTurnActivity = true; + } +} + +if (event.type === 'session.error') { + if (promptLifecycle === 'submitting' || promptLifecycle === 'accepted_by_endpoint') { + sawError = true; + diagnostics.push('OpenCode session.error observed before idle'); + if (promptLifecycle === 'submitting') { + candidateSessionError = true; + return; + } + resolveTerminalEvidence('error'); + return; + } else { + diagnostics.push('OpenCode session.error observed before prompt submit window'); + } +} + +if (isTerminalIdle(event)) { + if (promptLifecycle === 'submitting') { + candidateTerminalIdle = event; + return; + } + if (promptLifecycle !== 'accepted_by_endpoint') return; + const outcome = sawError + ? 'error' + : sawAssistantTurnActivity + ? 'success' + : 'idle_without_assistant_activity'; + resolveTerminalEvidence(outcome); +} +``` + +Timeout behavior: + +```ts +async waitForSettled({ timeoutMs }: { timeoutMs: number }) { + return await Promise.race([ + terminalEvidencePromise, + sleep(timeoutMs).then(() => buildEvidence(streamConnected ? 'timeout' : 'stream_unavailable')), + ]); +} +``` + +Readiness behavior: + +```ts +async waitUntilReady({ timeoutMs }: { timeoutMs: number }) { + return await Promise.race([ + streamConnectedPromise.then(() => 'connected' as const), + endpointFallbackPromise.then(() => 'fallback' as const), + sleep(timeoutMs).then(() => 'timeout' as const), + ]); +} +``` + +`waitUntilReady()` is advisory. If it times out, the prompt still proceeds and the final outcome can become `stream_unavailable` or `timeout`. + +### 8.2b Add Turn-Settled Emission Coordinator + +File: + +```text +src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.ts +``` + +Responsibility: + +- combine observer evidence with existing reconcile/preview evidence; +- avoid premature timeout emission when existing reconcile proves assistant-turn activity; +- build one final `OpenCodeRuntimeTurnSettledEvent`; +- call the file emitter once; +- translate outcome into existing bridge diagnostics. + +Example: + +```ts +export class OpenCodeTurnSettledEmissionCoordinator { + constructor(private readonly emitter: OpenCodeRuntimeTurnSettledEmitterPort) {} + + async emitDelivery(input: { + record: OpenCodeSessionRecord; + turnId: string; + teamName: string; + memberName: string; + observer: OpenCodeTurnSettledEvidence; + prePromptCursor: string | null; + reconcileSummary: OpenCodeSessionReconcileSummary | null; + responseObservation?: OpenCodeDeliveryResponseObservation | null; + }): Promise { + const finalOutcome = deriveDeliveryOutcome(input); + await emitOpenCodeTurnSettledBestEffort( + buildOpenCodeTurnSettledEvent({ ...input, outcome: finalOutcome.outcome }), + this.emitter, + ); + return [teamDiagnostic(finalOutcome.code, finalOutcome.message, finalOutcome.severity)]; + } +} +``` + +Derivation rules: + +```ts +function didReconcileAdvance(input: { + prePromptCursor: string | null; + summary: OpenCodeSessionReconcileSummary | null; +}): boolean { + return Boolean( + input.summary + && input.summary.lastCanonicalCursor + && input.summary.lastCanonicalCursor !== input.prePromptCursor + ); +} + +function deriveDeliveryOutcome(input: DeliveryEmissionInput): FinalOpenCodeTurnOutcome { + if (didResponseObservationProveActivity(input.responseObservation)) { + return success('response_observation_proved_activity'); + } + + if (didReconcileAdvance(input)) { + return success('reconcile_advanced_after_prompt'); + } + + if (input.observer.outcome === 'error') { + return failure('error', 'observer_session_error'); + } + + if (input.observer.outcome === 'success') { + return success('observer_idle_after_activity'); + } + + return { + outcome: input.observer.outcome, + diagnostics: input.observer.diagnostics, + }; +} +``` + +Launch derivation is similar but uses preview summary plus reconcile summary: + +```ts +function deriveLaunchOutcome(input: LaunchEmissionInput): FinalOpenCodeTurnOutcome { + if (didPreviewObserveActivity(input.preview) || didReconcileAdvance(input)) { + return success('launch_preview_or_reconcile_activity'); + } + if (input.preview?.runtimeState === 'error') { + return failure('error', 'launch_preview_session_error'); + } + return { outcome: 'timeout', diagnostics: ['launch_preview_no_activity'] }; +} + +function didPreviewObserveActivity(summary: OpenCodeSessionPreviewSummary | null): boolean { + if (!summary) return false; + return Boolean( + summary.previewOutcome === 'observed' + && ( + summary.runtimeState === 'idle' + || summary.latestEventType === 'session.idle' + || summary.latestAssistantMessageId + || summary.latestAssistantPreview + ) + ); +} +``` + +The coordinator is the only object that writes spool files for OpenCode turn-settled. The observer and preview reader return evidence only. + +Do not treat `previewOutcome === 'observed'` alone as success. The current preview observer can return `observed` after a bounded timeout once the stream was connected. The coordinator needs session activity evidence, not just stream availability. + +### 8.3 Reuse Or Extract SSE Helpers + +Existing file: + +```text +src/services/opencode/OpenCodePreviewObserver.ts +``` + +It already contains: + +- SSE parsing; +- OpenCode event normalization; +- session ID extraction logic. + +Required signature change: + +```ts +type ObserveSessionParams = { + baseUrl: string; + sessionId: string; + projectPath?: string | null; + timeoutMs?: number; + idleTimeoutMs?: number; + signal?: AbortSignal; +} +``` + +`OpenCodeSessionBridge.observePreview(record, ...)` must pass `record.projectPath` into `openCodePreviewObserver.observeSession(...)`. + +Current weak spot found in code: + +```ts +const status = asString(properties.status) +``` + +OpenCode SDK types model `session.status.properties.status` as an object: + +```ts +type SessionStatus = { type: 'idle' } | { type: 'busy' } | { type: 'retry', ... } +``` + +Older/live shapes can still be strings, so the shared helper must normalize both. This should be fixed while extracting helpers, otherwise `session.status idle` may be missed by both preview and turn-settled logic. + +Preferred low-risk approach: + +1. Extract shared pure helpers into: + +```text +src/services/opencode/OpenCodeSseEventStream.ts +``` + +2. Keep `OpenCodePreviewObserver` behavior unchanged. +3. Add tests that both preview and turn-settled observers parse the same fixture events. + +Example shared API: + +```ts +export function normalizeOpenCodeSseEvent(raw: unknown): OpenCodeSseEvent | null; +export function parseOpenCodeSseDataBlocks(input: string): string[]; +export function extractOpenCodeSseDataLines(block: string): string | null; +export function getOpenCodeSessionStatusType(value: unknown): string | null; +export async function* readOpenCodeSseEvents(input: { + fetchImpl: typeof fetch; + endpointUrl: string; + signal: AbortSignal; + projectPath?: string | null; +}): AsyncIterable; +``` + +If extraction looks risky, duplicate the small parser in v1 and document a follow-up dedupe task. However, extraction is preferred for DRY if tests stay tight. + +Current-code weak spots that this extraction must fix: + +1. `OpenCodePreviewObserver` currently reads `properties.status` as a string. It must use `getOpenCodeSessionStatusType()` so `{ type: 'idle' }` is terminal. +2. `OpenCodePreviewObserver` currently has `directory` on normalized events but no `projectPath` input, so `/global/event` fallback cannot reject foreign project events. Extend `ObserveSessionParams` with `projectPath?: string | null`, pass `record.projectPath` from `OpenCodeSessionBridge.observePreview()`, and filter before session matching. +3. `session.error` without a session identity should not mark the current session as errored. It should add a diagnostic and wait for matched session activity. This matters because current SDK types allow missing `sessionID`. +4. `server.connected` proves stream readiness only. It must not increment assistant-turn activity or launch success evidence. +5. Multiline SSE `data:` blocks and comment lines should stay supported. Do not replace the parser with a naive `split('\n')`. + +### 8.4 Integrate With OpenCodeSessionBridge + +File: + +```text +src/services/opencode/OpenCodeSessionBridge.ts +``` + +Extend deps: + +```ts +type OpenCodeSessionBridgeDeps = { + // existing deps + turnSettledObserver?: OpenCodeTurnSettledObserverPort; +} +``` + +Do not put final emission policy in `OpenCodeSessionBridge`. The bridge owns host/session IO. The command handler owns command-level evidence composition because it already has `prePromptCursor`, response observation, preview summary, and reconcile summary. + +Current code shape: + +```text +OpenCodeBridgeCommandHandler.ts +-> module-level runLaunch/runSendMessage functions +-> singleton imports: openCodeSessionBridge, openCodeSessionStore, ... +-> export executeOpenCodeBridgeCommandEnvelope(input) +``` + +Do not introduce a large class refactor in this patch. Add a small optional dependency seam: + +```ts +type OpenCodeBridgeCommandRuntimeDeps = { + sessionBridge: typeof openCodeSessionBridge; + turnSettledCoordinator?: OpenCodeTurnSettledEmissionCoordinator; +} + +const defaultBridgeCommandRuntimeDeps: OpenCodeBridgeCommandRuntimeDeps = { + sessionBridge: openCodeSessionBridge, + turnSettledCoordinator: defaultOpenCodeTurnSettledCoordinator, +}; + +export async function executeOpenCodeBridgeCommandEnvelope( + input: unknown, + deps: OpenCodeBridgeCommandRuntimeDeps = defaultBridgeCommandRuntimeDeps, +) { + // pass deps into runLaunch/runSendMessage only where needed +} +``` + +This keeps the CLI public API unchanged, improves testability, and avoids rewriting the whole command handler. + +Host lifecycle rule: + +- For delivery v1, keep observe -> prompt -> waitForSettled inside one `withSessionHost()` callback. +- Do not create an observation inside `withSessionHost()` and return it after the callback exits. +- If a future split observer is added, it must own a separate host ref and release it from `dispose()` / `waitForSettled()` finalizer. + +Future split scope shape: + +```ts +type OpenCodeObservedPromptScope = { + baseUrl: string; + runtimePid: number | null; + observation: OpenCodeTurnSettledObservation; + submit(params: OpenCodePromptParams): Promise; + dispose(): Promise; +}; +``` + +Add prompt context: + +```ts +type OpenCodePromptTurnSettledContext = { + teamName: string; + memberName: string; + purpose: 'launch' | 'delivery' | 'reminder' | 'manual'; + turnId?: string; + readyTimeoutMs?: number; + timeoutMs?: number; +}; +``` + +Do not change the behavior of existing `promptAsync()` calls. Add one public opt-in wrapper for delivery and one private helper: + +1. A convenience wrapper for single delivery prompts. +2. A private submit helper that can include `messageID` without changing public `promptAsync()` behavior. +3. Coordinator methods in the command handler that reuse existing `observePreview()` output for launch. + +Private submit helper: + +```ts +private async submitPromptAsync( + record: OpenCodeSessionRecord, + params: { + text: string; + agent?: string; + noReply?: boolean; + system?: string; + messageID?: string; + }, +): Promise +``` + +Existing public method remains a wrapper: + +```ts +async promptAsync(record, params): Promise { + await this.submitPromptAsync(record, params); +} +``` + +Single-prompt wrapper: + +```ts +async promptAsyncWithTurnSettled( + record: OpenCodeSessionRecord, + params: { + text: string; + agent?: string; + noReply?: boolean; + system?: string; + turnSettled: OpenCodePromptTurnSettledContext; + }, +): Promise +``` + +Guard: + +- `promptAsyncWithTurnSettled()` is for prompts that can produce an assistant turn. +- `runSendMessage()` must call plain `promptAsync()` when `body.noReply === true`. +- Add a defensive assertion inside `promptAsyncWithTurnSettled()` so accidental no-reply use fails in tests before production wiring. + +Do not expand bridge command public data unless needed. The final coordinator result can be converted into existing `diagnostics`: + +```ts +teamDiagnostic( + `opencode_turn_settled_${outcome}`, + `OpenCode turn-settled observer finished with outcome=${outcome}`, + outcome === 'success' ? 'info' : 'warning', +) +``` + +This keeps `OpenCodeSendMessageCommandData` and renderer IPC stable. + +Delivery wrapper implementation sketch: + +```ts +async promptAsyncWithTurnSettled(record, params): Promise { + if (params.noReply === true) { + throw new Error('OpenCode turn-settled observation does not support noReply prompts'); + } + + return await this.withSessionHost(record, async ({ baseUrl, runtimePid }) => { + const turnId = params.turnSettled.turnId ?? buildOpenCodePromptMessageId({ + teamId: record.teamId, + memberName: record.memberName, + sessionId: record.opencodeSessionId, + purpose: params.turnSettled.purpose, + nonce: new Date().toISOString(), + }); + + const observation = this.turnSettledObserver.observe({ + baseUrl, + sessionId: record.opencodeSessionId, + teamName: params.turnSettled.teamName, + memberName: params.turnSettled.memberName, + selectedModel: record.selectedModel, + projectPath: record.projectPath, + runtimePid, + turnId, + }); + + try { + observation.markPromptSubmitting(); + try { + await this.submitPromptAsync(record, { + text: params.text, + agent: params.agent, + noReply: params.noReply, + system: params.system, + messageID: turnId, + }); + observation.markPromptAcceptedByEndpoint(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + observation.markPromptRejectedByEndpoint(message); + throw error; + } + + const evidence = await observation.waitForSettled({ + timeoutMs: params.turnSettled.timeoutMs ?? OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS, + }); + + return { ok: true, turnId, readiness, evidence }; + } finally { + observation.dispose(); + } + }); +} +``` + +Launch preview-derived coordinator sketch: + +```ts +const settledMembers = await mapWithConcurrency(promptedMembers, 3, async ({ name, record }) => { + let preview: OpenCodePreviewSummary | null = null; + let reconciled: OpenCodeSessionReconcileSummary | null = null; + + try { + preview = await deps.sessionBridge.observePreview(record, { + timeoutMs: OPENCODE_LAUNCH_PREVIEW_TIMEOUT_MS, + idleTimeoutMs: OPENCODE_LAUNCH_PREVIEW_IDLE_TIMEOUT_MS, + }); + } catch (error) { + // Existing launch preview diagnostics stay unchanged. + } + + try { + reconciled = await deps.sessionBridge.reconcileSession(record, { limit: 50 }); + } catch (error) { + // Existing launch reconcile diagnostics stay unchanged. + } + + await deps.turnSettledCoordinator?.emitLaunch({ + record, + turnId: buildOpenCodeLaunchTurnId(record), + teamName: teamId, + memberName: name, + preview: preview?.summary ?? null, + reconcileSummary: reconciled, + }); + + return { name, record, reconciled }; +}); +``` + +Risk: + +- Adding `messageID` to all prompts could affect OpenCode behavior. +- Waiting for settlement can increase bridge command latency. + +Mitigation: + +- Keep `promptAsync()` unchanged for unobserved prompt paths. +- Only pass `messageID` inside `promptAsyncWithTurnSettled()`. +- Ensure generated ID starts with `msg_`. +- Keep wait bounded and return `timeout` evidence rather than waiting indefinitely. +- Test existing prompt paths remain unchanged. + +### 8.5 Add Turn-Settled Context At Prompt Sites + +Prompt sites: + +```text +src/services/opencode/OpenCodeBridgeCommandHandler.ts +``` + +Known calls: + +- launch/bootstrap prompt around `openCodeSessionBridge.promptAsync(record, ...)`; +- delivery prompt inside `runSendMessage()`. + +Launch prompt example: + +```ts +await openCodeSessionBridge.promptAsync(record, { + text: `${runtimeIdentityBlock}\n\n${prompt}`, + agent: 'teammate', +}); +promptedMembers.push({ name, record }); +``` + +Launch settle phase example: + +```ts +const preview = await safeObservePreview(record); +const reconciled = await safeReconcileSession(record, { limit: 50 }); +const diagnostics = await deps.turnSettledCoordinator?.emitLaunch({ + record, + teamName: teamId, + memberName: name, + turnId: buildOpenCodeLaunchTurnId(record), + preview: preview?.summary ?? null, + reconcileSummary: reconciled, +}); +``` + +Delivery prompt example: + +```ts +const promptText = identityReminder ? `${identityReminder}\n\n${text}` : text; + +const turnSettled = body.noReply === true + ? null + : await deps.sessionBridge.promptAsyncWithTurnSettled(deliveryRecord, { + text: promptText, + agent: asString(body.agent) ?? 'teammate', + turnSettled: { + teamName: teamId, + memberName, + purpose: 'delivery', + timeoutMs: OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS, + }, + }); + +if (body.noReply === true) { + await deps.sessionBridge.promptAsync(deliveryRecord, { + text: promptText, + agent: asString(body.agent) ?? 'teammate', + noReply: true, + }); +} + +const reconcileSummary = await safeReconcileSession(deliveryRecord, { limit: 50 }); +const responseObservation = observeOpenCodeDeliveryResponse(...); +const diagnostics = turnSettled + ? await deps.turnSettledCoordinator?.emitDelivery({ + record: deliveryRecord, + teamName: teamId, + memberName, + turnId: turnSettled.turnId, + observer: turnSettled.evidence, + prePromptCursor, + reconcileSummary, + responseObservation, + }) + : []; +``` + +Suggested defaults: + +```ts +const OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS = 12_000; +const OPENCODE_SEND_TURN_SETTLED_IDLE_TIMEOUT_MS = 2_500; +``` + +Keep these bounded. They are bridge-command telemetry budgets, not model behavior guarantees. + +### 8.6 Bounded Wait, Not Background Fire-And-Forget + +Do not leave the observer running in the background after the bridge command returns. + +Bad: + +```ts +void observation.waitForSettled({ timeoutMs: OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS }); +return accepted; +``` + +Good: + +```ts +const outcome = await observation.waitForSettled({ timeoutMs: OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS }); +const reconcileSummary = await reconcileSession(record); +await deps.turnSettledCoordinator?.emitDelivery({ observer: outcome, reconcileSummary, ... }); +return { accepted, diagnostics }; +``` + +Acceptance semantics remain: + +- `prompt_async` accepted means delivery accepted; +- observer timeout does not turn accepted delivery into failed delivery; +- post-send reconcile can still warn; +- turn-settled event is extra input for member-work-sync. + +Practical tradeoff: + +- The bridge command can take up to the telemetry budget longer. +- This is acceptable because OpenCode bridge calls already wait for delivery observation/reconcile in several paths, and durability matters more than a fire-and-forget signal that may never be written. + +--- + +## 9. claude_team Implementation Plan + +Repo: + +```text +/Users/belief/dev/projects/claude/_worktrees/claude_team_member_work_sync_opencode +``` + +### 9.1 Extend Provider Type + +File: + +```text +src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts +``` + +Change: + +```ts +export type RuntimeTurnSettledProvider = 'claude' | 'codex' | 'opencode'; + +export function isRuntimeTurnSettledProvider( + value: unknown +): value is RuntimeTurnSettledProvider { + return value === 'claude' || value === 'codex' || value === 'opencode'; +} +``` + +### 9.2 Add OpenCode Payload Normalizer + +File: + +```text +src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts +``` + +Example: + +```ts +export class OpenCodeTurnSettledPayloadNormalizer + implements RuntimeTurnSettledPayloadNormalizerPort +{ + constructor(private readonly hash: MemberWorkSyncHashPort) {} + + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization { + if (input.provider !== 'opencode') { + return { ok: false, reason: 'unsupported_provider' }; + } + + const payload = parseObject(input.raw); + if (!payload.ok) { + return { ok: false, reason: payload.reason }; + } + + if (getString(payload.value, 'provider') !== 'opencode') { + return { ok: false, reason: 'provider_mismatch' }; + } + if (getString(payload.value, 'source') !== 'agent-teams-orchestrator-opencode') { + return { ok: false, reason: 'source_mismatch' }; + } + if (getString(payload.value, 'eventName', 'event_name') !== 'runtime_turn_settled') { + return { ok: false, reason: 'not_turn_settled_event' }; + } + + const sessionId = getString(payload.value, 'sessionId', 'session_id'); + const teamName = getString(payload.value, 'teamName', 'team_name'); + const memberName = getString(payload.value, 'memberName', 'member_name'); + if (!sessionId) return { ok: false, reason: 'missing_session_identity' }; + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const payloadHash = this.hash.sha256Hex(input.raw); + const turnId = getString(payload.value, 'turnId', 'turn_id'); + const outcome = getString(payload.value, 'outcome'); + + return { + ok: true, + event: { + schemaVersion: 1, + provider: 'opencode', + hookEventName: 'Stop', + payloadHash, + recordedAt: getString(payload.value, 'recordedAt', 'recorded_at') ?? input.recordedAt, + sourceId: buildRuntimeTurnSettledSourceId({ + provider: 'opencode', + sessionId, + turnId, + payloadHash, + }), + sessionId, + ...(turnId ? { turnId } : {}), + teamName, + memberName, + ...(outcome ? { outcome } : {}), + }, + }; + } +} +``` + +Validation rules: + +- reject invalid JSON; +- reject source mismatch; +- require session ID; +- require team and member identity; +- accept known outcomes but do not fail if outcome is unknown, because event still wakes reconcile. + +### 9.3 Add Normalizer To Composition + +File: + +```text +src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +``` + +Change: + +```ts +const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([ + new ClaudeStopHookPayloadNormalizer(hash), + new CodexNativeTurnSettledPayloadNormalizer(hash), + new OpenCodeTurnSettledPayloadNormalizer(hash), +]); +``` + +### 9.4 Extend Target Resolver + +File: + +```text +src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +``` + +Add OpenCode branch matching Codex style: + +```ts +async resolve(event: RuntimeTurnSettledEvent): Promise { + if (event.provider === 'codex') { + return this.resolveExplicitProviderEvent(event, 'codex'); + } + if (event.provider === 'opencode') { + return this.resolveExplicitProviderEvent(event, 'opencode'); + } + // existing Claude transcript/session scan +} +``` + +Shared helper: + +```ts +private async resolveExplicitProviderEvent( + event: RuntimeTurnSettledEvent, + expectedProviderId: 'codex' | 'opencode' +): Promise { + const teamName = event.teamName?.trim(); + const memberName = event.memberName?.trim(); + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const member = await this.resolveActiveMember(teamName, memberName); + if (!member) { + return { ok: false, reason: 'member_not_active' }; + } + if (isReservedMemberName(member.name)) { + return { ok: false, reason: 'reserved_member' }; + } + + const providerId = providerForMember(member); + if (providerId && providerId !== expectedProviderId) { + return { ok: false, reason: 'provider_mismatch' }; + } + + return { + ok: true, + teamName, + memberName: normalizeMemberName(member.name), + }; +} +``` + +This reduces duplication and keeps Codex/OpenCode explicit identity resolution consistent. + +### 9.5 Split Spool Initialization From Shell Hook Installation + +Current weak spot: + +`ShellRuntimeTurnSettledHookScriptInstaller` both creates the spool root and installs the Claude shell hook script. Reusing it for OpenCode works accidentally but is confusing and can become wrong as more provider-native emitters are added. + +Add a provider-neutral initializer: + +```text +src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts +``` + +Example: + +```ts +export interface RuntimeTurnSettledSpoolInitializerPort { + ensure(): Promise<{ spoolRoot: string }>; +} + +export class RuntimeTurnSettledSpoolInitializer + implements RuntimeTurnSettledSpoolInitializerPort +{ + constructor(private readonly paths: RuntimeTurnSettledSpoolPaths) {} + + async ensure(): Promise<{ spoolRoot: string }> { + const root = this.paths.getSpoolRoot(); + await Promise.all([ + mkdir(join(root, 'incoming'), { recursive: true }), + mkdir(join(root, 'processing'), { recursive: true }), + mkdir(join(root, 'processed'), { recursive: true }), + mkdir(join(root, 'invalid'), { recursive: true }), + ]); + return { spoolRoot: root }; + } +} +``` + +Then: + +- Claude hook settings still use `ShellRuntimeTurnSettledHookScriptInstaller`. +- Codex and OpenCode runtime env use `RuntimeTurnSettledSpoolInitializer`. +- This keeps shell-hook concerns out of provider-native turn-settled emitters. + +Extend environment builder: + +```text +src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts +``` + +```ts +export function buildRuntimeTurnSettledEnvironment(input: { + provider: RuntimeTurnSettledProvider; + spoolRoot: string; +}): Record | null { + if (input.provider !== 'codex' && input.provider !== 'opencode') { + return null; + } + + return { + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot, + }; +} +``` + +### 9.6 Extend File Store Provider Parsing + +File: + +```text +src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts +``` + +Current weak spot: + +```ts +function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null +``` + +This currently extracts the provider token from the second-to-last filename segment and validates it through `isRuntimeTurnSettledProvider(provider)`. After the provider union is extended, the runtime behavior is almost correct, but the explicit return type would still make TypeScript reject `opencode` and can hide future provider additions. + +Change to: + +```ts +function parseProviderFromFileName(fileName: string): RuntimeTurnSettledProvider | null { + const parts = fileName.split('.'); + const provider = parts.length >= 3 ? parts[parts.length - 2] : null; + return isRuntimeTurnSettledProvider(provider) ? provider : null; +} +``` + +Add a test that a valid `.opencode.json` file reaches the normalizer instead of invalid quarantine. + +### 9.7 Pass Env To OpenCode Bridge Launch + +Find where `claude_team` invokes `agent_teams_orchestrator` OpenCode bridge commands. + +Expected service area: + +```text +src/main/services/team/ +src/features/runtime-provider-management/ +``` + +Concrete path found in current code: + +```text +src/main/index.ts +createOpenCodeRuntimeAdapterRegistry() +``` + +The important detail: `OpenCodeBridgeCommandClient` captures `env` in its constructor. Adding env after the client is constructed is too late. + +Second important detail found in current composition order: + +```text +teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry()) +... +memberWorkSyncFeature = createMemberWorkSyncFeature(...) +``` + +So `createOpenCodeRuntimeAdapterRegistry()` currently runs before `memberWorkSyncFeature` exists. A naive call to `memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' })` inside the registry factory would always see `null`. + +Related code path: + +```text +TeamProvisioningService.buildRuntimeTurnSettledEnvironment(providerId) +``` + +currently returns env only for `codex`. That path is for native provider process launches. OpenCode secondary teammates use the OpenCode runtime adapter bridge, whose env is captured by `OpenCodeBridgeCommandClient` in `src/main/index.ts`. Therefore the v1 OpenCode wiring must target the bridge client env, not only the generic provisioning env helper. + +Preferred composition fix: + +```text +create TeamDataService +create TeamProvisioningService +create memberWorkSyncFeature +register runtimeTurnSettled providers on TeamProvisioningService +create OpenCode runtime adapter registry with memberWorkSyncFeature available +``` + +Keep delayed side effects where they are: + +- startup replay/scan still runs after service wiring; +- IPC registration still runs after window/service setup; +- `memberWorkSyncFeature.noteTeamChange(...)` remains guarded by nullable access in emitters. + +The runtime launch/handoff path must merge: + +```ts +const openCodeTurnSettledEnv = + await memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }); +``` + +into the environment used for the OpenCode bridge process. + +Important: + +- do not overwrite existing env; +- merge before constructing `OpenCodeBridgeCommandClient`; +- do not expose this env to unrelated user project scripts; +- missing env means telemetry disabled, not runtime failure. +- keep `TeamProvisioningService.buildRuntimeTurnSettledEnvironment()` codex-only unless a real OpenCode path later launches a provider process directly through that generic helper. + +Example: + +```ts +const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env }); +const turnSettledEnv = memberWorkSyncFeature + ? await memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }) + : null; +Object.assign(bridgeEnv, turnSettledEnv ?? {}); + +const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath, + tempDirectory, + env: bridgeEnv, +}); +``` + +--- + +## 10. Event Flow Details + +### 10.1 Launch Bootstrap + +OpenCode launch currently prompts teammates with runtime identity and briefing instructions. + +New behavior: + +```text +launch prompt accepted +-> existing preview/reconcile settle phase collects evidence +-> coordinator writes one spool event +-> app reconciles member agenda +``` + +Expected practical value: + +- if launch finished and teammate has tasks, work-sync quickly re-evaluates; +- if launch errored, app still gets a signal and diagnostics; +- no direct user-visible UI changes. + +### 10.2 User Delivery + +OpenCode user-to-member message currently goes through `runSendMessage()`. + +New behavior: + +```text +delivery prompt accepted +-> observer tracks same session +-> post-send reconcile/response observation contributes evidence +-> coordinator writes one turn-settled event +-> member-work-sync recomputes whether member agenda is known/current +``` + +It does not replace: + +- OpenCode delivery ledger; +- response observation; +- visible reply correlation; +- MCP readiness repair. + +### 10.3 Watchdog Interaction + +If OpenCode agent stalls after weak start: + +1. delivery ledger/watchdog keeps existing behavior; +2. OpenCode turn-settled signal wakes member-work-sync after each turn; +3. member-work-sync may decide status is `needs_sync`; +4. future nudge outbox remains rate-limited; +5. task-stall monitor remains responsible for semantic no-progress. + +No conflict because each layer has a different proof model. + +--- + +## 11. Risks And Mitigations + +### Risk 1: SSE Misses Fast Turns + +Problem: + +```text +prompt_async can return and OpenCode can finish very quickly. +``` + +Mitigation: + +- start observer before `prompt_async`; +- call `markPromptSubmitting()` immediately before the HTTP request; +- buffer same-session activity and terminal idle seen while the request is in flight; +- validate buffered evidence only after `markPromptAcceptedByEndpoint()`; +- if `prompt_async` rejects, call `markPromptRejectedByEndpoint()` and do not emit a runtime turn-settled file. + +Residual risk: + +- if OpenCode emits a complete turn before SSE stream connects, v1 can miss it. + +Fallback: + +- bounded polling can be added if live tests show misses. + +### Risk 2: Idle After Error Looks Like Success + +Problem: + +OpenCode emits `session.idle` after `session.error`. + +Mitigation: + +- preserve `sawError` state; +- treat matched `session.error` as terminal error evidence immediately; +- if matched `session.error` arrives while `prompt_async` is still in flight, buffer it and promote it after endpoint acceptance; +- return observer `outcome: 'error'`; +- let coordinator upgrade to success only if response/reconcile evidence proves later successful activity; +- include short diagnostic. + +### Risk 3: Duplicate Idle Events + +Problem: + +Both `session.status idle` and `session.idle` can arrive. + +Mitigation: + +- observer-level `resolveOnce`; +- coordinator-level `emitOnce`; +- sourceId-level dedupe in `RuntimeTurnSettledIngestor`. + +### Risk 4: Misattributing Member + +Problem: + +OpenCode session ID alone can be stale or reused in corrupted metadata. + +Mitigation: + +- payload includes explicit `teamName` and `memberName` from `OpenCodeSessionRecord`; +- resolver validates active team config/meta; +- resolver validates provider is `opencode`; +- reserved names rejected. + +### Risk 5: Stale Removed Teammate Emits Event + +Problem: + +Old OpenCode process can still emit after member removal. + +Mitigation: + +- resolver checks config is not deleted and member has no `removedAt`; +- unresolved event is archived, not enqueued. + +### Risk 6: Telemetry Breaks Delivery + +Problem: + +If observer or spool fails, message delivery should still work. + +Mitigation: + +- all turn-settled emission is best-effort; +- observer errors are diagnostics only; +- `prompt_async` acceptance remains the delivery acceptance boundary; +- `waitForSettled()` is bounded and returns `timeout`/`stream_unavailable`, not an exception that fails delivery. + +Non-negotiable: + +- do not use unbounded await; +- do not run the observer fire-and-forget after bridge command return. + +### Risk 7: Message ID Changes Existing Behavior + +Problem: + +Passing `messageID` to `prompt_async` may alter idempotency in OpenCode. + +Mitigation: + +- only pass messageID when observer is enabled; +- generate valid `msg_...` ID; +- use unique turn ID, not deterministic retry ID, unless we explicitly want OpenCode-side idempotency later; +- tests verify existing no-observer prompt path sends no `messageID`. + +### Risk 8: OpenCode Event Schema Changes + +Problem: + +OpenCode event properties use `sessionID`, `info.sessionID`, or `part.sessionID`. + +Mitigation: + +- reuse existing preview observer extraction logic; +- tolerate unknown event types; +- only rely on `session.status`, `session.idle`, `session.error`. + +### Risk 8b: `session.error` Without Session Identity + +Problem: + +OpenCode SDK types allow `session.error.properties.sessionID` to be missing. Treating all host-level errors as the current session could misattribute errors if a host ever serves multiple sessions. + +Mitigation: + +- if `session.error` has the expected session ID, set `sawError = true`; +- if `session.error` has no session ID, record a diagnostic but do not mark error unless later matched activity/idle confirms the session; +- only add a stronger host-level fallback if the session bridge can prove the host is dedicated to this `OpenCodeSessionRecord`; +- tests cover sessionless error followed by matched idle and sessionless error with no matched activity. + +### Risk 8c: Global Event Cross-Project Noise + +Problem: + +`/global/event` wraps events with a `directory` field. If `/event` fails and observer falls back to `/global/event`, unrelated project events can be visible on the same server stream. + +Mitigation: + +- pass `projectPath` from `OpenCodeSessionRecord` into observation input; +- when event has `directory`, compare it with normalized `projectPath`; +- ignore mismatched directory events before session matching; +- keep direct `/event` behavior unchanged when no directory is present; +- tests cover global event with matching directory and foreign directory. + +### Risk 9: Long-Lived Background Observers Leak + +Problem: + +Many OpenCode messages can start many observers. + +Mitigation: + +- bounded timeout per observer; +- abort controller cleanup; +- no global listener per host in v1; +- unit test verifies `dispose()` aborts stream. + +Additional constraint: + +- observer lifetime must be scoped to one bridge command. If a future long-lived host-level observer is added, it should be a separate adapter with explicit lifecycle ownership and not hidden inside `promptAsyncWithTurnSettled()`. + +### Risk 10: Nudges Become More Frequent + +Problem: + +More reconcile triggers could expose existing Phase 2 nudges. + +Mitigation: + +- current nudge side effects remain gated by `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED`; +- queue quiet window debounces events; +- outbox has one item per fingerprint; +- dispatcher revalidates busy/watchdog cooldown before delivery. + +### Risk 11: Bridge Command Timeout Budget + +Problem: + +`claude_team` currently runs `opencode.sendMessage` through `OpenCodeReadinessBridge`, whose default send timeout is `30_000ms`. `runSendMessage()` already does MCP readiness repair and a post-prompt reconcile with `OPENCODE_SEND_RECONCILE_TIMEOUT_MS = 5_000ms`. Adding a turn-settled wait can consume that budget and accidentally turn accepted prompts into bridge timeouts. + +Mitigation: + +- keep `OPENCODE_SEND_TURN_SETTLED_TIMEOUT_MS` below the send command budget, implemented default `12_000ms`; +- keep `OPENCODE_SEND_TURN_SETTLED_IDLE_TIMEOUT_MS` small, implemented default `2_500ms`; +- do not nest another long response-observation wait inside the same critical path without reviewing total timeout; +- if the bridge envelope exposes remaining time, cap observer timeout to `min(configuredTurnSettledTimeout, remainingBudget - safetyMargin)`; +- compute a static fallback cap when remaining time is unavailable: + + ```ts + const telemetryBudgetMs = Math.min( + configuredTurnSettledTimeoutMs, + Math.max(1_000, envelope.timeoutMs - 12_000), + ); + ``` + +- add a test where `waitForSettled()` times out and the command still returns accepted before the bridge timeout. + +### Risk 12: Launch Fan-Out Becomes Serial Or Opens Duplicate Streams + +Problem: + +`runLaunch()` currently submits bootstrap prompts for all members first, then observes/reconciles with `mapWithConcurrency(promptedMembers, 3, ...)`. If launch uses the single-prompt wrapper and waits during the prompt loop, a 4-member OpenCode team can pay the observer timeout 4 times before all members even receive bootstrap. If it starts a second SSE observer in addition to `observePreview()`, launch does duplicate stream work. + +Mitigation: + +- launch v1 keeps current prompt submission path; +- launch v1 emits turn-settled from existing `observePreview()` summary in the concurrent settle phase; +- no second launch SSE stream unless live tests prove preview-derived signal is insufficient; +- tests assert prompt submission is not delayed by per-member observer timeout. + +### Risk 13: Status Shape Drift + +Problem: + +OpenCode SDK types model `session.status.properties.status` as an object with `type`, while earlier/live event captures may expose a string. Code that only checks one shape can silently miss terminal idle. + +Mitigation: + +- centralize `getOpenCodeSessionStatusType(value)` in shared SSE helpers; +- support both string and object status; +- update `OpenCodePreviewObserver` and the new turn-settled observer to use the helper; +- tests cover both shapes. + +### Risk 14: Composition Order Drops OpenCode Spool Env + +Problem: + +`createOpenCodeRuntimeAdapterRegistry()` currently runs before `memberWorkSyncFeature` is created. Since `OpenCodeBridgeCommandClient` captures env in its constructor, late wiring cannot fix the bridge env. + +Mitigation: + +- move `createMemberWorkSyncFeature(...)` earlier in `src/main/index.ts`, before OpenCode registry construction; +- keep effectful startup replay/scan and IPC registration in their current later positions; +- add a composition test or safe integration test proving `AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT` is present in the env passed to `OpenCodeBridgeCommandClient`; +- if reordering causes a cycle, extract a small public member-work-sync factory for runtime-turn-settled env creation rather than reaching into infrastructure directly. + +### Risk 15: Observation Outlives Host Reference + +Problem: + +`OpenCodeSessionBridge.withSessionHost()` retains a host for callback duration and releases it in `finally`. Any API that returns an observation after `withSessionHost()` exits can leave the SSE stream attached to a host whose in-process ref was already released. + +Mitigation: + +- v1 delivery wrapper keeps observe, prompt, and `waitForSettled()` inside one `withSessionHost()` callback; +- launch v1 reuses `observePreview()`, so it does not introduce a second returned observation; +- if future split observer is needed, introduce explicit observed prompt scope ownership; +- tests assert delivery wrapper does not release host before observer settle/dispose. + +### Risk 16: IPC/Data Contract Churn + +Problem: + +Adding `turnSettledOutcome` to bridge command data could force renderer/shared contract changes for a telemetry-only feature. + +Mitigation: + +- keep public command data shape stable in v1; +- surface observer outcome through existing `diagnostics`; +- only expand contract later if UI needs to display turn-settled telemetry directly. + +### Risk 17: Premature Timeout Before Reconcile Proves Activity + +Problem: + +The SSE observer can time out because of stream delay, but the existing post-send reconcile can still see the assistant message, tool call, or cursor advance a few milliseconds later. If the observer writes the spool file directly, `member-work-sync` receives a false `timeout` even though the turn completed. + +Mitigation: + +- observer never writes spool files; +- observer returns evidence only; +- command handler runs the same reconcile/preview logic it already owns; +- coordinator emits exactly one final event after all local evidence has been collected; +- tests cover timeout evidence upgraded to success by reconcile and assert no duplicate timeout+success files. + +### Risk 18: Cross-Repo Contract Drift + +Problem: + +The orchestrator writes `.opencode.json` payloads, while `claude_team` normalizes and resolves them. If the two repos drift, events can be silently quarantined or ignored. + +Mitigation: + +- keep payload minimal and versioned with `schemaVersion: 1`; +- add an orchestrator fixture event generated by `buildOpenCodeTurnSettledEvent(...)`; +- import that fixture into `claude_team` tests or duplicate it as a contract fixture with an explicit comment; +- test malformed provider/source/session/team/member cases; +- do not rely on TypeScript shared imports across repos for runtime compatibility. + +### Risk 19: Optional Coordinator Missing + +Problem: + +If turn-settled env is not present or coordinator construction fails, OpenCode delivery must not fail. + +Mitigation: + +- default coordinator no-ops when spool env is missing; +- command handler treats missing `turnSettledCoordinator` as telemetry disabled; +- diagnostics can include `opencode_turn_settled_disabled`, but delivery acceptance remains unchanged; +- tests run send-message with `turnSettledCoordinator: undefined`. + +### Risk 20: Prompt Request Race And Rejected Prompt Events + +Problem: + +`prompt_async` returns `204` after OpenCode accepts the prompt, but the runtime can start and finish a short turn while the HTTP request is still in flight. If observation only starts counting after the response, the app can miss the whole turn. If observation emits before the response, it can create a false turn-settled event for a rejected prompt. + +Mitigation: + +- start SSE before the request; +- call `markPromptSubmitting()` immediately before `submitPromptAsync()`; +- buffer same-session activity and idle seen during `submitting`; +- after `204`, call `markPromptAcceptedByEndpoint()` and promote buffered evidence; +- if the HTTP request throws, call `markPromptRejectedByEndpoint()`, dispose the observer, and emit no runtime turn-settled file; +- tests cover fast idle during in-flight submit, rejected prompt with buffered idle, and normal slow idle after accepted. + +### Risk 21: `204 Prompt Accepted` Is Not Turn Success + +Problem: + +Current OpenCode `prompt_async` handler returns `204` after scheduling the prompt run. The actual prompt can still fail later and publish `session.error`. Treating `204` as success would hide model/provider/tool startup failures and make member-work-sync reconcile too optimistically. + +Mitigation: + +- name the lifecycle state `accepted_by_endpoint`, not just `accepted`; +- still observe `session.error` after `204`; +- derive final success only from response proof, reconcile cursor/message proof, or observer idle after post-submit activity with no error; +- tests cover `204 -> session.error -> session.status idle` returning final `error` unless response/reconcile proof upgrades it. + +### Risk 22: SSE Has Heartbeats But No Replay + +Problem: + +Current OpenCode `/event` sends `server.connected` and `server.heartbeat`, but no SSE `id` for replay. If the stream connects late, reconnects, or closes before terminal idle, the observer cannot recover missed events from Last-Event-ID. + +Mitigation: + +- ignore `server.connected` and `server.heartbeat` as activity; +- do not implement reconnect replay in v1 because the server does not expose replay IDs; +- map premature stream EOF to `stream_unavailable` with diagnostic `stream_closed_before_terminal_event`; +- let coordinator upgrade stream failure from existing reconcile/response proof; +- tests cover heartbeat-only stream, stream EOF before idle, and stream failure upgraded by reconcile. + +### Risk 23: `noReply` Prompts Are Not Agent Turns + +Problem: + +OpenCode `SessionPrompt.prompt()` returns after creating the user message when `noReply === true`; it does not enter the assistant loop that sets busy/idle. Observing such prompts would produce false timeouts or fake work-sync signals. + +Mitigation: + +- do not request OpenCode turn-settled observation when `noReply === true`; +- keep existing delivery/reconcile behavior for no-reply bookkeeping; +- do not write runtime-turn-settled spool files for no-reply prompts; +- tests cover `noReply` delivery with no observer, no coordinator emission, and stable command response. + +### Risk 24: OpenCode Version Drift + +Problem: + +The plan references current OpenCode source and docs, but users can run different installed OpenCode versions. Event payload shapes can drift while still staying compatible at the HTTP level. + +Mitigation: + +- implement tolerant parsing for known string/object status variants and unknown event types; +- record OpenCode version, stream endpoint, and observed event type histogram in live test artifacts; +- avoid exact full-event snapshots in unit tests. Use targeted fixtures for session identity, status shape, error, heartbeat, and global payload wrapper; +- add a live smoke that dumps a compact compatibility report when `OPENCODE_TURN_SETTLED_LIVE=1`; +- if a future version drops `session.idle`, v1 still works through `session.status idle`. + +### Risk 25: User Prompt Persistence Looks Like Assistant Work + +Problem: + +OpenCode emits `message.updated user` and user text part events when it stores the prompt. If the observer treats those as activity, a prompt that only persisted user input and then idled or errored could be reported as a successful assistant turn. + +Mitigation: + +- ignore `message.updated` where `info.role === 'user'`; +- ignore parts whose `messageID` equals the generated OpenCode prompt `messageID`; +- track assistant message IDs from `message.updated assistant`; +- count assistant parts only when their message ID is known assistant, or when the part type is assistant-only such as `tool`, `step-start`, `step-finish`, or `reasoning`; +- count `session.status busy` as turn-start activity, but keep final success gated by later idle and no `session.error`; +- tests cover user-only prompt events followed by idle returning `idle_without_assistant_activity`. + +### 11.1 Highest-Risk Implementation Checks + +These are the checks to do first during implementation, before expanding tests broadly: + +1. **Command timeout budget.** Confirm the actual `OpenCodeBridgeCommandClient` timeout for `send-message` and `launch`. The sum of `waitUntilReady`, `waitForSettled`, response observation, and reconcile must stay below that budget with a safety margin. If not, cap the turn-settled wait dynamically. +2. **Host retention.** Add a test fake host manager that records retain/release order. The sequence must be `retain -> observe -> prompt -> waitForSettled -> dispose -> release`. +3. **Outcome derivation from real types.** Use actual `OpenCodeSessionPreviewSummary` and `OpenCodeSessionReconcileSummary` fields, not invented observer fields. Outcome helpers should be pure functions with fixture tests. +4. **Composition order.** Write a test or narrow integration seam proving OpenCode bridge env receives `AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT` before `OpenCodeBridgeCommandClient` construction. +5. **SSE schema drift.** Shared event helpers must parse `session.status` as string and object, and must unwrap `/global/event` payload events without trusting unrelated directories. +6. **Best-effort boundary.** Make emitter failure impossible to surface as delivery failure. The only accepted-prompt failure path should remain existing OpenCode delivery/reconcile logic. +7. **Prompt submit race.** Unit-test `submitting -> accepted` promotion and `submitting -> rejected` discard before wiring the observer into delivery. This is the highest-risk race in the design. +8. **No-reply bypass.** Confirm no-reply OpenCode prompts do not start the observer and do not emit runtime-turn-settled files. +9. **No replay assumption.** Treat SSE as best-effort current stream only. Reconcile remains the safety net for missed events. +10. **User prompt is not work.** Ensure `message.updated user` and prompt text parts do not satisfy `sawAssistantTurnActivity`. + +--- + +## 12. Alternatives Considered + +### Option 1: Bounded SSE Observer Per Prompt + +`šŸŽÆ 9 šŸ›”ļø 9 🧠 6`, roughly `850-1250 LOC`. + +Pros: + +- no OpenCode config mutation; +- exact prompt boundary; +- easy to test; +- works for launch and delivery; +- provider-specific logic stays in orchestrator. +- durable in the current short-lived bridge-command architecture because the command waits for a bounded outcome. +- preserves launch fan-out by reusing existing preview/reconcile settle lifecycle. +- avoids false timeout events by emitting only after observer + reconcile/preview evidence is merged. + +Cons: + +- one observer per prompt; +- needs careful timeout cleanup; +- can miss events if stream connection is delayed. +- adds bounded latency to OpenCode bridge command completion. + +Decision: choose this. + +### Option 2: Long-Lived Host-Level SSE Observer + +`šŸŽÆ 8 šŸ›”ļø 9 🧠 7`, roughly `650-1000 LOC`. + +Pros: + +- lower chance of missing fast events; +- one connection per host; +- can collect richer runtime diagnostics. + +Cons: + +- more lifecycle complexity; +- host lease cleanup risk; +- needs session-to-member registry updates; +- harder to prove no leaks. + +Decision: defer. Consider if per-prompt observer misses events in live validation. + +### Option 3: OpenCode Plugin `session.idle` + +`šŸŽÆ 6 šŸ›”ļø 6 🧠 5`, roughly `300-600 LOC`. + +Pros: + +- OpenCode officially documents `session.idle` plugin event; +- event is naturally emitted by OpenCode runtime. + +Cons: + +- requires plugin install/config mutation; +- risks user/project config conflict; +- harder to keep app-owned and reversible; +- less aligned with current OpenCode serve bridge. + +Decision: reject for v1. + +### Option 4: Poll `/session/status` + +`šŸŽÆ 6 šŸ›”ļø 7 🧠 3`, roughly `180-350 LOC`. + +Pros: + +- simple; +- no SSE parser. + +Cons: + +- less precise; +- more load; +- cannot distinguish error path as cleanly; +- slower reaction. + +Decision: use only as fallback if SSE has real misses. + +--- + +## 13. Test Plan + +### 13.1 Orchestrator Unit Tests + +Files: + +```text +src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.test.ts +src/services/opencode/OpenCodeTurnSettledObserver.test.ts +src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.test.ts +src/services/opencode/OpenCodeSessionBridge.test.ts +src/services/opencode/OpenCodeBridgeCommandHandler.test.ts +``` + +Cases: + +- emitter writes atomic `.opencode.json` file when env is present; +- emitter no-ops when env missing; +- command handler accepts missing coordinator and preserves delivery behavior; +- observer returns success evidence on post-prompt `session.status idle`; +- observer handles `session.status` where status is `{ type: 'idle' }`; +- preview observer still handles status string and status object after helper extraction; +- observer returns success evidence on post-prompt `session.idle`; +- observer returns error evidence when `session.error` precedes idle; +- observer does not misattribute sessionless `session.error` to a foreign session; +- observer records sessionless `session.error` diagnostic; +- observer resolves only once when both idle events arrive; +- observer ignores foreign session events; +- observer ignores `/global/event` events from a foreign directory; +- observer ignores `server.connected` and `server.heartbeat` as session activity; +- observer ignores `message.updated user` and user text prompt parts as assistant-turn activity; +- observer tracks assistant message IDs and counts assistant parts for those messages; +- observer treats prompt message parts with `messageID === runtimePromptMessageId` as prompt persistence, not assistant-turn activity; +- observer times out and returns timeout evidence; +- stream unavailable returns `stream_unavailable` evidence; +- premature stream EOF before terminal idle returns `stream_unavailable` with diagnostic `stream_closed_before_terminal_event`; +- observer buffers same-session activity and idle during `submitting`; +- observer buffers same-session `session.error` during `submitting` and promotes it to terminal error after endpoint acceptance; +- `markPromptAcceptedByEndpoint()` promotes buffered in-flight evidence into success; +- `markPromptRejectedByEndpoint()` discards buffered evidence and no emitter call happens; +- prompt failure after buffered idle does not produce a runtime turn-settled file; +- `204 -> session.error -> session.status idle` returns error unless response/reconcile proof upgrades it; +- `session.error` without later idle still returns error, not timeout; +- user-only prompt persistence followed by idle returns `idle_without_assistant_activity`, not `success`; +- coordinator writes one event per delivery emission; +- coordinator upgrades observer timeout to success when reconcile cursor advanced; +- coordinator upgrades stream unavailable to success when response observation proves visible/tool reply; +- coordinator keeps observer error when reconcile/response evidence does not prove activity; +- coordinator does not write both timeout and success for one prompt; +- coordinator emits launch success from preview activity; +- coordinator emits launch success from reconcile cursor advance when preview missed activity; +- orchestrator contract fixture is accepted by `claude_team` OpenCode normalizer; +- `promptAsync()` remains unchanged and sends no `messageID`; +- `promptAsyncWithTurnSettled()` starts observer before `promptSessionAsync`; +- `promptAsyncWithTurnSettled()` calls `waitUntilReady()` before prompt; +- `promptAsyncWithTurnSettled()` calls `markPromptSubmitting()` immediately before `submitPromptAsync()`; +- `promptAsyncWithTurnSettled()` calls `markPromptAcceptedByEndpoint()` only after `promptSessionAsync` resolves; +- `promptAsyncWithTurnSettled()` calls `markPromptRejectedByEndpoint()` when `promptSessionAsync` rejects; +- `promptAsyncWithTurnSettled()` calls `waitForSettled()` after prompt and before command return; +- `promptAsyncWithTurnSettled()` disposes observer when prompt request fails; +- delivery wrapper keeps host retained until settle/dispose; +- delivery wrapper releases host after settle/dispose; +- bounded observer timeout returns accepted delivery with internal observer outcome `timeout`; +- `noReply: true` delivery does not start turn-settled observation and writes no OpenCode runtime-turn-settled file; +- launch path reuses existing preview observation and does not open a second SSE stream; +- launch with 4 members does not add `4 * turnSettledTimeoutMs` to prompt submission time; +- delivery prompt passes turnSettled context; +- no observer context preserves old request body shape. +- bridge command data shape remains stable and final coordinator outcome appears in diagnostics. + +### 13.2 claude_team Unit Tests + +Files: + +```text +test/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.test.ts +test/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.test.ts +test/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.test.ts +test/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.test.ts +``` + +Cases: + +- OpenCode payload normalizes valid event; +- invalid JSON rejected; +- source mismatch rejected; +- missing session identity rejected; +- missing team/member rejected; +- error outcome preserved; +- resolver accepts active OpenCode member; +- resolver rejects non-OpenCode provider member; +- resolver rejects removed member; +- resolver rejects reserved member; +- env builder returns spool env for OpenCode; +- composition creates member-work-sync env provider before OpenCode bridge client captures env; +- file event store routes `.opencode.json` files to the OpenCode normalizer; +- ingestor enqueues OpenCode event once. +- orchestrator-generated contract fixture normalizes successfully. + +### 13.3 Integration Tests + +Add or update: + +```text +test/features/member-work-sync/MemberWorkSyncRuntimeTurnSettled.opencode.test.ts +``` + +Scenario: + +```text +given team with OpenCode member +and one actionable task +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 +``` + +### 13.4 Live E2E Prototype Test + +Add opt-in test: + +```text +src/services/opencode/OpenCodeTurnSettledObserver.live-e2e.test.ts +``` + +Gate: + +```text +OPENCODE_E2E=1 +OPENCODE_TURN_SETTLED_LIVE=1 +``` + +Default models: + +```text +opencode/gpt-5-nano +opencode/minimax-m2.5-free +``` + +If OpenAI model is used: + +```text +openai/gpt-5.4-mini-fast +``` + +Assertions: + +- server starts and cleans up; +- session is created; +- SSE stream connects; +- `prompt_async` accepted; +- observer returns terminal evidence before bridge command exits; +- coordinator writes one event after final evidence is derived; +- event has provider `opencode`; +- event has outcome `success` or `error`; +- error outcome includes diagnostic; +- all spawned `opencode serve` processes are killed by test cleanup. + +Live test must use a short prompt: + +```text +Reply with exactly OK. +``` + +No model matrix in this patch. + +### 13.5 Verification Commands + +In orchestrator worktree: + +```bash +cd /Users/belief/dev/projects/claude/_worktrees/agent_teams_orchestrator_opencode_turn_settled +bun test src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.test.ts src/services/opencode/OpenCodeTurnSettledObserver.test.ts src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.test.ts src/services/opencode/OpenCodeSessionBridge.test.ts src/services/opencode/OpenCodeBridgeCommandHandler.test.ts +bun run build:dev +git diff --check +``` + +In `claude_team` worktree: + +```bash +cd /Users/belief/dev/projects/claude/_worktrees/claude_team_member_work_sync_opencode +pnpm vitest run test/features/member-work-sync +pnpm typecheck --pretty false +git diff --check +``` + +Opt-in live: + +```bash +cd /Users/belief/dev/projects/claude/_worktrees/agent_teams_orchestrator_opencode_turn_settled +OPENCODE_E2E=1 OPENCODE_TURN_SETTLED_LIVE=1 bun test src/services/opencode/OpenCodeTurnSettledObserver.live-e2e.test.ts +``` + +--- + +## 14. Implementation Sequence + +### Cut 1: claude_team Contract Support + +`šŸŽÆ 9 šŸ›”ļø 9 🧠 4`, roughly `180-300 LOC`. + +Steps: + +1. Extend provider union with `opencode`. +2. Add provider-neutral `RuntimeTurnSettledSpoolInitializer`. +3. Extend runtime env builder. +4. Extend file store provider parsing for `.opencode.json`. +5. Add OpenCode normalizer. +6. Extend resolver through shared explicit-provider helper. +7. Add contract fixture test using the orchestrator payload shape. +8. Add tests. +9. Commit: + +```text +feat(member-work-sync): accept opencode turn-settled events +``` + +### Cut 2: Orchestrator Emitter And Observer + +`šŸŽÆ 9 šŸ›”ļø 8 🧠 5`, roughly `320-520 LOC`. + +Steps: + +1. Add OpenCode emitter. +2. Add OpenCode SSE observer. +3. Add OpenCode turn-settled emission coordinator. +4. Extract or duplicate SSE helpers safely. +5. Add generated contract fixture for `claude_team` normalizer tests. +6. Add observer/coordinator unit tests. +7. Add live e2e gate. +8. Commit: + +```text +feat(opencode): emit runtime turn-settled events +``` + +### Cut 3: Prompt Path Integration + +`šŸŽÆ 8 šŸ›”ļø 9 🧠 5`, roughly `240-420 LOC`. + +Steps: + +1. Keep `OpenCodeSessionBridge.promptAsync()` unchanged. +2. Add `promptAsyncWithTurnSettled()` as the delivery convenience wrapper. +3. Add coordinator usage to send-message after post-send reconcile/response observation. +4. Add coordinator usage to launch after existing preview + reconcile settle phase. +5. Keep launch prompt submission unchanged and avoid a second launch SSE stream. +6. Ensure failure path disposes observer. +7. Ensure observer wait is bounded and returns timeout evidence without failing accepted delivery. +8. Add regression tests for prompt request bodies, bounded wait behavior, and launch no-duplicate-stream behavior. +9. Commit: + +```text +feat(opencode): observe launch and delivery turn settlement +``` + +### Cut 4: App Launch Env Wiring + +`šŸŽÆ 8 šŸ›”ļø 9 🧠 5`, roughly `120-240 LOC`. + +Steps: + +1. Move `createMemberWorkSyncFeature(...)` before `createOpenCodeRuntimeAdapterRegistry()` call, without moving startup replay/scan side effects. +2. Register runtime turn-settled providers before OpenCode registry construction. +3. Wire env in `src/main/index.ts` before `OpenCodeBridgeCommandClient` construction. +4. Merge `memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' })`. +5. Add integration or safe e2e test proving env reaches bridge command constructor. +6. Commit: + +```text +feat(member-work-sync): pass opencode turn-settled spool env +``` + +### Cut 5: Full Verification + +`šŸŽÆ 9 šŸ›”ļø 9 🧠 3`, roughly test-only cleanup. + +Steps: + +1. Run targeted suites in both repos. +2. Run one OpenCode live e2e with cheap model. +3. Confirm no stray `opencode serve` process from tests. +4. Confirm no untracked temp artifacts. +5. Commit test fixture or doc updates if needed. + +--- + +## 15. Definition Of Done + +The implementation is done when: + +- `claude_team` accepts OpenCode runtime turn-settled spool events. +- OpenCode bridge process receives `AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT`. +- `promptAsync()` remains backward-compatible. +- Observed OpenCode delivery paths use `promptAsyncWithTurnSettled()`. +- Observed OpenCode launch paths derive final outcome from existing `observePreview()` + reconcile summaries without opening a second SSE stream. +- Observed OpenCode delivery paths start an observer before `prompt_async`. +- Observed OpenCode delivery paths wait a bounded telemetry budget before command return. +- `session.error` before idle produces `outcome: 'error'`. +- observer does not write spool files directly. +- coordinator emits one spool event after final local evidence is collected. +- observer timeout can be upgraded to success by response/reconcile proof. +- duplicate idle events produce one final spool event. +- missing spool env never breaks OpenCode delivery. +- `.opencode.json` spool files are accepted by the file event store. +- removed or non-OpenCode member events are rejected by resolver. +- `member-work-sync` reconciles from OpenCode event without direct frontend changes. +- tests pass in both repos. +- one opt-in live test proves actual `opencode serve` emits the expected lifecycle. + +--- + +## 16. Open Questions + +No blocker questions before implementation. + +Non-blocking decisions: + +- Whether to extract SSE helpers from `OpenCodePreviewObserver` immediately or duplicate in v1. Preferred: extract if tests remain small. +- Whether to pass explicit `messageID` on all observed prompts. Preferred: yes, only when `turnSettled` context is present. +- Whether to add polling fallback in v1. Preferred: no, add only if live e2e shows missed idle events. +- Whether to use a long-lived host-level observer later. Preferred: no for v1 because current bridge process lifecycle is command-scoped; revisit only if bounded per-prompt observer misses events in live tests. + +--- + +## 17. Final Recommendation + +Implement Option 1 in the cuts above. + +This gives OpenCode the same architectural role as Claude Stop hook and Codex native turn-settled: + +```text +provider-specific runtime signal +-> durable spool +-> provider normalizer +-> active team/member resolver +-> MemberWorkSync reconciler +``` + +It is more robust than a plain "ping after idle" loop and less invasive than OpenCode plugin hooks. It also keeps `member-work-sync` scalable for future providers. diff --git a/src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts b/src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts index c3a8dc27..7ea4ef8f 100644 --- a/src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts +++ b/src/features/member-work-sync/core/domain/RuntimeTurnSettledProvider.ts @@ -1,5 +1,5 @@ -export type RuntimeTurnSettledProvider = 'claude' | 'codex'; +export type RuntimeTurnSettledProvider = 'claude' | 'codex' | 'opencode'; export function isRuntimeTurnSettledProvider(value: unknown): value is RuntimeTurnSettledProvider { - return value === 'claude' || value === 'codex'; + return value === 'claude' || value === 'codex' || value === 'opencode'; } diff --git a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts index 0358ef8c..87c31205 100644 --- a/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts +++ b/src/features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver.ts @@ -80,7 +80,11 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT async resolve(event: RuntimeTurnSettledEvent): Promise { if (event.provider === 'codex') { - return this.resolveCodexEvent(event); + return this.resolveProviderOwnedEvent(event, 'codex'); + } + + if (event.provider === 'opencode') { + return this.resolveProviderOwnedEvent(event, 'opencode'); } if (event.provider !== 'claude') { @@ -154,8 +158,9 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT }; } - private async resolveCodexEvent( - event: RuntimeTurnSettledEvent + private async resolveProviderOwnedEvent( + event: RuntimeTurnSettledEvent, + expectedProviderId: 'codex' | 'opencode' ): Promise { const teamName = event.teamName?.trim(); const memberName = event.memberName?.trim(); @@ -172,7 +177,7 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT } const providerId = providerForMember(member); - if (providerId && providerId !== 'codex') { + if (providerId && providerId !== expectedProviderId) { return { ok: false, reason: 'provider_mismatch' }; } diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 4c7539f7..17ca5ac3 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -31,11 +31,9 @@ import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWo import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal'; import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; +import { OpenCodeTurnSettledPayloadNormalizer } from '../infrastructure/OpenCodeTurnSettledPayloadNormalizer'; import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler'; -import { buildRuntimeTurnSettledEnvironment } from '../infrastructure/runtimeTurnSettledEnvironment'; -import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings'; -import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths'; -import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller'; +import { RuntimeTurnSettledSpoolInitializer } from '../infrastructure/RuntimeTurnSettledSpoolInitializer'; import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter'; import type { @@ -82,6 +80,15 @@ export function resolveMemberWorkSyncNudgeSideEffectsEnabled( return false; } +export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: { + teamsBasePath: string; + provider: RuntimeTurnSettledProvider; +}): Promise | null> { + return new RuntimeTurnSettledSpoolInitializer(input.teamsBasePath).buildEnvironment({ + provider: input.provider, + }); +} + export interface MemberWorkSyncFeatureFacade { getStatus(request: MemberWorkSyncStatusRequest): Promise; getMetrics(request: MemberWorkSyncMetricsRequest): Promise; @@ -126,16 +133,14 @@ export function createMemberWorkSyncFeature(deps: { }); const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath); const store = new JsonMemberWorkSyncStore(storePaths); - const runtimeTurnSettledSpoolPaths = new RuntimeTurnSettledSpoolPaths(deps.teamsBasePath); - const runtimeTurnSettledHookInstaller = new ShellRuntimeTurnSettledHookScriptInstaller( - runtimeTurnSettledSpoolPaths - ); + const runtimeTurnSettledSpool = new RuntimeTurnSettledSpoolInitializer(deps.teamsBasePath); const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({ - paths: runtimeTurnSettledSpoolPaths, + paths: runtimeTurnSettledSpool.getPaths(), }); const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([ new ClaudeStopHookPayloadNormalizer(hash), new CodexNativeTurnSettledPayloadNormalizer(hash), + new OpenCodeTurnSettledPayloadNormalizer(hash), ]); const runtimeTurnSettledTargetResolver = deps.runtimeTurnSettledTargetResolver ?? @@ -257,27 +262,10 @@ export function createMemberWorkSyncFeature(deps: { claimedBy: `member-work-sync:${process.pid}`, }) : Promise.resolve(emptyNudgeDispatchSummary()), - buildRuntimeTurnSettledHookSettings: async ({ provider }) => { - if (provider !== 'claude') { - return null; - } - const installed = await runtimeTurnSettledHookInstaller.install(); - return buildRuntimeTurnSettledHookSettings({ - scriptPath: installed.scriptPath, - spoolRoot: installed.spoolRoot, - provider, - }); - }, - buildRuntimeTurnSettledEnvironment: async ({ provider }) => { - if (provider !== 'codex') { - return null; - } - const installed = await runtimeTurnSettledHookInstaller.install(); - return buildRuntimeTurnSettledEnvironment({ - provider, - spoolRoot: installed.spoolRoot, - }); - }, + buildRuntimeTurnSettledHookSettings: async ({ provider }) => + runtimeTurnSettledSpool.buildHookSettings({ provider }), + buildRuntimeTurnSettledEnvironment: async ({ provider }) => + runtimeTurnSettledSpool.buildEnvironment({ provider }), drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(), getQueueDiagnostics: () => queue.getDiagnostics(), dispose: async () => { diff --git a/src/features/member-work-sync/main/index.ts b/src/features/member-work-sync/main/index.ts index cc2f4c52..bed28883 100644 --- a/src/features/member-work-sync/main/index.ts +++ b/src/features/member-work-sync/main/index.ts @@ -5,6 +5,7 @@ export { } from './adapters/input/registerMemberWorkSyncIpc'; export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature'; export { + buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, resolveMemberWorkSyncNudgeSideEffectsEnabled, diff --git a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts index 29676835..fdc57e9e 100644 --- a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts +++ b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts @@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promis import path from 'path'; import { isRuntimeTurnSettledProvider } from '../../core/domain'; - +import type { RuntimeTurnSettledProvider } from '../../core/domain'; import type { RuntimeTurnSettledClaimedPayload, RuntimeTurnSettledEventStorePort, @@ -25,7 +25,7 @@ export interface FileRuntimeTurnSettledEventStoreDeps { now?: () => Date; } -function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null { +function parseProviderFromFileName(fileName: string): RuntimeTurnSettledProvider | null { const parts = fileName.split('.'); const provider = parts.length >= 3 ? parts[parts.length - 2] : null; return isRuntimeTurnSettledProvider(provider) ? provider : null; diff --git a/src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts new file mode 100644 index 00000000..eecd8375 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts @@ -0,0 +1,123 @@ +import { + buildRuntimeTurnSettledSourceId, + type RuntimeTurnSettledProvider, +} from '../../core/domain'; +import type { + MemberWorkSyncHashPort, + RuntimeTurnSettledPayloadNormalization, + RuntimeTurnSettledPayloadNormalizerPort, +} from '../../core/application'; + +const SUPPORTED_SOURCE = 'agent-teams-orchestrator-opencode'; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function getString(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value !== 'string') { + continue; + } + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return undefined; +} + +export class OpenCodeTurnSettledPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort { + constructor(private readonly hash: MemberWorkSyncHashPort) {} + + normalize(input: { + provider: RuntimeTurnSettledProvider; + raw: string; + recordedAt: string; + }): RuntimeTurnSettledPayloadNormalization { + if (input.provider !== 'opencode') { + return { ok: false, reason: 'unsupported_provider' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(input.raw); + } catch { + return { ok: false, reason: 'invalid_json' }; + } + + const payload = asRecord(parsed); + if (!payload) { + return { ok: false, reason: 'payload_not_object' }; + } + + const provider = getString(payload, 'provider'); + if (provider !== 'opencode') { + return { ok: false, reason: 'provider_mismatch' }; + } + + const source = getString(payload, 'source'); + if (source !== SUPPORTED_SOURCE) { + return { ok: false, reason: 'source_mismatch' }; + } + + const eventName = getString(payload, 'eventName', 'event_name'); + const hookEventName = getString(payload, 'hookEventName', 'hook_event_name'); + if (eventName !== 'runtime_turn_settled' && hookEventName !== 'Stop') { + return { ok: false, reason: 'not_turn_settled_event' }; + } + + const sessionId = getString(payload, 'sessionId', 'session_id', 'opencodeSessionId'); + const teamName = getString(payload, 'teamName', 'team_name', 'teamId', 'team_id'); + const memberName = getString(payload, 'memberName', 'member_name', 'agentName', 'agent_name'); + if (!sessionId) { + return { ok: false, reason: 'missing_session_identity' }; + } + if (!teamName || !memberName) { + return { ok: false, reason: 'missing_team_member_identity' }; + } + + const payloadHash = this.hash.sha256Hex(input.raw); + const promptMessageId = getString( + payload, + 'runtimePromptMessageId', + 'runtime_prompt_message_id', + 'promptMessageId', + 'prompt_message_id' + ); + const turnId = getString(payload, 'turnId', 'turn_id') ?? promptMessageId; + const cwd = getString(payload, 'cwd', 'projectPath', 'project_path'); + const agentId = getString(payload, 'agentId', 'agent_id', 'laneId', 'lane_id'); + const outcome = getString(payload, 'outcome'); + + return { + ok: true, + event: { + schemaVersion: 1, + provider: 'opencode', + hookEventName: 'Stop', + payloadHash, + recordedAt: + getString(payload, 'recordedAt', 'recorded_at', 'observedAt', 'observed_at') ?? + input.recordedAt, + sourceId: buildRuntimeTurnSettledSourceId({ + provider: 'opencode', + sessionId, + turnId, + payloadHash, + }), + sessionId, + ...(turnId ? { turnId } : {}), + ...(cwd ? { cwd } : {}), + teamName, + memberName, + ...(agentId ? { agentId } : {}), + ...(promptMessageId ? { threadId: promptMessageId } : {}), + ...(outcome ? { outcome } : {}), + }, + }; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts b/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts new file mode 100644 index 00000000..13c5c4e2 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts @@ -0,0 +1,47 @@ +import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller'; +import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths'; +import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment'; +import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings'; + +import type { RuntimeTurnSettledProvider } from '../../core/domain'; + +export class RuntimeTurnSettledSpoolInitializer { + private readonly paths: RuntimeTurnSettledSpoolPaths; + private readonly installer: ShellRuntimeTurnSettledHookScriptInstaller; + + constructor(teamsBasePath: string) { + this.paths = new RuntimeTurnSettledSpoolPaths(teamsBasePath); + this.installer = new ShellRuntimeTurnSettledHookScriptInstaller(this.paths); + } + + getPaths(): RuntimeTurnSettledSpoolPaths { + return this.paths; + } + + async buildHookSettings(input: { + provider: RuntimeTurnSettledProvider; + }): Promise | null> { + if (input.provider !== 'claude') { + return null; + } + const installed = await this.installer.install(); + return buildRuntimeTurnSettledHookSettings({ + scriptPath: installed.scriptPath, + spoolRoot: installed.spoolRoot, + provider: input.provider, + }); + } + + async buildEnvironment(input: { + provider: RuntimeTurnSettledProvider; + }): Promise | null> { + if (input.provider !== 'codex' && input.provider !== 'opencode') { + return null; + } + const installed = await this.installer.install(); + return buildRuntimeTurnSettledEnvironment({ + provider: input.provider, + spoolRoot: installed.spoolRoot, + }); + } +} diff --git a/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts b/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts index 13a24693..bca785ed 100644 --- a/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts +++ b/src/features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment.ts @@ -1,13 +1,12 @@ import type { RuntimeTurnSettledProvider } from '../../core/domain'; -export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = - 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT'; +export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT'; export function buildRuntimeTurnSettledEnvironment(input: { provider: RuntimeTurnSettledProvider; spoolRoot: string; }): Record | null { - if (input.provider !== 'codex') { + if (input.provider !== 'codex' && input.provider !== 'opencode') { return null; } return { diff --git a/src/main/index.ts b/src/main/index.ts index ebc0dc2e..a71f0a93 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,6 +31,7 @@ import { createCodexModelCatalogFeature, } from '@features/codex-model-catalog/main'; import { + buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, type MemberWorkSyncFeatureFacade, registerMemberWorkSyncIpc, @@ -246,6 +247,21 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise { }); }); + it('claims OpenCode runtime turn-settled payloads', async () => { + const paths = await makePaths(); + await fs.mkdir(paths.getIncomingDir(), { recursive: true }); + await fs.writeFile( + path.join(paths.getIncomingDir(), '20260429-1.opencode.json'), + '{"eventName":"runtime_turn_settled"}', + 'utf8' + ); + const store = new FileRuntimeTurnSettledEventStore({ + paths, + now: () => new Date('2026-04-29T12:00:00.000Z'), + }); + + const claimed = await store.claimPending(10); + + expect(claimed).toHaveLength(1); + expect(claimed[0]).toMatchObject({ + fileName: '20260429-1.opencode.json', + provider: 'opencode', + raw: '{"eventName":"runtime_turn_settled"}', + }); + }); + it('does not reclaim fresh processing payloads from an active drain', async () => { const paths = await makePaths(); await fs.mkdir(paths.getProcessingDir(), { recursive: true }); diff --git a/test/features/member-work-sync/main/OpenCodeTurnSettledPayloadNormalizer.test.ts b/test/features/member-work-sync/main/OpenCodeTurnSettledPayloadNormalizer.test.ts new file mode 100644 index 00000000..62e72b5d --- /dev/null +++ b/test/features/member-work-sync/main/OpenCodeTurnSettledPayloadNormalizer.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { OpenCodeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer'; +import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter'; + +describe('OpenCodeTurnSettledPayloadNormalizer', () => { + it('normalizes orchestrator-native OpenCode turn-settled payloads', () => { + const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()); + + const result = normalizer.normalize({ + provider: 'opencode', + raw: JSON.stringify({ + schemaVersion: 1, + provider: 'opencode', + source: 'agent-teams-orchestrator-opencode', + eventName: 'runtime_turn_settled', + hookEventName: 'Stop', + sessionId: 'ses-opencode-1', + runtimePromptMessageId: 'msg_123', + laneId: 'lane-jack', + memberName: 'jack', + teamName: 'team-a', + projectPath: '/tmp/project', + outcome: 'success', + observedAt: '2026-04-29T12:00:00.000Z', + }), + recordedAt: '2026-04-29T12:00:01.000Z', + }); + + expect(result).toEqual({ + ok: true, + event: expect.objectContaining({ + provider: 'opencode', + hookEventName: 'Stop', + sessionId: 'ses-opencode-1', + turnId: 'msg_123', + threadId: 'msg_123', + teamName: 'team-a', + memberName: 'jack', + agentId: 'lane-jack', + cwd: '/tmp/project', + outcome: 'success', + recordedAt: '2026-04-29T12:00:00.000Z', + }), + }); + }); + + it('rejects OpenCode payloads without durable team/member identity', () => { + const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()); + + expect( + normalizer.normalize({ + provider: 'opencode', + raw: JSON.stringify({ + provider: 'opencode', + source: 'agent-teams-orchestrator-opencode', + eventName: 'runtime_turn_settled', + sessionId: 'ses-opencode-1', + }), + recordedAt: '2026-04-29T12:00:01.000Z', + }) + ).toEqual({ ok: false, reason: 'missing_team_member_identity' }); + }); + + it('rejects payloads from non-agent-teams OpenCode sources', () => { + const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()); + + expect( + normalizer.normalize({ + provider: 'opencode', + raw: JSON.stringify({ + provider: 'opencode', + source: 'some-other-source', + eventName: 'runtime_turn_settled', + sessionId: 'ses-opencode-1', + teamName: 'team-a', + memberName: 'jack', + }), + recordedAt: '2026-04-29T12:00:01.000Z', + }) + ).toEqual({ ok: false, reason: 'source_mismatch' }); + }); +}); diff --git a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts index 81af0c74..4aedea90 100644 --- a/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts +++ b/test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts @@ -133,4 +133,63 @@ describe('TeamRuntimeTurnSettledTargetResolver', () => { }) ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); }); + + it('resolves OpenCode turn-settled payloads from durable team/member identity', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => { + throw new Error('opencode path should not scan attributed files'); + }), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [{ name: 'Jack', providerId: 'opencode' }], + }) satisfies TeamConfig), + }, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + memberLogsFinder: { + listAttributedMemberFiles: vi.fn(async () => []), + }, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'opencode', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + teamName: 'team-a', + memberName: 'jack', + }) + ).resolves.toEqual({ ok: true, teamName: 'team-a', memberName: 'jack' }); + }); + + it('rejects OpenCode events for non-OpenCode teammates', async () => { + const resolver = new TeamRuntimeTurnSettledTargetResolver({ + teamSource: { + listTeams: vi.fn(async () => []), + getConfig: vi.fn(async () => ({ + name: 'team-a', + members: [{ name: 'Jack', providerId: 'codex' }], + }) satisfies TeamConfig), + }, + membersMetaStore: { getMembers: vi.fn(async () => []) } as never, + }); + + await expect( + resolver.resolve({ + schemaVersion: 1, + provider: 'opencode', + hookEventName: 'Stop', + sourceId: 'source-1', + payloadHash: 'hash', + recordedAt: '2026-04-29T12:00:00.000Z', + sessionId: 'ses-1', + teamName: 'team-a', + memberName: 'jack', + }) + ).resolves.toEqual({ ok: false, reason: 'provider_mismatch' }); + }); }); diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index c31cad0d..9ba09292 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV, + buildMemberWorkSyncRuntimeTurnSettledEnvironment, createMemberWorkSyncFeature, resolveMemberWorkSyncNudgeSideEffectsEnabled, } from '@features/member-work-sync/main'; @@ -142,4 +143,50 @@ describe('createMemberWorkSyncFeature composition', () => { await feature.dispose(); } }); + + it('builds OpenCode turn-settled environment without requiring nudge side effects', async () => { + const root = makeTempRoot(); + const feature = createMemberWorkSyncFeature({ + teamsBasePath: root, + configReader: {} as never, + taskReader: {} as never, + kanbanManager: {} as never, + membersMetaStore: {} as never, + nudgeSideEffectsEnabled: false, + }); + + try { + const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' }); + expect(env).toEqual({ + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( + root, + '.member-work-sync/runtime-hooks' + ), + }); + await expect( + fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) + ).resolves.toMatchObject({ mode: expect.any(Number) }); + } finally { + await feature.dispose(); + } + }); + + it('builds OpenCode bridge environment before feature facade initialization', async () => { + const root = makeTempRoot(); + + const env = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ + teamsBasePath: root, + provider: 'opencode', + }); + + expect(env).toEqual({ + [RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join( + root, + '.member-work-sync/runtime-hooks' + ), + }); + await expect( + fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming')) + ).resolves.toMatchObject({ mode: expect.any(Number) }); + }); }); From 642cc16f65035ba3c6707ad2c7d9d38fddddd101 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 00:47:14 +0300 Subject: [PATCH 49/74] docs(member-work-sync): mark opencode turn-settled live verified --- .../member-work-sync-opencode-turn-settled-plan.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 7a5ef71c..37cb7355 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 @@ -1,6 +1,6 @@ # Member Work Sync OpenCode Turn-Settled Plan -- **Status:** implemented in `feat/member-work-sync-opencode-turn-settled` +- **Status:** implemented and live-verified in `feat/member-work-sync-opencode-turn-settled` - **Scope:** `member-work-sync`, OpenCode runtime turn-settled signal, OpenCode SSE observer - **Primary repo:** `claude_team` - **Secondary repo:** `agent_teams_orchestrator` @@ -11,8 +11,9 @@ Implemented verification: - `claude_team`: `pnpm exec vitest run test/features/member-work-sync/main/OpenCodeTurnSettledPayloadNormalizer.test.ts test/features/member-work-sync/main/CodexNativeTurnSettledPayloadNormalizer.test.ts test/features/member-work-sync/main/TeamRuntimeTurnSettledTargetResolver.test.ts test/features/member-work-sync/main/FileRuntimeTurnSettledEventStore.test.ts test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts` - `claude_team`: `pnpm typecheck --pretty false` -- `agent_teams_orchestrator`: `bun test src/services/opencode/OpenCodeSseEventStream.test.ts src/services/opencode/OpenCodeTurnSettledObserver.test.ts src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.test.ts src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.test.ts src/services/opencode/OpenCodeSessionBridge.test.ts src/services/opencode/OpenCodeBridgeCommandHandler.test.ts` +- `agent_teams_orchestrator`: `bun test src/services/opencode/OpenCodeSseEventStream.test.ts src/services/opencode/OpenCodePreviewObserver.test.ts src/services/opencode/OpenCodeTurnSettledObserver.test.ts src/services/opencode/OpenCodeRuntimeTurnSettledEmitter.test.ts src/services/opencode/OpenCodeTurnSettledEmissionCoordinator.test.ts src/services/opencode/OpenCodeSessionBridge.test.ts src/services/opencode/OpenCodeBridgeCommandHandler.test.ts` - `agent_teams_orchestrator`: `bun run build` +- `agent_teams_orchestrator`: `OPENCODE_E2E=1 OPENCODE_TURN_SETTLED_LIVE=1 bun test src/services/opencode/OpenCodeTurnSettledObserver.live-e2e.test.ts` - both repos: `git diff --check` --- From f0a5cf76178abd7a0c96b1aaa207f0ce7324cce9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 12:39:15 +0300 Subject: [PATCH 50/74] test(opencode): await live provisioning cleanup --- .../team/OpenCodeTeamProvisioning.live.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index f40e0ceb..c4a3a758 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -161,13 +161,22 @@ liveDescribe('OpenCode team provisioning live e2e', () => { }, }); - svc.stopTeam(teamName); + await svc.stopTeam(teamName); await waitUntil(async () => { const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); return Object.keys(laneIndex.lanes).length === 0; }, 90_000); } finally { - svc.stopTeam(teamName); + await svc.stopTeam(teamName).catch(() => undefined); + await readinessBridge + .cleanupOpenCodeHosts({ + reason: 'opencode-team-provisioning-live-e2e-cleanup', + mode: 'force', + projectPath: PROJECT_PATH, + staleAgeMs: null, + leaseStaleAgeMs: null, + }) + .catch(() => undefined); } }, 300_000); }); From 0b28d8f4b21bc6f36fa43ed6921f7e1d8ad2c9ee Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 12:40:32 +0300 Subject: [PATCH 51/74] fix(member-work-sync): ignore non-terminal opencode turns --- .../application/RuntimeTurnSettledIngestor.ts | 34 +++++ .../application/RuntimeTurnSettledPorts.ts | 2 +- .../bridge/OpenCodeReadinessBridge.ts | 4 +- .../main/RuntimeTurnSettledIngestor.test.ts | 140 ++++++++++++++++++ 4 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts b/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts index 4dc6ee5a..a745557d 100644 --- a/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts +++ b/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts @@ -1,4 +1,5 @@ import type { MemberWorkSyncClockPort, MemberWorkSyncLoggerPort } from './ports'; +import type { RuntimeTurnSettledEvent } from '../domain'; import type { RuntimeTurnSettledEventStorePort, RuntimeTurnSettledPayloadNormalizerPort, @@ -19,10 +20,30 @@ export interface RuntimeTurnSettledDrainSummary { claimed: number; enqueued: number; unresolved: number; + ignored: number; invalid: number; failed: number; } +const NON_TERMINAL_OPENCODE_OUTCOMES = new Set([ + 'timeout', + 'stream_unavailable', + 'prompt_rejected', + 'idle_without_assistant_activity', + 'unknown', +]); + +function getIgnoredReason(event: RuntimeTurnSettledEvent): string | null { + if (event.provider !== 'opencode') { + return null; + } + const outcome = event.outcome?.trim(); + if (!outcome || !NON_TERMINAL_OPENCODE_OUTCOMES.has(outcome)) { + return null; + } + return `opencode_non_terminal_outcome:${outcome}`; +} + export class RuntimeTurnSettledIngestor { constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {} @@ -31,6 +52,7 @@ export class RuntimeTurnSettledIngestor { claimed: 0, enqueued: 0, unresolved: 0, + ignored: 0, invalid: 0, failed: 0, }; @@ -56,6 +78,18 @@ export class RuntimeTurnSettledIngestor { continue; } + const ignoredReason = getIgnoredReason(normalized.event); + if (ignoredReason) { + summary.ignored += 1; + await this.deps.eventStore.markProcessed(payload, { + event: normalized.event, + outcome: 'ignored', + reason: ignoredReason, + processedAt, + }); + continue; + } + const resolution = await this.deps.targetResolver.resolve(normalized.event); if (!resolution.ok) { summary.unresolved += 1; diff --git a/src/features/member-work-sync/core/application/RuntimeTurnSettledPorts.ts b/src/features/member-work-sync/core/application/RuntimeTurnSettledPorts.ts index ededdf62..5bccd83d 100644 --- a/src/features/member-work-sync/core/application/RuntimeTurnSettledPorts.ts +++ b/src/features/member-work-sync/core/application/RuntimeTurnSettledPorts.ts @@ -25,7 +25,7 @@ export interface RuntimeTurnSettledProcessedResult { event: RuntimeTurnSettledEvent; teamName?: string; memberName?: string; - outcome: 'enqueued' | 'unresolved' | 'duplicate'; + outcome: 'enqueued' | 'unresolved' | 'duplicate' | 'ignored'; reason?: string; processedAt: string; } diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index ad4dff69..373d3e9e 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -65,7 +65,9 @@ export interface OpenCodeReadinessBridgeCommandBody { const DEFAULT_READINESS_TIMEOUT_MS = 120_000; const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000; const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000; -const DEFAULT_SEND_TIMEOUT_MS = 30_000; +// Longer than the renderer-facing UI timeout: late OpenCode turns should still +// finish bridge-side observation and emit member-work-sync signals. +const DEFAULT_SEND_TIMEOUT_MS = 45_000; const DEFAULT_OBSERVE_TIMEOUT_MS = 8_000; const DEFAULT_STOP_TIMEOUT_MS = 30_000; const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000; diff --git a/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts b/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts index 064799a2..d536ba8d 100644 --- a/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts +++ b/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { RuntimeTurnSettledIngestor } from '@features/member-work-sync/core/application'; import { ClaudeStopHookPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer'; import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter'; +import { OpenCodeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer'; import type { RuntimeTurnSettledClaimedPayload, @@ -23,6 +24,17 @@ function makePayload(raw: string): RuntimeTurnSettledClaimedPayload { }; } +function makeOpenCodePayload(raw: string): RuntimeTurnSettledClaimedPayload { + return { + id: 'event-1.opencode.json', + fileName: 'event-1.opencode.json', + filePath: '/tmp/event-1.opencode.json', + provider: 'opencode', + raw, + claimedAt: '2026-04-29T12:00:00.000Z', + }; +} + describe('RuntimeTurnSettledIngestor', () => { it('normalizes Claude Stop payloads and enqueues resolved member reconcile', async () => { const payload = makePayload( @@ -57,6 +69,7 @@ describe('RuntimeTurnSettledIngestor', () => { claimed: 1, enqueued: 1, unresolved: 0, + ignored: 0, invalid: 0, failed: 0, }); @@ -133,6 +146,7 @@ describe('RuntimeTurnSettledIngestor', () => { await expect(ingestor.drainPending()).resolves.toMatchObject({ claimed: 1, unresolved: 1, + ignored: 0, enqueued: 0, }); expect(processed[0]).toMatchObject({ @@ -140,4 +154,130 @@ describe('RuntimeTurnSettledIngestor', () => { reason: 'no_matching_member_session', }); }); + + it('normalizes OpenCode turn-settled payloads and enqueues provider-owned member reconcile', async () => { + const payload = makeOpenCodePayload( + JSON.stringify({ + schemaVersion: 1, + provider: 'opencode', + source: 'agent-teams-orchestrator-opencode', + eventName: 'runtime_turn_settled', + hookEventName: 'Stop', + sessionId: 'ses-opencode-1', + runtimePromptMessageId: 'msg_123', + laneId: 'secondary:opencode:jack', + memberName: 'jack', + teamName: 'team-a', + cwd: '/tmp/project', + outcome: 'success', + recordedAt: '2026-04-29T12:00:00.000Z', + }) + ); + const processed: RuntimeTurnSettledProcessedResult[] = []; + const store: RuntimeTurnSettledEventStorePort = { + claimPending: vi.fn(async () => [payload]), + markProcessed: vi.fn(async (_payload, result) => { + processed.push(result); + }), + markInvalid: vi.fn(), + }; + const resolver: RuntimeTurnSettledTargetResolverPort = { + resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'jack' })), + }; + const enqueueRuntimeTurnSettled = vi.fn(); + + const ingestor = new RuntimeTurnSettledIngestor({ + eventStore: store, + normalizer: new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()), + targetResolver: resolver, + reconcileQueue: { enqueueRuntimeTurnSettled }, + clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, + }); + + await expect(ingestor.drainPending()).resolves.toMatchObject({ + claimed: 1, + enqueued: 1, + invalid: 0, + unresolved: 0, + ignored: 0, + }); + expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'jack', + event: expect.objectContaining({ + provider: 'opencode', + hookEventName: 'Stop', + sessionId: 'ses-opencode-1', + turnId: 'msg_123', + teamName: 'team-a', + memberName: 'jack', + agentId: 'secondary:opencode:jack', + outcome: 'success', + }), + }); + expect(processed[0]).toMatchObject({ + outcome: 'enqueued', + teamName: 'team-a', + memberName: 'jack', + }); + }); + + it.each([ + 'timeout', + 'stream_unavailable', + 'prompt_rejected', + 'idle_without_assistant_activity', + 'unknown', + ])('ignores non-terminal OpenCode outcome %s without enqueueing premature reconcile', async (outcome) => { + const payload = makeOpenCodePayload( + JSON.stringify({ + schemaVersion: 1, + provider: 'opencode', + source: 'agent-teams-orchestrator-opencode', + eventName: 'runtime_turn_settled', + hookEventName: 'Stop', + sessionId: 'ses-opencode-1', + runtimePromptMessageId: 'msg_123', + laneId: 'secondary:opencode:jack', + memberName: 'jack', + teamName: 'team-a', + outcome, + recordedAt: '2026-04-29T12:00:00.000Z', + }) + ); + const processed: RuntimeTurnSettledProcessedResult[] = []; + const store: RuntimeTurnSettledEventStorePort = { + claimPending: vi.fn(async () => [payload]), + markProcessed: vi.fn(async (_payload, result) => { + processed.push(result); + }), + markInvalid: vi.fn(), + }; + const resolver: RuntimeTurnSettledTargetResolverPort = { + resolve: vi.fn(), + }; + const enqueueRuntimeTurnSettled = vi.fn(); + + const ingestor = new RuntimeTurnSettledIngestor({ + eventStore: store, + normalizer: new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()), + targetResolver: resolver, + reconcileQueue: { enqueueRuntimeTurnSettled }, + clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, + }); + + await expect(ingestor.drainPending()).resolves.toMatchObject({ + claimed: 1, + enqueued: 0, + unresolved: 0, + ignored: 1, + invalid: 0, + }); + expect(resolver.resolve).not.toHaveBeenCalled(); + expect(enqueueRuntimeTurnSettled).not.toHaveBeenCalled(); + expect(processed[0]).toMatchObject({ + outcome: 'ignored', + reason: `opencode_non_terminal_outcome:${outcome}`, + }); + }); }); From f391dda3cb6d93386b156f83a0eb3d98843b042f Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 14:02:23 +0300 Subject: [PATCH 52/74] fix(opencode): pass full MCP launch args --- src/main/index.ts | 1 + .../team/OpenCodeMixedRecovery.live.test.ts | 2 ++ .../OpenCodeTeamProvisioning.live.test.ts | 1 + .../TeamProvisioningServicePrepare.test.ts | 23 ++++++++++--------- .../services/team/openCodeLiveTestHarness.ts | 1 + 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index a71f0a93..3e1fca2a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -268,6 +268,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise { XDG_DATA_HOME: path.join(tempDir, 'xdg-data-single'), CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, @@ -182,6 +183,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { XDG_DATA_HOME: path.join(tempDir, 'xdg-data-multi'), CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index c4a3a758..0058beff 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -69,6 +69,7 @@ liveDescribe('OpenCode team provisioning live e2e', () => { XDG_DATA_HOME: path.join(tempDir, 'xdg-data'), CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), }; const bridgeClient = new OpenCodeBridgeCommandClient({ binaryPath: orchestratorCli, diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index cfc6d44a..53cddd7f 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -128,22 +128,23 @@ function getRealAgentTeamsMcpLaunchSpec(): { command: string; args: string[] } { } } - const distEntry = path.join(workspaceRoot, 'mcp-server', 'dist', 'index.js'); - if (fs.existsSync(distEntry)) { + const tsxCommand = path.join( + workspaceRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'tsx.cmd' : 'tsx' + ); + if (fs.existsSync(sourceEntry) && fs.existsSync(tsxCommand)) { return { - command: process.execPath, - args: [distEntry], + command: tsxCommand, + args: [sourceEntry], }; } + const distEntry = path.join(workspaceRoot, 'mcp-server', 'dist', 'index.js'); return { - command: path.join( - workspaceRoot, - 'node_modules', - '.bin', - process.platform === 'win32' ? 'tsx.cmd' : 'tsx' - ), - args: [path.join(workspaceRoot, 'mcp-server', 'src', 'index.ts')], + command: process.execPath, + args: [distEntry], }; } diff --git a/test/main/services/team/openCodeLiveTestHarness.ts b/test/main/services/team/openCodeLiveTestHarness.ts index 3f450222..174b49e3 100644 --- a/test/main/services/team/openCodeLiveTestHarness.ts +++ b/test/main/services/team/openCodeLiveTestHarness.ts @@ -73,6 +73,7 @@ export async function createOpenCodeLiveHarness(input: { CLAUDE_TEAM_CONTROL_URL: controlApi.baseUrl, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), }; if (process.env.OPENCODE_E2E_USE_REAL_APP_CREDENTIALS !== '1') { bridgeEnv.XDG_DATA_HOME = path.join(input.tempDir, 'xdg-data'); From 62691e203d6b9f9dfc91d56b127a8261ef64b752 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 14:48:00 +0300 Subject: [PATCH 53/74] test(member-work-sync): clean claude live temp dirs --- .../MemberWorkSyncClaudeStopHook.live.test.ts | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index 6116e207..6c07fede 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMemberWorkSyncFeature, @@ -94,6 +94,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { let previousDisableAppBootstrap: string | undefined; let previousDisableRuntimeBootstrap: string | undefined; let previousHome: string | undefined; + let previousHistFile: string | undefined; let previousUserProfile: string | undefined; let svc: TeamProvisioningService | null; let feature: MemberWorkSyncFeatureFacade | null; @@ -115,9 +116,11 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; previousHome = process.env.HOME; + previousHistFile = process.env.HISTFILE; previousUserProfile = process.env.USERPROFILE; process.env.HOME = tempHome; + process.env.HISTFILE = '/dev/null'; process.env.USERPROFILE = tempHome; process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; @@ -149,15 +152,20 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap); restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap); restoreEnv('HOME', previousHome); + restoreEnv('HISTFILE', previousHistFile); restoreEnv('USERPROFILE', previousUserProfile); setClaudeBasePathOverride(null); if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') { console.info(`[MemberWorkSyncClaudeStopHook.live] preserved temp dir: ${tempDir}`); } else { - await fs.rm(tempDir, { recursive: true, force: true }); + await removeTempDirAfterLateShellWrites(tempDir); } }); + afterAll(async () => { + await cleanupScopedClaudeStopHookLiveTempDirs(); + }); + async function runClaudeStopHookLiveScenario( scenario: ClaudeStopHookLiveScenario ): Promise { @@ -497,3 +505,34 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { 420_000 ); }); + +async function removeTempDirAfterLateShellWrites(tempDir: string): Promise { + // Claude Code can leave child shells that write ~/.zsh_history just after stopTeam cleanup. + // Bounded repeated passes keep live tests from leaving tiny recreated HOME directories behind. + for (let attempt = 0; attempt < 6; attempt += 1) { + await fs.rm(tempDir, { recursive: true, force: true }); + if (attempt < 5) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } +} + +async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise { + const tmpRoot = os.tmpdir(); + for (let attempt = 0; attempt < 6; attempt += 1) { + let entries: Array<{ isDirectory(): boolean; name: string }>; + try { + entries = await fs.readdir(tmpRoot, { withFileTypes: true }); + } catch { + return; + } + await Promise.all( + entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith('member-work-sync-claude-stop-live-')) + .map((entry) => fs.rm(path.join(tmpRoot, entry.name), { recursive: true, force: true })) + ); + if (attempt < 5) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } +} From 9fb9e5f66a556db78452098efbeed3837c8b500d Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 19:53:33 +0300 Subject: [PATCH 54/74] chore: snapshot dev work sync state --- .../src/internal/runtime.js | 111 +- .../test/controller.test.js | 149 ++ .../member-work-sync-debugging.md | 40 + .../core/application/MemberWorkSyncAudit.ts | 44 + .../MemberWorkSyncNudgeDispatcher.ts | 40 +- .../MemberWorkSyncNudgeOutboxPlanner.ts | 30 +- .../application/MemberWorkSyncReconciler.ts | 29 + .../application/MemberWorkSyncReporter.ts | 38 + .../application/RuntimeTurnSettledIngestor.ts | 59 +- .../core/application/index.ts | 1 + .../core/application/ports.ts | 50 + .../input/MemberWorkSyncTeamChangeRouter.ts | 14 +- .../createMemberWorkSyncFeature.ts | 15 +- .../FileMemberWorkSyncAuditJournal.ts | 157 ++ .../infrastructure/JsonMemberWorkSyncStore.ts | 1275 +++++++++++++---- .../MemberWorkSyncEventQueue.ts | 71 +- .../MemberWorkSyncStorePaths.ts | 72 +- .../infrastructure/EventLoopLagMonitor.ts | 19 +- src/main/services/team/TeamBackupService.ts | 6 +- .../services/team/TeamLaunchStateEvaluator.ts | 1 + .../services/team/TeamMemberStoragePaths.ts | 109 ++ .../services/team/TeamProvisioningService.ts | 230 ++- .../OpenCodeRuntimeManifestEvidenceReader.ts | 63 +- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 32 +- .../components/team/CliLogsRichView.tsx | 8 +- .../team/members/MemberDetailDialog.tsx | 4 +- src/shared/types/team.ts | 2 + .../core/MemberWorkSyncUseCases.test.ts | 39 +- .../FileMemberWorkSyncAuditJournal.test.ts | 113 ++ .../main/JsonMemberWorkSyncStore.test.ts | 459 +++++- .../main/MemberWorkSyncEventQueue.test.ts | 7 + .../main/RuntimeTurnSettledIngestor.test.ts | 17 + ...nCodeRuntimeManifestEvidenceReader.test.ts | 114 ++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 8 +- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 214 ++- .../services/team/TeamBackupService.test.ts | 80 ++ .../team/TeamMemberStoragePaths.test.ts | 76 + .../team/TeamProvisioningService.test.ts | 853 ++++++++++- 38 files changed, 4195 insertions(+), 454 deletions(-) create mode 100644 docs/team-management/member-work-sync-debugging.md create mode 100644 src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts create mode 100644 src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts create mode 100644 src/main/services/team/TeamMemberStoragePaths.ts create mode 100644 test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts create mode 100644 test/main/services/team/TeamMemberStoragePaths.test.ts diff --git a/agent-teams-controller/src/internal/runtime.js b/agent-teams-controller/src/internal/runtime.js index a798b6bc..ed328839 100644 --- a/agent-teams-controller/src/internal/runtime.js +++ b/agent-teams-controller/src/internal/runtime.js @@ -8,6 +8,9 @@ const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000; const POLL_INTERVAL_MS = 1000; const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json'; const RETRYABLE_CONTROL_ERROR = 'retryableControlError'; +const BOOTSTRAP_CHECKIN_MAX_ATTEMPTS = 3; +const BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS = 4000; +const BOOTSTRAP_CHECKIN_RETRY_DELAYS_MS = [300, 900]; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -67,9 +70,15 @@ function resolveControlBaseUrls(context, flags = {}) { return candidates; } -function makeRetryableControlError(message, cause) { +function makeRetryableControlError(message, cause, metadata = {}) { const error = new Error(message); error[RETRYABLE_CONTROL_ERROR] = true; + if (metadata.kind) { + error.retryableKind = metadata.kind; + } + if (metadata.statusCode) { + error.statusCode = metadata.statusCode; + } if (cause) { error.cause = cause; } @@ -114,7 +123,9 @@ async function requestJson(baseUrl, pathname, options = {}) { : `${response.status} ${response.statusText}`.trim(); if (isRetryableStatusCode(response.status)) { throw makeRetryableControlError( - `Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}` + `Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}`, + undefined, + { kind: 'status', statusCode: response.status } ); } throw new Error(detail || 'Team control API request failed'); @@ -122,19 +133,24 @@ async function requestJson(baseUrl, pathname, options = {}) { if (payload == null) { throw makeRetryableControlError( - `Team control API returned empty or non-JSON response at ${baseUrl}${pathname}` + `Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`, + undefined, + { kind: 'empty' } ); } return payload; } catch (error) { if (error && error.name === 'AbortError') { - throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error); + throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error, { + kind: 'timeout', + }); } if (error && error.name === 'TypeError') { throw makeRetryableControlError( `Failed to reach team control API at ${baseUrl}: ${error.message || 'fetch failed'}`, - error + error, + { kind: 'network' } ); } throw error; @@ -161,6 +177,54 @@ async function requestJsonWithFallback(baseUrls, pathname, options = {}) { throw lastError || new Error('Team control API request failed'); } +function isBootstrapCheckinRetryableControlError(error) { + if (!isRetryableControlError(error)) { + return false; + } + + if (error.retryableKind === 'timeout' || error.retryableKind === 'network') { + return true; + } + + if (error.retryableKind === 'status') { + return typeof error.statusCode === 'number' && error.statusCode >= 500; + } + + return false; +} + +async function requestJsonWithBoundedRetry(baseUrls, pathname, options = {}, retryOptions = {}) { + const maxAttempts = Math.max(1, retryOptions.maxAttempts || 1); + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const result = await requestJsonWithFallback(baseUrls, pathname, options); + if (attempt > 1 && result && typeof result === 'object' && !Array.isArray(result)) { + return { + ...result, + diagnostics: uniqueNonEmpty([ + ...(Array.isArray(result.diagnostics) ? result.diagnostics : []), + 'opencode_bootstrap_checkin_retry', + ]), + }; + } + return result; + } catch (error) { + lastError = error; + if (attempt >= maxAttempts || !isBootstrapCheckinRetryableControlError(error)) { + throw error; + } + const delayMs = retryOptions.delaysMs?.[attempt - 1] || 0; + if (delayMs > 0) { + await sleep(delayMs); + } + } + } + + throw lastError || new Error('Team control API request failed'); +} + function buildLaunchRequest(flags = {}) { const cwd = typeof flags.cwd === 'string' ? flags.cwd.trim() : ''; if (!cwd) { @@ -412,18 +476,31 @@ async function getRuntimeState(context, flags = {}) { } async function runtimeBootstrapCheckin(context, flags = {}) { - return postRuntimeTool( - context, - flags, - 'bootstrap-checkin', - compactRuntimeToolBody(context, flags, [ - 'runId', - 'memberName', - 'runtimeSessionId', - 'observedAt', - 'diagnostics', - 'metadata', - ]) + const baseUrls = resolveControlBaseUrls(context, flags); + const explicitTimeoutMs = flags.waitTimeoutMs || flags['wait-timeout-ms']; + const timeoutMs = Math.min( + normalizeTimeoutMs(explicitTimeoutMs || BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS), + BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS + ); + return requestJsonWithBoundedRetry( + baseUrls, + `/api/teams/${encodeURIComponent(context.teamName)}/opencode/runtime/bootstrap-checkin`, + { + method: 'POST', + body: compactRuntimeToolBody(context, flags, [ + 'runId', + 'memberName', + 'runtimeSessionId', + 'observedAt', + 'diagnostics', + 'metadata', + ]), + timeoutMs, + }, + { + maxAttempts: BOOTSTRAP_CHECKIN_MAX_ATTEMPTS, + delaysMs: BOOTSTRAP_CHECKIN_RETRY_DELAYS_MS, + } ); } diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 6a6a32b7..1cfb6144 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -2251,6 +2251,155 @@ describe('agent-teams-controller API', () => { } }); + it('retries OpenCode bootstrap check-in on retryable control API failures', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (calls.length < 3) { + return { statusCode: 500, body: { error: 'temporary bootstrap failure' } }; + } + return { body: { ok: true, state: 'accepted', diagnostics: [] } }; + }); + + try { + const result = await controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + + expect(result).toMatchObject({ + ok: true, + state: 'accepted', + diagnostics: expect.arrayContaining(['opencode_bootstrap_checkin_retry']), + }); + expect(calls).toHaveLength(3); + expect(calls.map((call) => call.body)).toEqual([ + { + teamName: 'my-team', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }, + { + teamName: 'my-team', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }, + { + teamName: 'my-team', + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }, + ]); + } finally { + await server.close(); + } + }); + + it('accepts idempotent OpenCode bootstrap check-in after a timed-out committed request', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + let committed = false; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + if (!committed) { + committed = true; + await new Promise((resolve) => setTimeout(resolve, 1200)); + return { body: { ok: true, state: 'accepted', diagnostics: [] } }; + } + return { + body: { + ok: true, + state: 'accepted', + diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'], + }, + }; + }); + + try { + const result = await controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + waitTimeoutMs: 1000, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }); + + expect(result).toMatchObject({ + ok: true, + state: 'accepted', + diagnostics: expect.arrayContaining([ + 'opencode_bootstrap_checkin_duplicate_accepted', + 'opencode_bootstrap_checkin_retry', + ]), + }); + expect(calls).toHaveLength(2); + } finally { + await server.close(); + } + }); + + it('does not retry OpenCode bootstrap check-in on validation failures', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + return { statusCode: 400, body: { error: 'invalid bootstrap payload' } }; + }); + + try { + await expect( + controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }) + ).rejects.toThrow('invalid bootstrap payload'); + expect(calls).toHaveLength(1); + } finally { + await server.close(); + } + }); + + it('fails OpenCode bootstrap check-in clearly after bounded timeout retries', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + await new Promise((resolve) => setTimeout(resolve, 1200)); + return { body: { ok: true, state: 'accepted' } }; + }); + + try { + await expect( + controller.runtime.runtimeBootstrapCheckin({ + controlUrl: server.baseUrl, + waitTimeoutMs: 1000, + runId: 'run-oc', + memberName: 'bob', + runtimeSessionId: 'ses-1', + }) + ).rejects.toThrow('Timed out calling team control API'); + expect(calls).toHaveLength(3); + } finally { + await server.close(); + } + }); + it('forwards member work sync status and reports to the app validator', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/docs/team-management/member-work-sync-debugging.md b/docs/team-management/member-work-sync-debugging.md new file mode 100644 index 00000000..9f3d5f95 --- /dev/null +++ b/docs/team-management/member-work-sync-debugging.md @@ -0,0 +1,40 @@ +# Member Work Sync Debugging + +`member-work-sync` stores member-scoped control-plane state under each team member: + +```text +~/.claude/teams//members//.member-work-sync/ + status.json + reports.json + outbox.json + journal.jsonl +``` + +`member-key` is the normalized, percent-encoded member name. The canonical name is stored in: + +```text +~/.claude/teams//members//member.meta.json +``` + +Use the journal for local debugging: + +```bash +tail -f ~/.claude/teams//members//.member-work-sync/journal.jsonl +``` + +The journal is append-only JSONL and records sync decisions, not raw agent transcripts. Useful events: + +- `reconcile_started`, `agenda_loaded`, `decision_made`, `status_written` +- `report_received`, `report_accepted`, `report_rejected` +- `nudge_planned`, `nudge_delivered`, `nudge_skipped`, `nudge_retryable`, `nudge_superseded` +- `member_busy`, `watchdog_cooldown_active`, `team_inactive`, `legacy_fallback_used` + +Team-level shared/index state remains under: + +```text +~/.claude/teams//.member-work-sync/ + indexes/ + report-token-secret.json +``` + +The indexes are implementation details used to avoid scanning every member directory on the hot path. diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts b/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts new file mode 100644 index 00000000..f2a52b09 --- /dev/null +++ b/src/features/member-work-sync/core/application/MemberWorkSyncAudit.ts @@ -0,0 +1,44 @@ +import type { + MemberWorkSyncAuditEvent, + MemberWorkSyncAuditEventName, + MemberWorkSyncUseCaseDeps, +} from './ports'; + +export type MemberWorkSyncAuditEventInput = Omit & { + timestamp?: string; +}; + +export async function appendMemberWorkSyncAudit( + deps: Pick, + input: MemberWorkSyncAuditEventInput +): Promise { + if (!deps.auditJournal) { + return; + } + try { + await deps.auditJournal.append({ + ...input, + timestamp: input.timestamp ?? deps.clock.now().toISOString(), + }); + } catch (error) { + deps.logger?.warn('member work sync audit event failed', { + teamName: input.teamName, + memberName: input.memberName, + event: input.event, + error: String(error), + }); + } +} + +export function reasonToAuditEvent(reason: string): MemberWorkSyncAuditEventName { + if (reason.startsWith('member_busy:')) { + return 'member_busy'; + } + if (reason === 'watchdog_cooldown_active') { + return 'watchdog_cooldown_active'; + } + if (reason === 'team_inactive') { + return 'team_inactive'; + } + return 'nudge_skipped'; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index ebcbe89f..81902547 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -1,5 +1,6 @@ import type { MemberWorkSyncOutboxItem } from '../../contracts'; -import type { MemberWorkSyncUseCaseDeps } from './ports'; +import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit'; +import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports'; const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; const MEMBER_WORK_SYNC_RETRY_BASE_MINUTES = 10; @@ -50,7 +51,9 @@ function nextRetryAt(item: MemberWorkSyncOutboxItem, nowIso: string): string { export class MemberWorkSyncNudgeDispatcher { constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {} - async dispatchDue(options: MemberWorkSyncNudgeDispatchOptions): Promise { + async dispatchDue( + options: MemberWorkSyncNudgeDispatchOptions + ): Promise { const outbox = this.deps.outboxStore; const inbox = this.deps.inboxNudge; if (!outbox || !inbox) { @@ -59,7 +62,9 @@ export class MemberWorkSyncNudgeDispatcher { const nowIso = this.deps.clock.now().toISOString(); const summary = emptySummary(); - for (const teamName of [...new Set(options.teamNames.map((name) => name.trim()).filter(Boolean))]) { + for (const teamName of [ + ...new Set(options.teamNames.map((name) => name.trim()).filter(Boolean)), + ]) { const claimed = await outbox.claimDue({ teamName, claimedBy: options.claimedBy, @@ -97,6 +102,11 @@ export class MemberWorkSyncNudgeDispatcher { nowIso, nextAttemptAt: revalidation.nextAttemptAt ?? nextRetryAt(item, nowIso), }); + await this.appendDispatchAudit( + item, + reasonToAuditEvent(revalidation.reason), + revalidation.reason + ); return 'retryable'; } await outbox.markSuperseded({ @@ -105,6 +115,7 @@ export class MemberWorkSyncNudgeDispatcher { reason: revalidation.reason, nowIso, }); + await this.appendDispatchAudit(item, 'nudge_superseded', revalidation.reason); return 'superseded'; } @@ -126,6 +137,7 @@ export class MemberWorkSyncNudgeDispatcher { retryable: false, nowIso, }); + await this.appendDispatchAudit(item, 'nudge_skipped', 'inbox_payload_conflict'); return 'terminal'; } await outbox.markDelivered({ @@ -135,6 +147,7 @@ export class MemberWorkSyncNudgeDispatcher { deliveredMessageId: inserted.messageId, nowIso, }); + await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted'); return 'delivered'; } catch (error) { await outbox.markFailed({ @@ -146,16 +159,33 @@ export class MemberWorkSyncNudgeDispatcher { nowIso, nextAttemptAt: nextRetryAt(item, nowIso), }); + await this.appendDispatchAudit(item, 'nudge_retryable', String(error)); return 'retryable'; } } + private async appendDispatchAudit( + item: MemberWorkSyncOutboxItem, + event: MemberWorkSyncAuditEventName, + reason: string + ): Promise { + await appendMemberWorkSyncAudit(this.deps, { + teamName: item.teamName, + memberName: item.memberName, + event, + source: 'nudge_dispatcher', + agendaFingerprint: item.agendaFingerprint, + reason, + taskRefs: item.payload.taskRefs, + messagePreview: item.payload.text, + }); + } + private async revalidate( item: MemberWorkSyncOutboxItem, nowIso: string ): Promise< - | { ok: true } - | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } + { ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } > { if (this.deps.lifecycle && !(await this.deps.lifecycle.isTeamActive(item.teamName))) { return { ok: false, reason: 'team_inactive', retryable: false }; diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts index a042d0bf..ab01079e 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts @@ -1,5 +1,7 @@ import { buildMemberWorkSyncOutboxEnsureInput } from '../domain'; +import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit'; + import type { MemberWorkSyncStatus } from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; @@ -37,6 +39,7 @@ export class MemberWorkSyncNudgeOutboxPlanner { const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName); if (metrics.phase2Readiness.state !== 'shadow_ready') { + await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' }); return { planned: false, code: 'phase2_not_ready' }; } @@ -49,9 +52,34 @@ export class MemberWorkSyncNudgeOutboxPlanner { existingPayloadHash: result.existingPayloadHash, requestedPayloadHash: result.requestedPayloadHash, }); + await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' }); return { planned: false, code: 'payload_conflict' }; } - return { planned: true, code: result.outcome }; + const planResult = { planned: true, code: result.outcome } as const; + await this.appendPlanAudit(status, planResult); + return planResult; + } + + private async appendPlanAudit( + status: MemberWorkSyncStatus, + result: MemberWorkSyncNudgeOutboxPlanResult + ): Promise { + await appendMemberWorkSyncAudit(this.deps, { + teamName: status.teamName, + memberName: status.memberName, + event: result.planned ? 'nudge_planned' : 'nudge_skipped', + source: 'nudge_planner', + agendaFingerprint: status.agenda.fingerprint, + state: status.state, + actionableCount: status.agenda.items.length, + reason: result.code, + ...(status.providerId ? { providerId: status.providerId } : {}), + taskRefs: status.agenda.items.map((item) => ({ + taskId: item.taskId, + displayId: item.displayId, + teamName: status.teamName, + })), + }); } } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index 3043f90b..0ac4f068 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -5,6 +5,7 @@ import { formatAgendaFingerprint, } from '../domain'; +import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit'; import { MemberWorkSyncNudgeOutboxPlanner } from './MemberWorkSyncNudgeOutboxPlanner'; import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; @@ -46,8 +47,25 @@ export class MemberWorkSyncReconciler { request: MemberWorkSyncStatusRequest, context: MemberWorkSyncReconcileContext = {} ): Promise { + await appendMemberWorkSyncAudit(this.deps, { + teamName: request.teamName, + memberName: request.memberName, + event: 'reconcile_started', + source: 'reconciler', + ...(context.triggerReasons?.length ? { triggerReasons: context.triggerReasons } : {}), + }); const source = await this.deps.agendaSource.loadAgenda(request); const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); + await appendMemberWorkSyncAudit(this.deps, { + teamName: agenda.teamName, + memberName: agenda.memberName, + event: 'agenda_loaded', + source: 'reconciler', + agendaFingerprint: agenda.fingerprint, + actionableCount: agenda.items.length, + ...(source.providerId ? { providerId: source.providerId } : {}), + diagnostics: agenda.diagnostics, + }); const previous = await this.deps.statusStore.read(request); const nowIso = this.deps.clock.now().toISOString(); const teamActive = this.deps.lifecycle @@ -59,6 +77,17 @@ export class MemberWorkSyncReconciler { nowIso, inactive: source.inactive || !teamActive, }); + await appendMemberWorkSyncAudit(this.deps, { + teamName: agenda.teamName, + memberName: agenda.memberName, + event: source.inactive || !teamActive ? 'team_inactive' : 'decision_made', + source: 'reconciler', + agendaFingerprint: agenda.fingerprint, + state: decision.state, + actionableCount: agenda.items.length, + ...(source.providerId ? { providerId: source.providerId } : {}), + diagnostics: decision.diagnostics, + }); const status = await attachMemberWorkSyncReportToken(this.deps, { teamName: agenda.teamName, diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts index 2076adf4..8899468f 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -1,5 +1,6 @@ import { validateMemberWorkSyncReport } from '../domain'; +import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit'; import { attachMemberWorkSyncReportToken, finalizeMemberWorkSyncAgenda, @@ -22,6 +23,22 @@ export class MemberWorkSyncReporter { } async execute(request: MemberWorkSyncReportRequest): Promise { + await appendMemberWorkSyncAudit(this.deps, { + teamName: request.teamName, + memberName: request.memberName, + event: 'report_received', + source: 'reporter', + agendaFingerprint: request.agendaFingerprint, + state: request.state, + ...(request.taskIds?.length + ? { + taskRefs: request.taskIds.map((taskId) => ({ + taskId, + teamName: request.teamName, + })), + } + : {}), + }); const source = await this.deps.agendaSource.loadAgenda(request); const agenda = finalizeMemberWorkSyncAgenda(this.deps, source); const nowIso = this.deps.clock.now().toISOString(); @@ -105,6 +122,16 @@ export class MemberWorkSyncReporter { }); await this.deps.statusStore.write(status); + await appendMemberWorkSyncAudit(this.deps, { + teamName: status.teamName, + memberName: status.memberName, + event: 'report_accepted', + source: 'reporter', + agendaFingerprint: agenda.fingerprint, + state: status.state, + actionableCount: agenda.items.length, + ...(source.providerId ? { providerId: source.providerId } : {}), + }); return { accepted: true, code: 'accepted', @@ -135,6 +162,17 @@ export class MemberWorkSyncReporter { diagnostics: [...status.diagnostics, `report_rejected:${rejectionCode}`], }; await this.deps.statusStore.write(rejectedStatus); + await appendMemberWorkSyncAudit(this.deps, { + teamName: status.teamName, + memberName: status.memberName, + event: 'report_rejected', + source: 'reporter', + agendaFingerprint: request.agendaFingerprint, + state: request.state, + actionableCount: status.agenda.items.length, + reason: rejectionCode, + ...(status.providerId ? { providerId: status.providerId } : {}), + }); return rejectedStatus; } } diff --git a/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts b/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts index a745557d..39f66af3 100644 --- a/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts +++ b/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts @@ -1,4 +1,9 @@ -import type { MemberWorkSyncClockPort, MemberWorkSyncLoggerPort } from './ports'; +import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit'; +import type { + MemberWorkSyncAuditJournalPort, + MemberWorkSyncClockPort, + MemberWorkSyncLoggerPort, +} from './ports'; import type { RuntimeTurnSettledEvent } from '../domain'; import type { RuntimeTurnSettledEventStorePort, @@ -13,6 +18,7 @@ export interface RuntimeTurnSettledIngestorDeps { targetResolver: RuntimeTurnSettledTargetResolverPort; reconcileQueue: RuntimeTurnSettledReconcileQueuePort; clock: MemberWorkSyncClockPort; + auditJournal?: MemberWorkSyncAuditJournalPort; logger?: MemberWorkSyncLoggerPort; } @@ -78,6 +84,20 @@ export class RuntimeTurnSettledIngestor { continue; } + if (normalized.event.teamName && normalized.event.memberName) { + await appendMemberWorkSyncAudit(this.deps, { + teamName: normalized.event.teamName, + memberName: normalized.event.memberName, + event: 'turn_settled_claimed', + source: 'runtime_turn_settled_ingestor', + reason: normalized.event.provider, + metadata: { + sourceId: normalized.event.sourceId, + provider: normalized.event.provider, + }, + }); + } + const ignoredReason = getIgnoredReason(normalized.event); if (ignoredReason) { summary.ignored += 1; @@ -87,6 +107,19 @@ export class RuntimeTurnSettledIngestor { reason: ignoredReason, processedAt, }); + if (normalized.event.teamName && normalized.event.memberName) { + await appendMemberWorkSyncAudit(this.deps, { + teamName: normalized.event.teamName, + memberName: normalized.event.memberName, + event: 'turn_settled_ignored', + source: 'runtime_turn_settled_ingestor', + reason: ignoredReason, + metadata: { + sourceId: normalized.event.sourceId, + provider: normalized.event.provider, + }, + }); + } continue; } @@ -99,6 +132,19 @@ export class RuntimeTurnSettledIngestor { reason: resolution.reason, processedAt, }); + if (normalized.event.teamName && normalized.event.memberName) { + await appendMemberWorkSyncAudit(this.deps, { + teamName: normalized.event.teamName, + memberName: normalized.event.memberName, + event: 'turn_settled_unresolved', + source: 'runtime_turn_settled_ingestor', + reason: resolution.reason, + metadata: { + sourceId: normalized.event.sourceId, + provider: normalized.event.provider, + }, + }); + } continue; } @@ -115,6 +161,17 @@ export class RuntimeTurnSettledIngestor { outcome: 'enqueued', processedAt, }); + await appendMemberWorkSyncAudit(this.deps, { + teamName: resolution.teamName, + memberName: resolution.memberName, + event: 'turn_settled_resolved', + source: 'runtime_turn_settled_ingestor', + reason: normalized.event.provider, + metadata: { + sourceId: normalized.event.sourceId, + provider: normalized.event.provider, + }, + }); } catch (error) { summary.failed += 1; this.deps.logger?.warn('runtime turn settled ingest failed', { diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 74021af2..704debbc 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -1,5 +1,6 @@ export * from './MemberWorkSyncDiagnosticsReader'; export * from './MemberWorkSyncMetricsReader'; +export * from './MemberWorkSyncAudit'; export * from './MemberWorkSyncNudgeDispatcher'; export * from './MemberWorkSyncNudgeOutboxPlanner'; export * from './MemberWorkSyncPendingReportIntentReplayer'; diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index 4ac83fec..886cb178 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -64,6 +64,55 @@ export interface MemberWorkSyncLoggerPort { error(message: string, metadata?: Record): void; } +export type MemberWorkSyncAuditEventName = + | 'turn_settled_claimed' + | 'turn_settled_resolved' + | 'turn_settled_unresolved' + | 'turn_settled_ignored' + | 'queue_enqueued' + | 'queue_coalesced' + | 'queue_reconciled' + | 'queue_dropped' + | 'reconcile_started' + | 'agenda_loaded' + | 'decision_made' + | 'status_written' + | 'report_received' + | 'report_accepted' + | 'report_rejected' + | 'nudge_planned' + | 'nudge_delivered' + | 'nudge_skipped' + | 'nudge_retryable' + | 'nudge_superseded' + | 'watchdog_cooldown_active' + | 'member_busy' + | 'team_inactive' + | 'index_repaired' + | 'legacy_fallback_used'; + +export interface MemberWorkSyncAuditEvent { + timestamp: string; + teamName: string; + memberName: string; + event: MemberWorkSyncAuditEventName; + source: string; + agendaFingerprint?: string; + state?: string; + actionableCount?: number; + reason?: string; + triggerReasons?: string[]; + providerId?: string; + taskRefs?: { taskId: string; displayId?: string; teamName?: string }[]; + diagnostics?: string[]; + messagePreview?: string; + metadata?: Record; +} + +export interface MemberWorkSyncAuditJournalPort { + append(event: MemberWorkSyncAuditEvent): Promise; +} + export interface MemberWorkSyncAgendaSourceResult { agenda: Omit; activeMemberNames: string[]; @@ -143,6 +192,7 @@ export interface MemberWorkSyncUseCaseDeps { watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort; busySignal?: MemberWorkSyncBusySignalPort; reportToken?: MemberWorkSyncReportTokenPort; + auditJournal?: MemberWorkSyncAuditJournalPort; lifecycle?: MemberWorkSyncLifecyclePort; logger?: MemberWorkSyncLoggerPort; } 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 477a8995..edb54e8a 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts @@ -14,6 +14,10 @@ interface MemberWorkSyncRosterSource { loadActiveMemberNames(teamName: string): Promise; } +interface MemberWorkSyncMemberStorageMaterializer { + materializeMember(teamName: string, memberName: string): Promise; +} + const TEAM_WIDE_REASONS: Partial> = { config: 'config_changed', task: 'task_changed', @@ -58,7 +62,8 @@ function parseMemberTurnSettled(detail: string | undefined): MemberTurnSettledEv export class MemberWorkSyncTeamChangeRouter { constructor( private readonly rosterSource: MemberWorkSyncRosterSource, - private readonly queue: MemberWorkSyncEventQueue + private readonly queue: MemberWorkSyncEventQueue, + private readonly materializer?: MemberWorkSyncMemberStorageMaterializer ) {} async enqueueStartupScan(teamNames: string[]): Promise { @@ -137,6 +142,13 @@ export class MemberWorkSyncTeamChangeRouter { runAfterMs?: number ): Promise { const activeMembers = await this.rosterSource.loadActiveMemberNames(teamName); + if (this.materializer) { + await Promise.allSettled( + activeMembers.map((memberName) => + this.materializer?.materializeMember(teamName, memberName) + ) + ); + } for (const memberName of activeMembers) { this.queue.enqueue({ teamName, memberName, triggerReason, runAfterMs }); } diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 17ca5ac3..11c4fef4 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -21,6 +21,7 @@ import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHoo import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer'; import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer'; import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; +import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal'; import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; import { @@ -132,7 +133,11 @@ export function createMemberWorkSyncFeature(deps: { clock, }); const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath); - const store = new JsonMemberWorkSyncStore(storePaths); + const auditJournal = new FileMemberWorkSyncAuditJournal(storePaths, deps.logger); + const store = new JsonMemberWorkSyncStore(storePaths, { + auditJournal, + logger: deps.logger, + }); const runtimeTurnSettledSpool = new RuntimeTurnSettledSpoolInitializer(deps.teamsBasePath); const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({ paths: runtimeTurnSettledSpool.getPaths(), @@ -165,6 +170,7 @@ export function createMemberWorkSyncFeature(deps: { watchdogCooldown, busySignal, reportToken, + auditJournal, ...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}), logger: deps.logger, }; @@ -186,9 +192,13 @@ export function createMemberWorkSyncFeature(deps: { }, isTeamActive: deps.isTeamActive ?? (() => true), ...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}), + auditJournal, logger: deps.logger, }); - const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue); + const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue, { + materializeMember: (teamName, memberName) => + storePaths.ensureMemberWorkSyncDir(teamName, memberName), + }); const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({ eventStore: runtimeTurnSettledStore, normalizer: runtimeTurnSettledNormalizer, @@ -207,6 +217,7 @@ export function createMemberWorkSyncFeature(deps: { }, }, clock, + auditJournal, logger: deps.logger, }); const runtimeTurnSettledDrainScheduler = new RuntimeTurnSettledDrainScheduler({ diff --git a/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts b/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts new file mode 100644 index 00000000..9b47e604 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts @@ -0,0 +1,157 @@ +import { appendFile, mkdir, rename, rm, stat } from 'fs/promises'; +import { dirname } from 'path'; + +import { withFileLock } from '@main/services/team/fileLock'; + +import type { + MemberWorkSyncAuditEvent, + MemberWorkSyncAuditJournalPort, + MemberWorkSyncLoggerPort, +} from '../../core/application'; +import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; + +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; +const DEFAULT_ROTATED_FILE_COUNT = 5; +const MAX_PREVIEW_CHARS = 240; +const MAX_DIAGNOSTICS = 20; +const MAX_TRIGGER_REASONS = 20; +const MAX_TASK_REFS = 20; +const MAX_SHORT_FIELD_CHARS = 240; + +interface PersistedAuditEvent extends MemberWorkSyncAuditEvent { + schemaVersion: 1; +} + +export interface FileMemberWorkSyncAuditJournalOptions { + maxBytes?: number; + rotatedFileCount?: number; +} + +function truncateText(value: string, maxChars: number): string { + return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`; +} + +function sanitizeMetadata( + metadata: MemberWorkSyncAuditEvent['metadata'] +): MemberWorkSyncAuditEvent['metadata'] { + if (!metadata) { + return undefined; + } + const sanitized = Object.create(null) as NonNullable; + for (const [key, value] of Object.entries(metadata)) { + sanitized[truncateText(key, MAX_SHORT_FIELD_CHARS)] = + typeof value === 'string' ? truncateText(value, MAX_SHORT_FIELD_CHARS) : value; + } + return sanitized; +} + +function sanitizeTaskRefs( + taskRefs: MemberWorkSyncAuditEvent['taskRefs'] +): MemberWorkSyncAuditEvent['taskRefs'] { + return taskRefs?.slice(0, MAX_TASK_REFS).map((taskRef) => ({ + taskId: truncateText(taskRef.taskId, MAX_SHORT_FIELD_CHARS), + ...(taskRef.displayId + ? { displayId: truncateText(taskRef.displayId, MAX_SHORT_FIELD_CHARS) } + : {}), + ...(taskRef.teamName + ? { teamName: truncateText(taskRef.teamName, MAX_SHORT_FIELD_CHARS) } + : {}), + })); +} + +function sanitizeEvent(event: MemberWorkSyncAuditEvent): PersistedAuditEvent { + return { + ...event, + schemaVersion: 1, + source: truncateText(event.source, MAX_SHORT_FIELD_CHARS), + ...(event.reason ? { reason: truncateText(event.reason, MAX_SHORT_FIELD_CHARS) } : {}), + ...(event.providerId + ? { providerId: truncateText(event.providerId, MAX_SHORT_FIELD_CHARS) } + : {}), + ...(event.state ? { state: truncateText(event.state, MAX_SHORT_FIELD_CHARS) } : {}), + ...(event.agendaFingerprint + ? { agendaFingerprint: truncateText(event.agendaFingerprint, MAX_SHORT_FIELD_CHARS) } + : {}), + ...(typeof event.messagePreview === 'string' + ? { messagePreview: truncateText(event.messagePreview, MAX_PREVIEW_CHARS) } + : {}), + ...(event.diagnostics + ? { + diagnostics: event.diagnostics + .slice(0, MAX_DIAGNOSTICS) + .map((diagnostic) => truncateText(diagnostic, MAX_SHORT_FIELD_CHARS)), + } + : {}), + ...(event.triggerReasons + ? { + triggerReasons: event.triggerReasons + .slice(0, MAX_TRIGGER_REASONS) + .map((reason) => truncateText(reason, MAX_SHORT_FIELD_CHARS)), + } + : {}), + ...(event.taskRefs ? { taskRefs: sanitizeTaskRefs(event.taskRefs) } : {}), + ...(event.metadata ? { metadata: sanitizeMetadata(event.metadata) } : {}), + }; +} + +function rotatedPath(filePath: string, index: number): string { + return `${filePath}.${index}`; +} + +async function rotateIfNeeded( + filePath: string, + maxBytes: number, + rotatedFileCount: number +): Promise { + const current = await stat(filePath).catch(() => null); + if (!current?.isFile() || current.size < maxBytes) { + return; + } + + await rm(rotatedPath(filePath, rotatedFileCount), { force: true }).catch(() => undefined); + for (let index = rotatedFileCount - 1; index >= 1; index -= 1) { + await rename(rotatedPath(filePath, index), rotatedPath(filePath, index + 1)).catch( + () => undefined + ); + } + await rename(filePath, rotatedPath(filePath, 1)).catch(() => undefined); +} + +export class NoopMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort { + async append(): Promise { + // Intentionally empty. + } +} + +export class FileMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort { + private readonly maxBytes: number; + private readonly rotatedFileCount: number; + + constructor( + private readonly paths: MemberWorkSyncStorePaths, + private readonly logger?: MemberWorkSyncLoggerPort, + options: FileMemberWorkSyncAuditJournalOptions = {} + ) { + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.rotatedFileCount = options.rotatedFileCount ?? DEFAULT_ROTATED_FILE_COUNT; + } + + async append(event: MemberWorkSyncAuditEvent): Promise { + try { + await this.paths.ensureMemberWorkSyncDir(event.teamName, event.memberName); + const filePath = this.paths.getMemberJournalPath(event.teamName, event.memberName); + await mkdir(dirname(filePath), { recursive: true }); + await withFileLock(filePath, async () => { + await rotateIfNeeded(filePath, this.maxBytes, this.rotatedFileCount); + await appendFile(filePath, `${JSON.stringify(sanitizeEvent(event))}\n`, 'utf8'); + }); + } catch (error) { + this.logger?.warn('member work sync audit journal append failed', { + teamName: event.teamName, + memberName: event.memberName, + event: event.event, + error: String(error), + }); + } + } +} diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index 1b702de8..7fb1901c 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -1,7 +1,8 @@ import { withFileLock } from '@main/services/team/fileLock'; import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { createHash } from 'crypto'; -import { mkdir, readFile, rename } from 'fs/promises'; +import { mkdir, readdir, readFile, rename } from 'fs/promises'; +import { dirname, join } from 'path'; import { assessMemberWorkSyncPhase2Readiness } from '../../core/domain'; @@ -22,13 +23,15 @@ import type { MemberWorkSyncTeamMetrics, } from '../../contracts'; import type { + MemberWorkSyncAuditJournalPort, + MemberWorkSyncLoggerPort, MemberWorkSyncOutboxStorePort, MemberWorkSyncReportStorePort, MemberWorkSyncStatusStorePort, } from '../../core/application'; import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; -interface StoreFile { +interface LegacyStatusFile { schemaVersion: 1; members: Record; metrics?: { @@ -36,29 +39,90 @@ interface StoreFile { }; } -interface PendingReportFile { +interface MemberStatusFile { + schemaVersion: 2; + status: MemberWorkSyncStatus; +} + +interface MetricsIndexMember { + memberName: string; + state: MemberWorkSyncStatusState; + agendaFingerprint: string; + actionableCount: number; + evaluatedAt: string; + providerId?: string; +} + +interface MetricsIndexFile { + schemaVersion: 2; + members: Record; + recentEvents: MemberWorkSyncMetricEvent[]; +} + +interface LegacyPendingReportFile { schemaVersion: 1; intents: Record; } -interface OutboxFile { +interface MemberReportsFile { + schemaVersion: 2; + intents: Record; +} + +interface PendingReportsIndexFile { + schemaVersion: 2; + items: Record< + string, + { + memberKey: string; + memberName: string; + status: MemberWorkSyncReportIntent['status']; + recordedAt: string; + processedAt?: string; + } + >; +} + +interface LegacyOutboxFile { schemaVersion: 1; items: Record; } +interface MemberOutboxFile { + schemaVersion: 2; + items: Record; +} + +interface OutboxIndexFile { + schemaVersion: 2; + items: Record< + string, + { + memberKey: string; + memberName: string; + status: MemberWorkSyncOutboxItem['status']; + nextAttemptAt?: string; + updatedAt: string; + createdAt: string; + } + >; +} + +type OutboxIndexRoute = OutboxIndexFile['items'][string]; +type OutboxDueRoute = [string, OutboxIndexRoute]; + +export interface JsonMemberWorkSyncStoreDeps { + auditJournal?: MemberWorkSyncAuditJournalPort; + logger?: MemberWorkSyncLoggerPort; + now?: () => Date; +} + function normalizeMemberKey(memberName: string): string { return memberName.trim().toLowerCase(); } -function isStoreFile(value: unknown): value is StoreFile { - return ( - value != null && - typeof value === 'object' && - (value as StoreFile).schemaVersion === 1 && - (value as StoreFile).members != null && - typeof (value as StoreFile).members === 'object' && - !Array.isArray((value as StoreFile).members) - ); +function emptyMetricsIndex(): MetricsIndexFile { + return { schemaVersion: 2, members: {}, recentEvents: [] }; } function emptyStateCounts(): Record { @@ -72,25 +136,101 @@ function emptyStateCounts(): Record { }; } -function isPendingReportFile(value: unknown): value is PendingReportFile { +function isLegacyStatusFile(value: unknown): value is LegacyStatusFile { return ( value != null && typeof value === 'object' && - (value as PendingReportFile).schemaVersion === 1 && - (value as PendingReportFile).intents != null && - typeof (value as PendingReportFile).intents === 'object' && - !Array.isArray((value as PendingReportFile).intents) + (value as LegacyStatusFile).schemaVersion === 1 && + (value as LegacyStatusFile).members != null && + typeof (value as LegacyStatusFile).members === 'object' && + !Array.isArray((value as LegacyStatusFile).members) ); } -function isOutboxFile(value: unknown): value is OutboxFile { +function isMemberStatusFile(value: unknown): value is MemberStatusFile { return ( value != null && typeof value === 'object' && - (value as OutboxFile).schemaVersion === 1 && - (value as OutboxFile).items != null && - typeof (value as OutboxFile).items === 'object' && - !Array.isArray((value as OutboxFile).items) + (value as MemberStatusFile).schemaVersion === 2 && + (value as MemberStatusFile).status != null && + typeof (value as MemberStatusFile).status === 'object' + ); +} + +function isMetricsIndexFile(value: unknown): value is MetricsIndexFile { + return ( + value != null && + typeof value === 'object' && + (value as MetricsIndexFile).schemaVersion === 2 && + (value as MetricsIndexFile).members != null && + typeof (value as MetricsIndexFile).members === 'object' && + Array.isArray((value as MetricsIndexFile).recentEvents) + ); +} + +function isLegacyPendingReportFile(value: unknown): value is LegacyPendingReportFile { + return ( + value != null && + typeof value === 'object' && + (value as LegacyPendingReportFile).schemaVersion === 1 && + (value as LegacyPendingReportFile).intents != null && + typeof (value as LegacyPendingReportFile).intents === 'object' && + !Array.isArray((value as LegacyPendingReportFile).intents) + ); +} + +function isMemberReportsFile(value: unknown): value is MemberReportsFile { + return ( + value != null && + typeof value === 'object' && + (value as MemberReportsFile).schemaVersion === 2 && + (value as MemberReportsFile).intents != null && + typeof (value as MemberReportsFile).intents === 'object' && + !Array.isArray((value as MemberReportsFile).intents) + ); +} + +function isPendingReportsIndexFile(value: unknown): value is PendingReportsIndexFile { + return ( + value != null && + typeof value === 'object' && + (value as PendingReportsIndexFile).schemaVersion === 2 && + (value as PendingReportsIndexFile).items != null && + typeof (value as PendingReportsIndexFile).items === 'object' && + !Array.isArray((value as PendingReportsIndexFile).items) + ); +} + +function isLegacyOutboxFile(value: unknown): value is LegacyOutboxFile { + return ( + value != null && + typeof value === 'object' && + (value as LegacyOutboxFile).schemaVersion === 1 && + (value as LegacyOutboxFile).items != null && + typeof (value as LegacyOutboxFile).items === 'object' && + !Array.isArray((value as LegacyOutboxFile).items) + ); +} + +function isMemberOutboxFile(value: unknown): value is MemberOutboxFile { + return ( + value != null && + typeof value === 'object' && + (value as MemberOutboxFile).schemaVersion === 2 && + (value as MemberOutboxFile).items != null && + typeof (value as MemberOutboxFile).items === 'object' && + !Array.isArray((value as MemberOutboxFile).items) + ); +} + +function isOutboxIndexFile(value: unknown): value is OutboxIndexFile { + return ( + value != null && + typeof value === 'object' && + (value as OutboxIndexFile).schemaVersion === 2 && + (value as OutboxIndexFile).items != null && + typeof (value as OutboxIndexFile).items === 'object' && + !Array.isArray((value as OutboxIndexFile).items) ); } @@ -112,6 +252,22 @@ function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boo return item.nextAttemptAt <= nowIso; } +function getDueOutboxRoutes( + index: OutboxIndexFile, + nowIso: string, + limit: number +): OutboxDueRoute[] { + return Object.entries(index.items) + .filter(([, route]) => route.status === 'pending' || route.status === 'failed_retryable') + .filter(([, route]) => !route.nextAttemptAt || route.nextAttemptAt <= nowIso) + .sort((left, right) => { + const leftTime = left[1].nextAttemptAt ?? left[1].updatedAt; + const rightTime = right[1].nextAttemptAt ?? right[1].updatedAt; + return leftTime.localeCompare(rightTime); + }) + .slice(0, Math.max(0, limit)); +} + function stableStringify(value: unknown): string { if (value == null || typeof value !== 'object') { return JSON.stringify(value); @@ -216,16 +372,62 @@ function buildMetricEvents(status: MemberWorkSyncStatus): MemberWorkSyncMetricEv return events; } -function appendMetricEvents(file: StoreFile, status: MemberWorkSyncStatus): void { - const current = file.metrics?.recentEvents ?? []; - const byId = new Map(current.map((event) => [event.id, event])); +function appendMetricEvents(file: MetricsIndexFile, status: MemberWorkSyncStatus): void { + const byId = new Map(file.recentEvents.map((event) => [event.id, event])); for (const event of buildMetricEvents(status)) { byId.set(event.id, event); } - file.metrics = { - recentEvents: [...byId.values()] - .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)) - .slice(-200), + file.recentEvents = [...byId.values()] + .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)) + .slice(-200); +} + +function updateMetricsMember( + file: MetricsIndexFile, + status: MemberWorkSyncStatus, + memberKey: string +): void { + file.members[memberKey] = { + memberName: status.memberName, + state: status.state, + agendaFingerprint: status.agenda.fingerprint, + actionableCount: status.agenda.items.length, + evaluatedAt: status.evaluatedAt, + ...(status.providerId ? { providerId: status.providerId } : {}), + }; + appendMetricEvents(file, status); +} + +function toMetrics(teamName: string, file: MetricsIndexFile): MemberWorkSyncTeamMetrics { + const stateCounts = emptyStateCounts(); + const members = Object.values(file.members); + let actionableItemCount = 0; + for (const member of members) { + stateCounts[member.state] += 1; + actionableItemCount += member.actionableCount; + } + const recentEvents = [...file.recentEvents].sort((left, right) => + left.recordedAt.localeCompare(right.recordedAt) + ); + const metrics = { + teamName, + generatedAt: new Date().toISOString(), + memberCount: members.length, + stateCounts, + actionableItemCount, + wouldNudgeCount: recentEvents.filter((event) => event.kind === 'would_nudge').length, + fingerprintChangeCount: recentEvents.filter((event) => event.kind === 'fingerprint_changed') + .length, + reportAcceptedCount: recentEvents.filter((event) => event.kind === 'report_accepted').length, + reportRejectedCount: recentEvents.filter((event) => event.kind === 'report_rejected').length, + recentEvents, + }; + return { + ...metrics, + phase2Readiness: assessMemberWorkSyncPhase2Readiness({ + memberCount: metrics.memberCount, + recentEvents: metrics.recentEvents, + }), }; } @@ -237,6 +439,34 @@ async function quarantineFile(filePath: string): Promise { } } +async function readJsonFile( + filePath: string, + guard: (value: unknown) => value is T, + fallback: T, + options: { quarantineInvalid?: boolean } = {} +): Promise { + try { + const raw = await readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw); + if (guard(parsed)) { + return parsed; + } + if (options.quarantineInvalid) { + await quarantineFile(filePath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && options.quarantineInvalid) { + await quarantineFile(filePath); + } + } + return fallback; +} + +async function writeJsonFile(filePath: string, value: unknown): Promise { + await mkdir(dirname(filePath), { recursive: true }); + await atomicWriteAsync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + export class JsonMemberWorkSyncStore implements MemberWorkSyncStatusStorePort, @@ -244,94 +474,166 @@ export class JsonMemberWorkSyncStore MemberWorkSyncOutboxStorePort { private readonly writeQueues = new Map>(); + private readonly now: () => Date; - constructor(private readonly paths: MemberWorkSyncStorePaths) {} + constructor( + private readonly paths: MemberWorkSyncStorePaths, + private readonly deps: JsonMemberWorkSyncStoreDeps = {} + ) { + this.now = deps.now ?? (() => new Date()); + } async read(input: { teamName: string; memberName: string; }): Promise { - const file = await this.readFile(input.teamName); - return file.members[normalizeMemberKey(input.memberName)] ?? null; + const memberFile = await this.readMemberStatusFile(input.teamName, input.memberName); + if (memberFile) { + return memberFile.status; + } + + const legacy = await this.readLegacyStatusFile(input.teamName); + const legacyStatus = legacy.members[normalizeMemberKey(input.memberName)] ?? null; + if (legacyStatus) { + await this.appendAudit({ + teamName: input.teamName, + memberName: input.memberName, + event: 'legacy_fallback_used', + source: 'json_store', + reason: 'status_v1', + }); + } + return legacyStatus; } async write(status: MemberWorkSyncStatus): Promise { + const memberKey = this.paths.getMemberKey(status.memberName); + await this.paths.ensureMemberWorkSyncDir(status.teamName, status.memberName); await this.enqueue(status.teamName, async () => { - await withFileLock(this.paths.getStatusPath(status.teamName), async () => { - const existing = await this.readFile(status.teamName); - existing.members[normalizeMemberKey(status.memberName)] = status; - appendMetricEvents(existing, status); - await mkdir(this.paths.getTeamDir(status.teamName), { recursive: true }); - await atomicWriteAsync( - this.paths.getStatusPath(status.teamName), - JSON.stringify(existing, null, 2) + await withFileLock(this.paths.getMetricsIndexPath(status.teamName), async () => { + await withFileLock( + this.paths.getMemberStatusPath(status.teamName, status.memberName), + async () => { + const metrics = await this.readMetricsIndexFile(status.teamName); + updateMetricsMember(metrics, status, memberKey); + await this.writeMemberStatusFile(status); + await this.writeMetricsIndexFile(status.teamName, metrics); + } + ); + }); + }); + await this.appendAudit({ + teamName: status.teamName, + memberName: status.memberName, + event: 'status_written', + source: 'json_store', + agendaFingerprint: status.agenda.fingerprint, + state: status.state, + actionableCount: status.agenda.items.length, + ...(status.shadow?.triggerReasons ? { triggerReasons: status.shadow.triggerReasons } : {}), + ...(status.providerId ? { providerId: status.providerId } : {}), + }); + } + + async readTeamMetrics(teamName: string): Promise { + let file = await this.readMetricsIndexFile(teamName); + if (Object.keys(file.members).length === 0 && file.recentEvents.length === 0) { + const repaired = await this.repairMetricsIndex(teamName); + if (repaired) { + file = repaired; + } + } + return toMetrics(teamName, file); + } + + async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { + const id = buildPendingReportIntentId(request); + const memberKey = this.paths.getMemberKey(request.memberName); + await this.paths.ensureMemberWorkSyncDir(request.teamName, request.memberName); + await this.enqueue(request.teamName, async () => { + await withFileLock(this.paths.getPendingReportsIndexPath(request.teamName), async () => { + await withFileLock( + this.paths.getMemberReportsPath(request.teamName, request.memberName), + async () => { + const reports = await this.readMemberReportsFile(request.teamName, request.memberName); + const current = reports.intents[id]; + if (current && current.status !== 'pending') { + return; + } + const intent: MemberWorkSyncReportIntent = { + id, + teamName: request.teamName, + memberName: request.memberName, + request, + reason: current?.reason ?? reason, + status: 'pending', + recordedAt: current?.recordedAt ?? this.now().toISOString(), + }; + reports.intents[id] = intent; + await this.writeMemberReportsFile(request.teamName, request.memberName, reports); + + const index = await this.readPendingReportsIndexFile(request.teamName); + index.items[id] = { + memberKey, + memberName: request.memberName, + status: intent.status, + recordedAt: intent.recordedAt, + }; + await this.writePendingReportsIndexFile(request.teamName, index); + } ); }); }); } - async readTeamMetrics(teamName: string): Promise { - const file = await this.readFile(teamName); - const stateCounts = emptyStateCounts(); - const members = Object.values(file.members); - let actionableItemCount = 0; - for (const status of members) { - stateCounts[status.state] += 1; - actionableItemCount += status.agenda.items.length; - } - const recentEvents = [...(file.metrics?.recentEvents ?? [])].sort((left, right) => - left.recordedAt.localeCompare(right.recordedAt) - ); - const metrics = { - teamName, - generatedAt: new Date().toISOString(), - memberCount: members.length, - stateCounts, - actionableItemCount, - wouldNudgeCount: recentEvents.filter((event) => event.kind === 'would_nudge').length, - fingerprintChangeCount: recentEvents.filter((event) => event.kind === 'fingerprint_changed') - .length, - reportAcceptedCount: recentEvents.filter((event) => event.kind === 'report_accepted').length, - reportRejectedCount: recentEvents.filter((event) => event.kind === 'report_rejected').length, - recentEvents, - }; - return { - ...metrics, - phase2Readiness: assessMemberWorkSyncPhase2Readiness({ - memberCount: metrics.memberCount, - recentEvents: metrics.recentEvents, - }), - }; - } - - async appendPendingReport(request: MemberWorkSyncReportRequest, reason: string): Promise { - const id = buildPendingReportIntentId(request); - await this.enqueue(request.teamName, async () => { - await withFileLock(this.paths.getPendingReportsPath(request.teamName), async () => { - const existing = await this.readPendingFile(request.teamName); - const current = existing.intents[id]; - if (current && current.status !== 'pending') { - return; - } - existing.intents[id] = { - id, - teamName: request.teamName, - memberName: request.memberName, - request, - reason: current?.reason ?? reason, - status: 'pending', - recordedAt: current?.recordedAt ?? new Date().toISOString(), - }; - await this.writePendingFile(request.teamName, existing); - }); - }); - } - async listPendingReports(teamName: string): Promise { - const file = await this.readPendingFile(teamName); - return Object.values(file.intents) - .filter((intent) => intent.status === 'pending') - .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); + let index = await this.readPendingReportsIndexFile(teamName); + if (Object.keys(index.items).length === 0) { + await this.enqueue(teamName, async () => { + await withFileLock(this.paths.getPendingReportsIndexPath(teamName), async () => { + index = await this.readPendingReportsIndexFile(teamName); + if (Object.keys(index.items).length === 0) { + index = await this.repairPendingReportsIndex(teamName); + } + }); + }); + } + let staleIndex = false; + const pending: MemberWorkSyncReportIntent[] = []; + for (const [id, route] of Object.entries(index.items)) { + if (route.status !== 'pending') { + continue; + } + const file = await this.readMemberReportsFile(teamName, route.memberName); + const intent = file.intents[id]; + if (intent?.status === 'pending') { + pending.push(intent); + } else { + staleIndex = true; + } + } + const missingIndexedPending = staleIndex + ? false + : await this.hasMissingIndexedPendingReport(teamName, index); + if (staleIndex || missingIndexedPending) { + await this.enqueue(teamName, async () => { + await withFileLock(this.paths.getPendingReportsIndexPath(teamName), async () => { + index = await this.repairPendingReportsIndex(teamName); + }); + }); + pending.length = 0; + for (const [id, route] of Object.entries(index.items)) { + if (route.status !== 'pending') { + continue; + } + const file = await this.readMemberReportsFile(teamName, route.memberName); + const intent = file.intents[id]; + if (intent?.status === 'pending') { + pending.push(intent); + } + } + } + return pending.sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); } async markPendingReportProcessed( @@ -344,19 +646,38 @@ export class JsonMemberWorkSyncStore } ): Promise { await this.enqueue(teamName, async () => { - await withFileLock(this.paths.getPendingReportsPath(teamName), async () => { - const existing = await this.readPendingFile(teamName); - const current = existing.intents[id]; - if (current?.status !== 'pending') { + await withFileLock(this.paths.getPendingReportsIndexPath(teamName), async () => { + let index = await this.readPendingReportsIndexFile(teamName); + if (!index.items[id]) { + index = await this.repairPendingReportsIndex(teamName); + } + const route = index.items[id]; + if (!route) { return; } - existing.intents[id] = { - ...current, - status: result.status, - resultCode: result.resultCode, - processedAt: result.processedAt, - }; - await this.writePendingFile(teamName, existing); + await withFileLock( + this.paths.getMemberReportsPath(teamName, route.memberName), + async () => { + const reports = await this.readMemberReportsFile(teamName, route.memberName); + const current = reports.intents[id]; + if (current?.status !== 'pending') { + return; + } + reports.intents[id] = { + ...current, + status: result.status, + resultCode: result.resultCode, + processedAt: result.processedAt, + }; + await this.writeMemberReportsFile(teamName, route.memberName, reports); + index.items[id] = { + ...route, + status: result.status, + processedAt: result.processedAt, + }; + await this.writePendingReportsIndexFile(teamName, index); + } + ); }); }); } @@ -365,63 +686,73 @@ export class JsonMemberWorkSyncStore input: MemberWorkSyncOutboxEnsureInput ): Promise { let result: MemberWorkSyncOutboxEnsureResult | null = null; + const memberKey = this.paths.getMemberKey(input.memberName); + await this.paths.ensureMemberWorkSyncDir(input.teamName, input.memberName); await this.enqueue(input.teamName, async () => { - await withFileLock(this.paths.getOutboxPath(input.teamName), async () => { - const existing = await this.readOutboxFile(input.teamName); - const current = existing.items[input.id]; - if (current) { - if (current.payloadHash !== input.payloadHash) { - result = { - ok: false, - outcome: 'payload_conflict', - item: current, - existingPayloadHash: current.payloadHash, - requestedPayloadHash: input.payloadHash, - }; - return; - } + await withFileLock(this.paths.getOutboxIndexPath(input.teamName), async () => { + await withFileLock( + this.paths.getMemberOutboxPath(input.teamName, input.memberName), + async () => { + const outbox = await this.readMemberOutboxFile(input.teamName, input.memberName); + const current = outbox.items[input.id]; + if (current) { + if (current.payloadHash !== input.payloadHash) { + result = { + ok: false, + outcome: 'payload_conflict', + item: current, + existingPayloadHash: current.payloadHash, + requestedPayloadHash: input.payloadHash, + }; + return; + } - if (canReviveOutboxItem(current.status)) { - const next: MemberWorkSyncOutboxItem = { - ...current, + if (canReviveOutboxItem(current.status)) { + const next: MemberWorkSyncOutboxItem = { + ...current, + status: 'pending', + updatedAt: input.nowIso, + }; + const nextAttemptAt = input.nextAttemptAt ?? current.nextAttemptAt; + if (nextAttemptAt) { + next.nextAttemptAt = nextAttemptAt; + } else { + delete next.nextAttemptAt; + } + delete next.claimedBy; + delete next.claimedAt; + delete next.lastError; + outbox.items[input.id] = next; + await this.writeMemberOutboxFile(input.teamName, input.memberName, outbox); + await this.upsertOutboxIndexItem(input.teamName, next, memberKey); + result = { ok: true, outcome: 'existing', item: next }; + return; + } + + await this.upsertOutboxIndexItem(input.teamName, current, memberKey); + result = { ok: true, outcome: 'existing', item: current }; + return; + } + + const item: MemberWorkSyncOutboxItem = { + id: input.id, + teamName: input.teamName, + memberName: input.memberName, + agendaFingerprint: input.agendaFingerprint, + payloadHash: input.payloadHash, + payload: input.payload, status: 'pending', + attemptGeneration: 0, + ...(input.nextAttemptAt ? { nextAttemptAt: input.nextAttemptAt } : {}), + createdAt: input.nowIso, updatedAt: input.nowIso, }; - const nextAttemptAt = input.nextAttemptAt ?? current.nextAttemptAt; - if (nextAttemptAt) { - next.nextAttemptAt = nextAttemptAt; - } else { - delete next.nextAttemptAt; - } - delete next.claimedBy; - delete next.claimedAt; - delete next.lastError; - existing.items[input.id] = next; - await this.writeOutboxFile(input.teamName, existing); - result = { ok: true, outcome: 'existing', item: existing.items[input.id] }; - return; + outbox.items[input.id] = item; + await this.writeMemberOutboxFile(input.teamName, input.memberName, outbox); + await this.upsertOutboxIndexItem(input.teamName, item, memberKey); + result = { ok: true, outcome: 'created', item }; } - - result = { ok: true, outcome: 'existing', item: current }; - return; - } - - const item: MemberWorkSyncOutboxItem = { - id: input.id, - teamName: input.teamName, - memberName: input.memberName, - agendaFingerprint: input.agendaFingerprint, - payloadHash: input.payloadHash, - payload: input.payload, - status: 'pending', - attemptGeneration: 0, - ...(input.nextAttemptAt ? { nextAttemptAt: input.nextAttemptAt } : {}), - createdAt: input.nowIso, - updatedAt: input.nowIso, - }; - existing.items[input.id] = item; - await this.writeOutboxFile(input.teamName, existing); - result = { ok: true, outcome: 'created', item }; + ); }); }); @@ -434,33 +765,54 @@ export class JsonMemberWorkSyncStore async claimDue(input: MemberWorkSyncOutboxClaimInput): Promise { const claimed: MemberWorkSyncOutboxItem[] = []; await this.enqueue(input.teamName, async () => { - await withFileLock(this.paths.getOutboxPath(input.teamName), async () => { - const existing = await this.readOutboxFile(input.teamName); - const due = Object.values(existing.items) - .filter((item) => canClaimOutboxItem(item, input.nowIso)) - .sort((left, right) => { - const leftTime = left.nextAttemptAt ?? left.updatedAt; - const rightTime = right.nextAttemptAt ?? right.updatedAt; - return leftTime.localeCompare(rightTime); - }) - .slice(0, Math.max(0, input.limit)); - - for (const item of due) { - const next: MemberWorkSyncOutboxItem = { - ...item, - status: 'claimed', - attemptGeneration: item.attemptGeneration + 1, - claimedBy: input.claimedBy, - claimedAt: input.nowIso, - updatedAt: input.nowIso, - }; - delete next.lastError; - existing.items[item.id] = next; - claimed.push(next); + await withFileLock(this.paths.getOutboxIndexPath(input.teamName), async () => { + let index = await this.readOutboxIndexFile(input.teamName); + if (Object.keys(index.items).length === 0) { + index = await this.repairOutboxIndex(input.teamName); + } + let dueRoutes = getDueOutboxRoutes(index, input.nowIso, input.limit); + if ( + dueRoutes.length > 0 && + dueRoutes.length < Math.max(0, input.limit) && + (await this.hasMissingIndexedDueOutboxItem(input.teamName, index, input.nowIso)) + ) { + index = await this.repairOutboxIndex(input.teamName); + dueRoutes = getDueOutboxRoutes(index, input.nowIso, input.limit); } - if (due.length > 0) { - await this.writeOutboxFile(input.teamName, existing); + let staleIndex = false; + for (const [id, route] of dueRoutes) { + await withFileLock( + this.paths.getMemberOutboxPath(input.teamName, route.memberName), + async () => { + const outbox = await this.readMemberOutboxFile(input.teamName, route.memberName); + const item = outbox.items[id]; + if (!item || !canClaimOutboxItem(item, input.nowIso)) { + delete index.items[id]; + staleIndex = true; + return; + } + const next: MemberWorkSyncOutboxItem = { + ...item, + status: 'claimed', + attemptGeneration: item.attemptGeneration + 1, + claimedBy: input.claimedBy, + claimedAt: input.nowIso, + updatedAt: input.nowIso, + }; + delete next.lastError; + outbox.items[id] = next; + await this.writeMemberOutboxFile(input.teamName, route.memberName, outbox); + index.items[id] = toOutboxIndexItem(next, route.memberKey); + claimed.push(next); + } + ); + } + + if (staleIndex) { + index = await this.repairOutboxIndex(input.teamName); + } else if (dueRoutes.length > 0) { + await this.writeOutboxIndexFile(input.teamName, index); } }); }); @@ -519,69 +871,178 @@ export class JsonMemberWorkSyncStore async countRecentDelivered( input: MemberWorkSyncOutboxCountRecentDeliveredInput ): Promise { - const file = await this.readOutboxFile(input.teamName); - return Object.values(file.items).filter( + let index = await this.readOutboxIndexFile(input.teamName); + if (Object.keys(index.items).length === 0) { + await this.enqueue(input.teamName, async () => { + await withFileLock(this.paths.getOutboxIndexPath(input.teamName), async () => { + index = await this.readOutboxIndexFile(input.teamName); + if (Object.keys(index.items).length === 0) { + index = await this.repairOutboxIndex(input.teamName); + } + }); + }); + } + const indexedCount = Object.values(index.items).filter( (item) => - item.memberName.trim().toLowerCase() === input.memberName.trim().toLowerCase() && + normalizeMemberKey(item.memberName) === normalizeMemberKey(input.memberName) && item.status === 'delivered' && item.updatedAt >= input.sinceIso ).length; - } - - private async readFile(teamName: string): Promise { - const filePath = this.paths.getStatusPath(teamName); - try { - const raw = await readFile(filePath, 'utf8'); - const parsed = JSON.parse(raw); - if (isStoreFile(parsed)) { - return parsed; - } - await quarantineFile(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - await quarantineFile(filePath); - } + const memberOutbox = await this.readMemberOutboxFile(input.teamName, input.memberName); + const memberFileCount = Object.values(memberOutbox.items).filter( + (item) => item.status === 'delivered' && item.updatedAt >= input.sinceIso + ).length; + if (memberFileCount > indexedCount) { + await this.enqueue(input.teamName, async () => { + await withFileLock(this.paths.getOutboxIndexPath(input.teamName), async () => { + await this.repairOutboxIndex(input.teamName); + }); + }); } - return { schemaVersion: 1, members: {}, metrics: { recentEvents: [] } }; + return Math.max(indexedCount, memberFileCount); } - private async readPendingFile(teamName: string): Promise { - const filePath = this.paths.getPendingReportsPath(teamName); - try { - const raw = await readFile(filePath, 'utf8'); - const parsed = JSON.parse(raw); - if (isPendingReportFile(parsed)) { - return parsed; - } - await quarantineFile(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - await quarantineFile(filePath); - } - } - return { schemaVersion: 1, intents: {} }; + private async readLegacyStatusFile(teamName: string): Promise { + return readJsonFile( + this.paths.getLegacyStatusPath(teamName), + isLegacyStatusFile, + { schemaVersion: 1, members: {}, metrics: { recentEvents: [] } }, + { quarantineInvalid: true } + ); } - private async readOutboxFile(teamName: string): Promise { - const filePath = this.paths.getOutboxPath(teamName); - try { - const raw = await readFile(filePath, 'utf8'); - const parsed = JSON.parse(raw); - if (isOutboxFile(parsed)) { - return parsed; - } - await quarantineFile(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - await quarantineFile(filePath); - } - } - return { schemaVersion: 1, items: {} }; + private async readMemberStatusFile( + teamName: string, + memberName: string + ): Promise { + const file = await readJsonFile( + this.paths.getMemberStatusPath(teamName, memberName), + (value): value is MemberStatusFile | null => value === null || isMemberStatusFile(value), + null, + { quarantineInvalid: true } + ); + return file; } - private async writeOutboxFile(teamName: string, file: OutboxFile): Promise { - await mkdir(this.paths.getTeamDir(teamName), { recursive: true }); - await atomicWriteAsync(this.paths.getOutboxPath(teamName), JSON.stringify(file, null, 2)); + private async writeMemberStatusFile(status: MemberWorkSyncStatus): Promise { + await writeJsonFile(this.paths.getMemberStatusPath(status.teamName, status.memberName), { + schemaVersion: 2, + status, + } satisfies MemberStatusFile); + } + + private async readMetricsIndexFile(teamName: string): Promise { + return readJsonFile( + this.paths.getMetricsIndexPath(teamName), + isMetricsIndexFile, + emptyMetricsIndex(), + { + quarantineInvalid: true, + } + ); + } + + private async writeMetricsIndexFile(teamName: string, file: MetricsIndexFile): Promise { + await writeJsonFile(this.paths.getMetricsIndexPath(teamName), file); + } + + private async readLegacyPendingFile(teamName: string): Promise { + return readJsonFile( + this.paths.getLegacyPendingReportsPath(teamName), + isLegacyPendingReportFile, + { schemaVersion: 1, intents: {} }, + { quarantineInvalid: true } + ); + } + + private async readMemberReportsFile( + teamName: string, + memberName: string + ): Promise { + return readJsonFile( + this.paths.getMemberReportsPath(teamName, memberName), + isMemberReportsFile, + { schemaVersion: 2, intents: {} }, + { quarantineInvalid: true } + ); + } + + private async writeMemberReportsFile( + teamName: string, + memberName: string, + file: MemberReportsFile + ): Promise { + await this.paths.ensureMemberWorkSyncDir(teamName, memberName); + await writeJsonFile(this.paths.getMemberReportsPath(teamName, memberName), file); + } + + private async readPendingReportsIndexFile(teamName: string): Promise { + return readJsonFile( + this.paths.getPendingReportsIndexPath(teamName), + isPendingReportsIndexFile, + { schemaVersion: 2, items: {} }, + { quarantineInvalid: true } + ); + } + + private async writePendingReportsIndexFile( + teamName: string, + file: PendingReportsIndexFile + ): Promise { + await writeJsonFile(this.paths.getPendingReportsIndexPath(teamName), file); + } + + private async readLegacyOutboxFile(teamName: string): Promise { + return readJsonFile( + this.paths.getLegacyOutboxPath(teamName), + isLegacyOutboxFile, + { schemaVersion: 1, items: {} }, + { quarantineInvalid: true } + ); + } + + private async readMemberOutboxFile( + teamName: string, + memberName: string + ): Promise { + return readJsonFile( + this.paths.getMemberOutboxPath(teamName, memberName), + isMemberOutboxFile, + { schemaVersion: 2, items: {} }, + { quarantineInvalid: true } + ); + } + + private async writeMemberOutboxFile( + teamName: string, + memberName: string, + file: MemberOutboxFile + ): Promise { + await this.paths.ensureMemberWorkSyncDir(teamName, memberName); + await writeJsonFile(this.paths.getMemberOutboxPath(teamName, memberName), file); + } + + private async readOutboxIndexFile(teamName: string): Promise { + return readJsonFile( + this.paths.getOutboxIndexPath(teamName), + isOutboxIndexFile, + { schemaVersion: 2, items: {} }, + { quarantineInvalid: true } + ); + } + + private async writeOutboxIndexFile(teamName: string, file: OutboxIndexFile): Promise { + await writeJsonFile(this.paths.getOutboxIndexPath(teamName), file); + } + + private async upsertOutboxIndexItem( + teamName: string, + item: MemberWorkSyncOutboxItem, + memberKey: string + ): Promise { + const index = await this.readOutboxIndexFile(teamName); + index.items[item.id] = toOutboxIndexItem(item, memberKey); + await this.writeOutboxIndexFile(teamName, index); } private async updateOutboxItem( @@ -590,24 +1051,305 @@ export class JsonMemberWorkSyncStore updater: (current: MemberWorkSyncOutboxItem | undefined) => MemberWorkSyncOutboxItem | undefined ): Promise { await this.enqueue(teamName, async () => { - await withFileLock(this.paths.getOutboxPath(teamName), async () => { - const existing = await this.readOutboxFile(teamName); - const next = updater(existing.items[id]); - if (!next) { + await withFileLock(this.paths.getOutboxIndexPath(teamName), async () => { + let index = await this.readOutboxIndexFile(teamName); + if (!index.items[id]) { + index = await this.repairOutboxIndex(teamName); + } + const route = index.items[id]; + if (!route) { return; } - existing.items[id] = next; - await this.writeOutboxFile(teamName, existing); + await withFileLock(this.paths.getMemberOutboxPath(teamName, route.memberName), async () => { + const outbox = await this.readMemberOutboxFile(teamName, route.memberName); + const next = updater(outbox.items[id]); + if (!next) { + return; + } + outbox.items[id] = next; + await this.writeMemberOutboxFile(teamName, route.memberName, outbox); + index.items[id] = toOutboxIndexItem(next, route.memberKey); + await this.writeOutboxIndexFile(teamName, index); + }); }); }); } - private async writePendingFile(teamName: string, file: PendingReportFile): Promise { - await mkdir(this.paths.getTeamDir(teamName), { recursive: true }); - await atomicWriteAsync( - this.paths.getPendingReportsPath(teamName), - JSON.stringify(file, null, 2) - ); + private async repairMetricsIndex(teamName: string): Promise { + let repaired: MetricsIndexFile | null = null; + await this.enqueue(teamName, async () => { + await withFileLock(this.paths.getMetricsIndexPath(teamName), async () => { + const current = await this.readMetricsIndexFile(teamName); + if (Object.keys(current.members).length > 0 || current.recentEvents.length > 0) { + repaired = current; + return; + } + + const next = emptyMetricsIndex(); + const memberStatuses = await this.scanMemberStatuses(teamName); + for (const status of memberStatuses) { + updateMetricsMember(next, status, this.paths.getMemberKey(status.memberName)); + } + const legacy = await this.readLegacyStatusFile(teamName); + for (const status of Object.values(legacy.members)) { + const memberKey = this.paths.getMemberKey(status.memberName); + if (!next.members[memberKey]) { + updateMetricsMember(next, status, memberKey); + } + } + for (const event of legacy.metrics?.recentEvents ?? []) { + if (!next.recentEvents.some((existing) => existing.id === event.id)) { + next.recentEvents.push(event); + } + } + next.recentEvents.sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); + next.recentEvents = next.recentEvents.slice(-200); + if (Object.keys(next.members).length === 0 && next.recentEvents.length === 0) { + repaired = null; + return; + } + await this.writeMetricsIndexFile(teamName, next); + repaired = next; + }); + }); + + const repairedIndex = repaired as MetricsIndexFile | null; + if (!repairedIndex) { + return null; + } + for (const member of Object.values(repairedIndex.members)) { + await this.appendAudit({ + teamName, + memberName: member.memberName, + event: 'index_repaired', + source: 'json_store', + reason: 'metrics', + agendaFingerprint: member.agendaFingerprint, + state: member.state, + actionableCount: member.actionableCount, + ...(member.providerId ? { providerId: member.providerId } : {}), + }); + } + return repairedIndex; + } + + private async repairPendingReportsIndex(teamName: string): Promise { + const index: PendingReportsIndexFile = { schemaVersion: 2, items: {} }; + const repairedMembers = new Set(); + const legacyMembers = new Set(); + for (const { memberName, reports } of await this.scanMemberReports(teamName)) { + const memberKey = this.paths.getMemberKey(memberName); + for (const intent of Object.values(reports.intents)) { + index.items[intent.id] = toPendingReportIndexItem(intent, memberKey); + repairedMembers.add(intent.memberName); + } + } + for (const intent of Object.values((await this.readLegacyPendingFile(teamName)).intents)) { + const memberKey = this.paths.getMemberKey(intent.memberName); + if (!index.items[intent.id]) { + await withFileLock( + this.paths.getMemberReportsPath(teamName, intent.memberName), + async () => { + const reports = await this.readMemberReportsFile(teamName, intent.memberName); + reports.intents[intent.id] = intent; + await this.writeMemberReportsFile(teamName, intent.memberName, reports); + } + ); + index.items[intent.id] = toPendingReportIndexItem(intent, memberKey); + repairedMembers.add(intent.memberName); + legacyMembers.add(intent.memberName); + } + } + await this.writePendingReportsIndexFile(teamName, index); + for (const memberName of repairedMembers) { + await this.appendAudit({ + teamName, + memberName, + event: 'index_repaired', + source: 'json_store', + reason: 'pending_reports', + }); + } + for (const memberName of legacyMembers) { + await this.appendAudit({ + teamName, + memberName, + event: 'legacy_fallback_used', + source: 'json_store', + reason: 'pending_reports_v1', + }); + } + return index; + } + + private async repairOutboxIndex(teamName: string): Promise { + const index: OutboxIndexFile = { schemaVersion: 2, items: {} }; + const repairedMembers = new Set(); + const legacyMembers = new Set(); + for (const { memberName, outbox } of await this.scanMemberOutboxes(teamName)) { + const memberKey = this.paths.getMemberKey(memberName); + for (const item of Object.values(outbox.items)) { + index.items[item.id] = toOutboxIndexItem(item, memberKey); + repairedMembers.add(item.memberName); + } + } + for (const item of Object.values((await this.readLegacyOutboxFile(teamName)).items)) { + const memberKey = this.paths.getMemberKey(item.memberName); + if (!index.items[item.id]) { + await withFileLock(this.paths.getMemberOutboxPath(teamName, item.memberName), async () => { + const outbox = await this.readMemberOutboxFile(teamName, item.memberName); + outbox.items[item.id] = item; + await this.writeMemberOutboxFile(teamName, item.memberName, outbox); + }); + index.items[item.id] = toOutboxIndexItem(item, memberKey); + repairedMembers.add(item.memberName); + legacyMembers.add(item.memberName); + } + } + await this.writeOutboxIndexFile(teamName, index); + for (const memberName of repairedMembers) { + await this.appendAudit({ + teamName, + memberName, + event: 'index_repaired', + source: 'json_store', + reason: 'outbox', + }); + } + for (const memberName of legacyMembers) { + await this.appendAudit({ + teamName, + memberName, + event: 'legacy_fallback_used', + source: 'json_store', + reason: 'outbox_v1', + }); + } + return index; + } + + private async scanMemberNames(teamName: string): Promise { + const membersDir = join(this.paths.getTeamRootDir(teamName), 'members'); + const entries = await readdir(membersDir, { withFileTypes: true }).catch(() => []); + const names: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const metaPath = join(membersDir, entry.name, 'member.meta.json'); + try { + const raw = await readFile(metaPath, 'utf8'); + const parsed = JSON.parse(raw) as { memberName?: unknown }; + if (typeof parsed.memberName === 'string' && parsed.memberName.trim()) { + names.push(parsed.memberName.trim()); + } + } catch { + // Ignore malformed member storage dirs during repair. + } + } + return names; + } + + private async scanMemberStatuses(teamName: string): Promise { + const statuses: MemberWorkSyncStatus[] = []; + for (const memberName of await this.scanMemberNames(teamName)) { + const file = await this.readMemberStatusFile(teamName, memberName); + if (file) { + statuses.push(file.status); + } + } + return statuses; + } + + private async scanMemberReports( + teamName: string + ): Promise<{ memberName: string; reports: MemberReportsFile }[]> { + const reports: { memberName: string; reports: MemberReportsFile }[] = []; + for (const memberName of await this.scanMemberNames(teamName)) { + reports.push({ memberName, reports: await this.readMemberReportsFile(teamName, memberName) }); + } + return reports; + } + + private async hasMissingIndexedPendingReport( + teamName: string, + index: PendingReportsIndexFile + ): Promise { + const indexedIds = new Set(Object.keys(index.items)); + for (const { reports } of await this.scanMemberReports(teamName)) { + for (const intent of Object.values(reports.intents)) { + if (intent.status === 'pending' && !indexedIds.has(intent.id)) { + return true; + } + } + } + for (const intent of Object.values((await this.readLegacyPendingFile(teamName)).intents)) { + if (intent.status === 'pending' && !indexedIds.has(intent.id)) { + return true; + } + } + return false; + } + + private async scanMemberOutboxes( + teamName: string + ): Promise<{ memberName: string; outbox: MemberOutboxFile }[]> { + const outboxes: { memberName: string; outbox: MemberOutboxFile }[] = []; + for (const memberName of await this.scanMemberNames(teamName)) { + outboxes.push({ memberName, outbox: await this.readMemberOutboxFile(teamName, memberName) }); + } + return outboxes; + } + + private async hasMissingIndexedDueOutboxItem( + teamName: string, + index: OutboxIndexFile, + nowIso: string + ): Promise { + const indexedIds = new Set(Object.keys(index.items)); + for (const { outbox } of await this.scanMemberOutboxes(teamName)) { + for (const item of Object.values(outbox.items)) { + if (canClaimOutboxItem(item, nowIso) && !indexedIds.has(item.id)) { + return true; + } + } + } + for (const item of Object.values((await this.readLegacyOutboxFile(teamName)).items)) { + if (canClaimOutboxItem(item, nowIso) && !indexedIds.has(item.id)) { + return true; + } + } + return false; + } + + private async appendAudit(input: { + teamName: string; + memberName: string; + event: 'status_written' | 'legacy_fallback_used' | 'index_repaired'; + source: string; + agendaFingerprint?: string; + state?: string; + actionableCount?: number; + reason?: string; + triggerReasons?: string[]; + providerId?: string; + }): Promise { + if (!this.deps.auditJournal) { + return; + } + try { + await this.deps.auditJournal.append({ + ...input, + timestamp: this.now().toISOString(), + }); + } catch (error) { + this.deps.logger?.warn('member work sync store audit append failed', { + teamName: input.teamName, + memberName: input.memberName, + event: input.event, + error: String(error), + }); + } } private async enqueue(teamName: string, operation: () => Promise): Promise { @@ -624,3 +1366,30 @@ export class JsonMemberWorkSyncStore await next; } } + +function toOutboxIndexItem( + item: MemberWorkSyncOutboxItem, + memberKey: string +): OutboxIndexFile['items'][string] { + return { + memberKey, + memberName: item.memberName, + status: item.status, + ...(item.nextAttemptAt ? { nextAttemptAt: item.nextAttemptAt } : {}), + updatedAt: item.updatedAt, + createdAt: item.createdAt, + }; +} + +function toPendingReportIndexItem( + intent: MemberWorkSyncReportIntent, + memberKey: string +): PendingReportsIndexFile['items'][string] { + return { + memberKey, + memberName: intent.memberName, + status: intent.status, + recordedAt: intent.recordedAt, + ...(intent.processedAt ? { processedAt: intent.processedAt } : {}), + }; +} diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts index 87c963a5..82436b55 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncEventQueue.ts @@ -1,4 +1,8 @@ -import type { MemberWorkSyncLoggerPort } from '../../core/application'; +import type { + MemberWorkSyncAuditEvent, + MemberWorkSyncAuditJournalPort, + MemberWorkSyncLoggerPort, +} from '../../core/application'; import type { MemberWorkSyncReconcileContext } from '../../core/application/MemberWorkSyncReconciler'; export type MemberWorkSyncTriggerReason = @@ -43,6 +47,8 @@ export interface MemberWorkSyncEventQueueDeps { quietWindowMs?: number; concurrency?: number; now?: () => number; + nowIso?: () => string; + auditJournal?: MemberWorkSyncAuditJournalPort; logger?: MemberWorkSyncLoggerPort; } @@ -61,6 +67,7 @@ export class MemberWorkSyncEventQueue { private readonly quietWindowMs: number; private readonly concurrency: number; private readonly now: () => number; + private readonly nowIso: () => string; private timer: ReturnType | null = null; private stopped = false; private counters = { @@ -75,6 +82,7 @@ export class MemberWorkSyncEventQueue { this.quietWindowMs = deps.quietWindowMs ?? 90_000; this.concurrency = Math.max(1, deps.concurrency ?? 2); this.now = deps.now ?? Date.now; + this.nowIso = deps.nowIso ?? (() => new Date().toISOString()); } enqueue(input: { @@ -87,19 +95,27 @@ export class MemberWorkSyncEventQueue { return; } + const teamName = input.teamName.trim(); const memberName = input.memberName.trim(); - if (!input.teamName.trim() || !memberName) { + if (!teamName || !memberName) { this.counters.dropped += 1; return; } - const key = keyOf(input.teamName, memberName); + const key = keyOf(teamName, memberName); const runAt = this.now() + (input.runAfterMs ?? this.quietWindowMs); const running = this.running.get(key); if (running) { running.rerunRequested = true; running.triggerReasons.add(input.triggerReason); this.counters.coalesced += 1; + this.appendAudit({ + teamName, + memberName, + event: 'queue_coalesced', + source: 'event_queue', + reason: input.triggerReason, + }); return; } @@ -108,17 +124,31 @@ export class MemberWorkSyncEventQueue { existing.triggerReasons.add(input.triggerReason); existing.runAt = Math.max(existing.runAt, runAt); this.counters.coalesced += 1; + this.appendAudit({ + teamName, + memberName, + event: 'queue_coalesced', + source: 'event_queue', + reason: input.triggerReason, + }); this.schedule(); return; } this.items.set(key, { - teamName: input.teamName, + teamName, memberName, runAt, triggerReasons: new Set([input.triggerReason]), }); this.counters.enqueued += 1; + this.appendAudit({ + teamName, + memberName, + event: 'queue_enqueued', + source: 'event_queue', + reason: input.triggerReason, + }); this.schedule(); } @@ -231,6 +261,13 @@ export class MemberWorkSyncEventQueue { private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise { if (!(await this.deps.isTeamActive(item.teamName))) { this.counters.dropped += 1; + this.appendAudit({ + teamName: item.teamName, + memberName: item.memberName, + event: 'queue_dropped', + source: 'event_queue', + reason: 'team_inactive', + }); return; } @@ -242,5 +279,31 @@ export class MemberWorkSyncEventQueue { } ); this.counters.reconciled += 1; + this.appendAudit({ + teamName: item.teamName, + memberName: item.memberName, + event: 'queue_reconciled', + source: 'event_queue', + triggerReasons: [...running.triggerReasons].sort(), + }); + } + + private appendAudit(input: Omit): void { + if (!this.deps.auditJournal) { + return; + } + void this.deps.auditJournal + .append({ + ...input, + timestamp: this.nowIso(), + }) + .catch((error: unknown) => { + this.deps.logger?.warn('member work sync queue audit append failed', { + teamName: input.teamName, + memberName: input.memberName, + event: input.event, + error: String(error), + }); + }); } } diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts index 55d1facc..27635ace 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts @@ -1,7 +1,17 @@ import { join } from 'path'; +import { TeamMemberStoragePaths } from '@main/services/team/TeamMemberStoragePaths'; + export class MemberWorkSyncStorePaths { - constructor(private readonly teamsBasePath: string) {} + private readonly memberStorage: TeamMemberStoragePaths; + + constructor(private readonly teamsBasePath: string) { + this.memberStorage = new TeamMemberStoragePaths(teamsBasePath); + } + + getTeamRootDir(teamName: string): string { + return join(this.teamsBasePath, teamName); + } getTeamDir(teamName: string): string { return join(this.teamsBasePath, teamName, '.member-work-sync'); @@ -22,4 +32,64 @@ export class MemberWorkSyncStorePaths { getReportTokenSecretPath(teamName: string): string { return join(this.getTeamDir(teamName), 'report-token-secret.json'); } + + getIndexesDir(teamName: string): string { + return join(this.getTeamDir(teamName), 'indexes'); + } + + getMetricsIndexPath(teamName: string): string { + return join(this.getIndexesDir(teamName), 'metrics.json'); + } + + getOutboxIndexPath(teamName: string): string { + return join(this.getIndexesDir(teamName), 'outbox-index.json'); + } + + getPendingReportsIndexPath(teamName: string): string { + return join(this.getIndexesDir(teamName), 'pending-reports-index.json'); + } + + getLegacyStatusPath(teamName: string): string { + return this.getStatusPath(teamName); + } + + getLegacyPendingReportsPath(teamName: string): string { + return this.getPendingReportsPath(teamName); + } + + getLegacyOutboxPath(teamName: string): string { + return this.getOutboxPath(teamName); + } + + getMemberKey(memberName: string): string { + return this.memberStorage.getMemberKey(memberName); + } + + getMemberDir(teamName: string, memberName: string): string { + return this.memberStorage.getMemberDir(teamName, memberName); + } + + getMemberWorkSyncDir(teamName: string, memberName: string): string { + return this.memberStorage.getMemberFeatureDir(teamName, memberName, '.member-work-sync'); + } + + getMemberStatusPath(teamName: string, memberName: string): string { + return join(this.getMemberWorkSyncDir(teamName, memberName), 'status.json'); + } + + getMemberReportsPath(teamName: string, memberName: string): string { + return join(this.getMemberWorkSyncDir(teamName, memberName), 'reports.json'); + } + + getMemberOutboxPath(teamName: string, memberName: string): string { + return join(this.getMemberWorkSyncDir(teamName, memberName), 'outbox.json'); + } + + getMemberJournalPath(teamName: string, memberName: string): string { + return join(this.getMemberWorkSyncDir(teamName, memberName), 'journal.jsonl'); + } + + async ensureMemberWorkSyncDir(teamName: string, memberName: string): Promise { + await this.memberStorage.ensureMemberMeta(teamName, memberName); + } } diff --git a/src/main/services/infrastructure/EventLoopLagMonitor.ts b/src/main/services/infrastructure/EventLoopLagMonitor.ts index c47fe9b8..58022cde 100644 --- a/src/main/services/infrastructure/EventLoopLagMonitor.ts +++ b/src/main/services/infrastructure/EventLoopLagMonitor.ts @@ -4,14 +4,24 @@ import { createLogger } from '@shared/utils/logger'; const logger = createLogger('Perf:EventLoop'); +const DEFAULT_MAX_STALL_THRESHOLD_MS = 750; +const DEFAULT_REPORT_INTERVAL_MS = 30_000; + let started = false; let currentOp: string | null = null; +let lastReportAt = 0; + +function isEnabled(): boolean { + const raw = process.env.CLAUDE_TEAM_EVENT_LOOP_LAG_MONITOR_ENABLED?.trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} export function setCurrentMainOp(op: string | null): void { currentOp = op; } export function startEventLoopLagMonitor(): void { + if (!isEnabled()) return; if (started) return; started = true; @@ -24,14 +34,19 @@ export function startEventLoopLagMonitor(): void { // Reset first so next window is clean even if logging throws h.reset(); - // Only report meaningful stalls - if (maxMs < 250) return; + // Only report severe stalls. Sub-second blips are common during expected + // Electron/main-process IO and are too noisy for default development logs. + if (maxMs < DEFAULT_MAX_STALL_THRESHOLD_MS) return; // For known IPC/main-thread operations we already emit operation-specific // timing diagnostics. Suppress the generic event-loop warning to avoid // duplicate noisy logs that do not add new debugging value. if (currentOp) return; + const now = Date.now(); + if (now - lastReportAt < DEFAULT_REPORT_INTERVAL_MS) return; + lastReportAt = now; + logger.warn( `Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` + (currentOp ? ` op=${currentOp}` : '') diff --git a/src/main/services/team/TeamBackupService.ts b/src/main/services/team/TeamBackupService.ts index fca34e30..8035e045 100644 --- a/src/main/services/team/TeamBackupService.ts +++ b/src/main/services/team/TeamBackupService.ts @@ -70,8 +70,9 @@ const TEAM_ROOT_FILES = [ // Subdirs under ~/.claude/teams/{teamName}/ const TEAM_SUBDIRS = ['inboxes', 'review-decisions']; -const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime']; +const TEAM_RECURSIVE_SUBDIRS = ['.opencode-runtime', 'members']; const ATOMIC_WRITE_TEMP_FILE_PREFIX = '.tmp.'; +const FILE_LOCK_SUFFIX = '.lock'; const QUARANTINED_OPENCODE_LANE_INDEX_RE = /^lanes\.invalid\.\d+\.json$/; // Subdirs under getAppDataPath() (our own storage, not in ~/.claude/) const APP_DATA_SUBDIRS = ['attachments']; @@ -112,6 +113,9 @@ function shouldCollectRecursiveBackupFile(relPath: string): boolean { if (fileName.startsWith(ATOMIC_WRITE_TEMP_FILE_PREFIX)) { return false; } + if (fileName.endsWith(FILE_LOCK_SUFFIX)) { + return false; + } // Runtime quarantine files are diagnostic snapshots of invalid JSON. if (QUARANTINED_OPENCODE_LANE_INDEX_RE.test(fileName)) { return false; diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 9131b462..3c18ea0f 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -480,6 +480,7 @@ function normalizePersistedMemberState( parsed.pendingPermissionRequestIds ), runtimePid: normalizeRuntimePid(parsed.runtimePid), + runtimeRunId: normalizeOptionalString(parsed.runtimeRunId), runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId), livenessKind, pidSource: normalizePidSource(parsed.pidSource), diff --git a/src/main/services/team/TeamMemberStoragePaths.ts b/src/main/services/team/TeamMemberStoragePaths.ts new file mode 100644 index 00000000..92fd8cef --- /dev/null +++ b/src/main/services/team/TeamMemberStoragePaths.ts @@ -0,0 +1,109 @@ +import { mkdir, readFile } from 'fs/promises'; +import { join } from 'path'; + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; + +export interface TeamMemberStorageMetaFile { + schemaVersion: 1; + memberName: string; + memberKey: string; + updatedAt: string; +} + +export function normalizeTeamMemberStorageName(memberName: string): string { + return memberName.trim().toLowerCase(); +} + +export function encodeTeamMemberStorageKey(memberName: string): string { + const normalized = normalizeTeamMemberStorageName(memberName); + if (!normalized) { + throw new Error('memberName is required for member-scoped storage'); + } + const encoded = encodeURIComponent(normalized); + if (encoded === '.') { + return '%2E'; + } + if (encoded === '..') { + return '%2E%2E'; + } + return encoded; +} + +function isMetaFile(value: unknown): value is TeamMemberStorageMetaFile { + return ( + value != null && + typeof value === 'object' && + (value as TeamMemberStorageMetaFile).schemaVersion === 1 && + typeof (value as TeamMemberStorageMetaFile).memberName === 'string' && + typeof (value as TeamMemberStorageMetaFile).memberKey === 'string' && + typeof (value as TeamMemberStorageMetaFile).updatedAt === 'string' + ); +} + +export class TeamMemberStoragePaths { + constructor(private readonly teamsBasePath: string) {} + + getTeamDir(teamName: string): string { + return join(this.teamsBasePath, teamName); + } + + getMembersDir(teamName: string): string { + return join(this.getTeamDir(teamName), 'members'); + } + + getMemberKey(memberName: string): string { + return encodeTeamMemberStorageKey(memberName); + } + + getMemberDir(teamName: string, memberName: string): string { + return join(this.getMembersDir(teamName), this.getMemberKey(memberName)); + } + + getMemberMetaPath(teamName: string, memberName: string): string { + return join(this.getMemberDir(teamName, memberName), 'member.meta.json'); + } + + getMemberFeatureDir(teamName: string, memberName: string, featureDirName: string): string { + const featureDirSegment = featureDirName.trim(); + if ( + !featureDirSegment || + featureDirSegment === '.' || + featureDirSegment === '..' || + featureDirSegment.includes('/') || + featureDirSegment.includes('\\') + ) { + throw new Error('featureDirName must be a single path segment'); + } + return join(this.getMemberDir(teamName, memberName), featureDirSegment); + } + + async ensureMemberMeta(teamName: string, memberName: string): Promise { + const canonicalMemberName = memberName.trim(); + const memberKey = this.getMemberKey(canonicalMemberName); + const metaPath = this.getMemberMetaPath(teamName, canonicalMemberName); + const existing = await this.readMeta(metaPath); + if (existing?.memberName === canonicalMemberName && existing.memberKey === memberKey) { + return existing; + } + + const next: TeamMemberStorageMetaFile = { + schemaVersion: 1, + memberName: canonicalMemberName, + memberKey, + updatedAt: new Date().toISOString(), + }; + await mkdir(this.getMemberDir(teamName, canonicalMemberName), { recursive: true }); + await atomicWriteAsync(metaPath, `${JSON.stringify(next, null, 2)}\n`); + return next; + } + + private async readMeta(filePath: string): Promise { + try { + const raw = await readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return isMetaFile(parsed) ? parsed : null; + } catch { + return null; + } + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9d55b060..87861de5 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -167,6 +167,7 @@ import { } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { createRuntimeRunTombstoneStore, + RuntimeStaleEvidenceError, type RuntimeEvidenceKind, } from './opencode/store/RuntimeRunTombstoneStore'; import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; @@ -1411,6 +1412,11 @@ interface ProvisioningRun { effectiveMembers: TeamCreateRequest['members']; launchIdentity: ProviderModelLaunchIdentity | null; mixedSecondaryLanes: MixedSecondaryRuntimeLaneState[]; + /** + * OpenCode secondary lanes share bridge state files. Launch them sequentially + * per team run to avoid file-lock contention while keeping launch non-blocking. + */ + mixedSecondaryLaneLaunchQueue?: Promise; lastLogProgressAt: number; /** Monotonic ms timestamp of last stdout/stderr data. For stall detection. */ lastDataReceivedAt: number; @@ -1619,7 +1625,7 @@ interface PromptSizeSummary { lines: number; } -const MEMBER_LAUNCH_GRACE_MS = 90_000; +const MEMBER_LAUNCH_GRACE_MS = 150_000; const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; export function shouldWarnOnUnreadableMemberAuditConfig(params: { @@ -1809,6 +1815,18 @@ function isRecoverablePersistedOpenCodeRuntimeCandidate( ); } +function isPersistedOpenCodeSecondaryLaneMember( + member: PersistedTeamLaunchMemberState | undefined | null +): boolean { + return ( + member?.providerId === 'opencode' && + member.laneKind === 'secondary' && + member.laneOwnerProviderId === 'opencode' && + typeof member.laneId === 'string' && + member.laneId.trim().length > 0 + ); +} + function isRecoverablePersistedOpenCodeTerminalRuntimeCandidate( member: PersistedTeamLaunchMemberState | undefined | null ): boolean { @@ -6081,6 +6099,26 @@ export class TeamProvisioningService { runtimeActive = true; } } + if ( + runtimeActive && + laneIdentity.laneKind === 'secondary' && + laneIdentity.laneOwnerProviderId === 'opencode' && + !liveSecondaryLaneRunId + ) { + const staleLane = await recoverStaleOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: laneIdentity.laneId, + }); + if (staleLane.stale) { + this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId); + return { + delivered: false, + reason: 'opencode_runtime_not_active', + diagnostics: staleLane.diagnostics, + }; + } + } if (!runtimeActive) { this.cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName); return { delivered: false, reason: 'opencode_runtime_not_active' }; @@ -8193,6 +8231,38 @@ export class TeamProvisioningService { laneId, evidenceKind: 'bootstrap_checkin', }); + const idempotent = await this.resolveOpenCodeRuntimeBootstrapCheckinIdempotency({ + teamName, + runId, + memberName, + runtimeSessionId, + }); + await this.assertOpenCodeRuntimeMemberCheckinAllowed({ + teamName, + memberName, + previousMember: idempotent.previousMember, + }); + if (idempotent.state === 'duplicate') { + return { + ok: true, + providerId: 'opencode', + teamName, + runId, + state: 'accepted', + memberName, + runtimeSessionId, + diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'], + observedAt, + }; + } + if (idempotent.state === 'conflict') { + throw new RuntimeStaleEvidenceError( + `opencode_bootstrap_checkin_session_conflict: existing runtime session ${idempotent.existingRuntimeSessionId}, received ${runtimeSessionId} for ${memberName}`, + 'run_mismatch', + 'bootstrap_checkin', + runId + ); + } await this.updateOpenCodeRuntimeMemberLiveness({ teamName, runId, @@ -8217,6 +8287,95 @@ export class TeamProvisioningService { }; } + private async resolveOpenCodeRuntimeBootstrapCheckinIdempotency(input: { + teamName: string; + runId: string; + memberName: string; + runtimeSessionId: string; + }): Promise< + | { + state: 'new'; + previousMember?: PersistedTeamLaunchMemberState; + } + | { + state: 'duplicate'; + previousMember: PersistedTeamLaunchMemberState; + } + | { + state: 'conflict'; + previousMember: PersistedTeamLaunchMemberState; + existingRuntimeSessionId: string; + } + > { + const snapshot = await this.launchStateStore.read(input.teamName); + const previousMember = snapshot?.members[input.memberName]; + if (!previousMember) { + return { state: 'new' }; + } + + const existingRuntimeSessionId = previousMember.runtimeSessionId?.trim(); + const existingRuntimeRunId = + typeof previousMember.runtimeRunId === 'string' ? previousMember.runtimeRunId.trim() : ''; + const hasAcceptedBootstrap = + previousMember.bootstrapConfirmed === true || + previousMember.livenessKind === 'confirmed_bootstrap' || + previousMember.launchState === 'confirmed_alive'; + + if (!hasAcceptedBootstrap || !existingRuntimeSessionId) { + return { state: 'new', previousMember }; + } + + if (existingRuntimeRunId && existingRuntimeRunId !== input.runId) { + return { state: 'new', previousMember }; + } + + if (existingRuntimeSessionId === input.runtimeSessionId) { + return { state: 'duplicate', previousMember }; + } + + if (!existingRuntimeRunId) { + return { state: 'new', previousMember }; + } + + return { + state: 'conflict', + previousMember, + existingRuntimeSessionId, + }; + } + + private async assertOpenCodeRuntimeMemberCheckinAllowed(input: { + teamName: string; + memberName: string; + previousMember?: PersistedTeamLaunchMemberState; + }): Promise { + const config = await this.configReader.getConfig(input.teamName).catch(() => null); + const metaMembers = await this.membersMetaStore.getMembers(input.teamName).catch(() => []); + const configuredMember = this.resolveEffectiveConfiguredMember( + config?.members ?? [], + metaMembers, + input.memberName + ); + + if (configuredMember?.removedAt != null) { + throw new RuntimeStaleEvidenceError( + `Rejected OpenCode bootstrap check-in for removed member "${input.memberName}"`, + 'run_mismatch', + 'bootstrap_checkin', + null + ); + } + + if (!configuredMember && !input.previousMember) { + throw new RuntimeStaleEvidenceError( + `Rejected OpenCode bootstrap check-in for unconfigured member "${input.memberName}"`, + 'run_mismatch', + 'bootstrap_checkin', + null + ); + } + } + async deliverOpenCodeRuntimeMessage(raw: unknown): Promise { const payload = asRuntimeRecord(raw); const teamName = requireRuntimeString(payload.teamName, 'teamName'); @@ -8386,6 +8545,16 @@ export class TeamProvisioningService { .map((member) => (typeof member.name === 'string' ? member.name.trim() : '')) .filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name })); const previousMember = previous?.members[input.memberName]; + const previousRuntimeRunId = + typeof previousMember?.runtimeRunId === 'string' ? previousMember.runtimeRunId.trim() : ''; + const sameRuntimeRun = previousRuntimeRunId.length > 0 && previousRuntimeRunId === input.runId; + const runtimePid = + input.metadata?.runtimePid ?? (sameRuntimeRun ? previousMember?.runtimePid : undefined); + const pidSource = input.metadata?.runtimePid + ? ('runtime_bootstrap' as const) + : sameRuntimeRun + ? previousMember?.pidSource + : undefined; const persistedIdentity = this.resolvePersistedRuntimeMemberIdentity({ teamName: input.teamName, memberName: input.memberName, @@ -8400,10 +8569,11 @@ export class TeamProvisioningService { runtimeAlive: true, bootstrapConfirmed: true, hardFailure: false, - ...(input.metadata?.runtimePid ? { runtimePid: input.metadata.runtimePid } : {}), + runtimePid, + runtimeRunId: input.runId, runtimeSessionId: input.runtimeSessionId, livenessKind: 'confirmed_bootstrap', - ...(input.metadata?.runtimePid ? { pidSource: 'runtime_bootstrap' as const } : {}), + pidSource, runtimeDiagnostic: input.reason, runtimeDiagnosticSeverity: 'info', runtimeLastSeenAt: input.observedAt, @@ -10854,6 +11024,7 @@ export class TeamProvisioningService { return; } await this.reconcileBootstrapTranscriptFailures(run); + await this.reconcileBootstrapTranscriptSuccesses(run); if (this.shouldSkipMemberSpawnAudit(run)) { return; } @@ -10899,9 +11070,17 @@ export class TeamProvisioningService { private async reconcileBootstrapTranscriptSuccesses(run: ProvisioningRun): Promise { for (const memberName of run.expectedMembers ?? []) { const current = run.memberSpawnStatuses.get(memberName); + if (this.isOpenCodeSecondaryLaneMemberInRun(run, memberName)) { + continue; + } + const failureReason = current?.hardFailureReason ?? current?.error; + const canClearFailedBootstrap = + current?.launchState === 'failed_to_start' && + current.agentToolAccepted === true && + isAutoClearableLaunchFailureReason(failureReason); if ( !current || - current.launchState === 'failed_to_start' || + (current.launchState === 'failed_to_start' && !canClearFailedBootstrap) || current.launchState === 'confirmed_alive' || current.bootstrapConfirmed === true || current.agentToolAccepted !== true @@ -10922,6 +11101,11 @@ export class TeamProvisioningService { } } + private isOpenCodeSecondaryLaneMemberInRun(run: ProvisioningRun, memberName: string): boolean { + const lanes = Array.isArray(run.mixedSecondaryLanes) ? run.mixedSecondaryLanes : []; + return lanes.some((lane) => lane.providerId === 'opencode' && lane.member.name === memberName); + } + private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000; private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000; @@ -17848,8 +18032,13 @@ export class TeamProvisioningService { lane.state = 'launching'; lane.runId = lane.runId ?? randomUUID(); - void (async () => { + const launch = async () => { try { + if (run.cancelRequested || run.processKilled) { + this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); + lane.state = 'finished'; + return; + } await this.launchSingleMixedSecondaryLane(run, lane); } catch (error) { if (run.cancelRequested || run.processKilled) { @@ -17879,7 +18068,18 @@ export class TeamProvisioningService { await this.publishMixedSecondaryLaneStatusChange(run, lane).catch(() => undefined); lane.state = 'finished'; } - })(); + }; + + const previousLaunch = run.mixedSecondaryLaneLaunchQueue ?? Promise.resolve(); + const nextLaunch = previousLaunch.catch(() => undefined).then(launch); + run.mixedSecondaryLaneLaunchQueue = nextLaunch.catch((error) => { + logger.warn( + `[${run.teamName}] OpenCode secondary lane launch queue failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + void run.mixedSecondaryLaneLaunchQueue; } private async launchMixedSecondaryLaneIfNeeded( @@ -18085,7 +18285,7 @@ export class TeamProvisioningService { projectPath, previousLaunchState: persistedSnapshot ?? bootstrapSnapshot, }); - if (runtimeEvidence) { + if (isRecoverableOpenCodeRuntimeEvidence(runtimeEvidence)) { recoveredAny = true; secondaryMembers.push({ laneId: laneIdentity.laneId, @@ -18340,7 +18540,10 @@ export class TeamProvisioningService { expected, Number.isFinite(acceptedAtMs) ? acceptedAtMs : null ); - if (transcriptOutcome) { + if ( + transcriptOutcome && + (transcriptOutcome.kind !== 'success' || !isPersistedOpenCodeSecondaryLaneMember(current)) + ) { return true; } } @@ -18481,12 +18684,17 @@ export class TeamProvisioningService { hardFailure: false, lastEvaluatedAt: now, }; + const isOpenCodeSecondaryLaneMember = isPersistedOpenCodeSecondaryLaneMember(current); if (bootstrapMember?.agentToolAccepted && !current.agentToolAccepted) { current.agentToolAccepted = true; current.firstSpawnAcceptedAt = current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt; } - if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) { + if ( + bootstrapMember?.bootstrapConfirmed && + !current.bootstrapConfirmed && + !isOpenCodeSecondaryLaneMember + ) { current.bootstrapConfirmed = true; current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt; } @@ -18546,7 +18754,7 @@ export class TeamProvisioningService { current.hardFailure = true; current.hardFailureReason = heartbeatReason; current.sources.hardFailureSignal = true; - } else if (heartbeatMessage) { + } else if (heartbeatMessage && !isOpenCodeSecondaryLaneMember) { current.bootstrapConfirmed = true; current.lastHeartbeatAt = heartbeatMessage.timestamp; current.hardFailure = false; @@ -18558,7 +18766,7 @@ export class TeamProvisioningService { expected, Number.isFinite(acceptedAtMs) ? acceptedAtMs : null ); - if (transcriptOutcome?.kind === 'success') { + if (transcriptOutcome?.kind === 'success' && !isOpenCodeSecondaryLaneMember) { current.bootstrapConfirmed = true; current.lastHeartbeatAt = current.lastHeartbeatAt ?? transcriptOutcome.observedAt; current.hardFailure = false; diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index 8939a180..a0f5a6ea 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -9,6 +9,7 @@ import { withFileLock } from '../../fileLock'; import { createDefaultRuntimeStoreManifest, createRuntimeStoreManifestStore, + OPENCODE_RUNTIME_STORE_DESCRIPTORS, OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, validateRuntimeStoreManifest, } from './RuntimeStoreManifest'; @@ -28,11 +29,19 @@ const OPENCODE_TEAM_RUNTIME_LANES_DIR = 'lanes'; const OPENCODE_TEAM_RUNTIME_LANES_INDEX_FILE = 'lanes.json'; const OPENCODE_RUNTIME_MANIFEST_FILE = 'manifest.json'; const OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE = 'opencode-run-tombstones.json'; +const OPENCODE_ACTIVE_EMPTY_LANE_STALE_MS = 150_000; const OPENCODE_LANE_INDEX_LOCK_OPTIONS = { acquireTimeoutMs: 30_000, staleTimeoutMs: 25_000, retryIntervalMs: 25, } as const; +const OPENCODE_RUNTIME_EVIDENCE_FILES = new Set( + OPENCODE_RUNTIME_STORE_DESCRIPTORS.filter( + (descriptor) => + descriptor.schemaName !== 'opencode.promptDeliveryLedger' && + descriptor.schemaName !== 'opencode.deliveryJournal' + ).map((descriptor) => descriptor.relativePath) +); export interface OpenCodeRuntimeLaneIndexEntry { laneId: string; @@ -318,6 +327,9 @@ export async function inspectOpenCodeRuntimeLaneStorage(params: { }): Promise<{ laneDirectoryExists: boolean; hasStateOnDisk: boolean; + hasRuntimeEvidenceOnDisk: boolean; + manifestEntryCount: number | null; + manifestUpdatedAt: string | null; fileNames: string[]; }> { const laneDir = getOpenCodeTeamRuntimeLaneDirectory( @@ -330,14 +342,38 @@ export async function inspectOpenCodeRuntimeLaneStorage(params: { return { laneDirectoryExists: false, hasStateOnDisk: false, + hasRuntimeEvidenceOnDisk: false, + manifestEntryCount: null, + manifestUpdatedAt: null, fileNames: [], }; } const fileNames = (await readdir(laneDir).catch(() => [] as string[])).sort(); + const manifestPath = getOpenCodeRuntimeManifestPath( + params.teamsBasePath, + params.teamName, + params.laneId + ); + const manifest = (await fileExists(manifestPath)) + ? await readRuntimeStoreManifestEvidenceData( + manifestPath, + params.teamName, + () => new Date() + ).catch(() => null) + : null; + const hasRuntimeEvidenceFile = fileNames.some((fileName) => + OPENCODE_RUNTIME_EVIDENCE_FILES.has(fileName) + ); + const hasRuntimeEvidenceManifestEntry = + manifest?.entries.some((entry) => OPENCODE_RUNTIME_EVIDENCE_FILES.has(entry.relativePath)) ?? + false; return { laneDirectoryExists: true, hasStateOnDisk: fileNames.length > 0, + hasRuntimeEvidenceOnDisk: hasRuntimeEvidenceFile || hasRuntimeEvidenceManifestEntry, + manifestEntryCount: manifest ? manifest.entries.length : null, + manifestUpdatedAt: manifest?.updatedAt ?? null, fileNames, }; } @@ -513,6 +549,8 @@ export async function recoverStaleOpenCodeRuntimeLaneIndexEntry(params: { teamsBasePath: string; teamName: string; laneId: string; + clock?: () => Date; + emptyLaneStaleAfterMs?: number; }): Promise<{ stale: boolean; degraded: boolean; @@ -529,7 +567,7 @@ export async function recoverStaleOpenCodeRuntimeLaneIndexEntry(params: { } const storage = await inspectOpenCodeRuntimeLaneStorage(params); - if (storage.hasStateOnDisk) { + if (storage.hasRuntimeEvidenceOnDisk) { return { stale: false, degraded: false, @@ -537,9 +575,26 @@ export async function recoverStaleOpenCodeRuntimeLaneIndexEntry(params: { }; } - const diagnostics = [ - `OpenCode lane ${params.laneId} is marked active in lanes.json, but no lane state exists on disk.`, - ]; + const now = params.clock?.() ?? new Date(); + const staleAfterMs = params.emptyLaneStaleAfterMs ?? OPENCODE_ACTIVE_EMPTY_LANE_STALE_MS; + const lastTouchedAt = + Date.parse(storage.manifestUpdatedAt ?? '') || Date.parse(entry.updatedAt) || NaN; + const laneAgeMs = Number.isFinite(lastTouchedAt) ? now.getTime() - lastTouchedAt : Infinity; + if (storage.hasStateOnDisk && laneAgeMs < staleAfterMs) { + return { + stale: false, + degraded: false, + diagnostics: [], + }; + } + + const diagnostics = storage.hasStateOnDisk + ? [ + `OpenCode lane ${params.laneId} is marked active in lanes.json, but its runtime manifest has no committed runtime evidence after launch grace.`, + ] + : [ + `OpenCode lane ${params.laneId} is marked active in lanes.json, but no lane state exists on disk.`, + ]; await upsertOpenCodeRuntimeLaneIndexEntry({ teamsBasePath: params.teamsBasePath, teamName: params.teamName, diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index d1d7f1ad..1df7928c 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -429,6 +429,7 @@ function mapOpenCodeLaunchDataToRuntimeResult( data: OpenCodeLaunchTeamCommandData, prepareWarnings: string[] ): TeamRuntimeLaunchResult { + const bridgeDiagnostics = data.diagnostics.map(formatOpenCodeBridgeDiagnostic); const checkpointNames = extractCheckpointNames(data); const readyCheckpointsPresent = [...REQUIRED_READY_CHECKPOINTS].every((name) => checkpointNames.has(name) @@ -491,6 +492,7 @@ function mapOpenCodeLaunchDataToRuntimeResult( ...(bridgeMember?.evidence ?? []).map( (evidence) => `${evidence.kind} at ${evidence.observedAt}` ), + ...bridgeDiagnostics, ...checkpointDiagnostic, ...(missingExpectedMembers.includes(member.name) ? incompleteReadyDiagnostic : []), ] @@ -518,11 +520,7 @@ function mapOpenCodeLaunchDataToRuntimeResult( : 'partial_failure', members, warnings: [...prepareWarnings, ...data.warnings.map((warning) => warning.message)], - diagnostics: [ - ...data.diagnostics.map(formatOpenCodeBridgeDiagnostic), - ...checkpointDiagnostic, - ...incompleteReadyDiagnostic, - ], + diagnostics: [...bridgeDiagnostics, ...checkpointDiagnostic, ...incompleteReadyDiagnostic], }; } @@ -539,29 +537,28 @@ function mapBridgeMemberToRuntimeEvidence( const failed = launchState === 'failed'; const hasRuntimePid = typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0; - const pendingRuntimeObserved = launchState === 'created' && hasRuntimePid; + const hasSessionId = typeof sessionId === 'string' && sessionId.trim().length > 0; + const hasRuntimeHandle = hasRuntimePid || hasSessionId; + const pendingRuntimeObserved = launchState === 'created' && hasRuntimeHandle; const livenessKind = confirmed ? 'confirmed_bootstrap' : pendingRuntimeObserved ? 'runtime_process_candidate' : launchState === 'permission_blocked' ? 'permission_blocked' - : runtimeMaterialized || sessionId - ? 'runtime_process_candidate' - : 'registered_only'; + : 'registered_only'; const runtimeDiagnostic = pendingRuntimeObserved - ? 'OpenCode runtime pid reported by bridge without local process verification' + ? hasRuntimePid + ? 'OpenCode runtime pid reported by bridge without local process verification' + : 'OpenCode session exists without verified runtime pid' : launchState === 'permission_blocked' ? 'OpenCode runtime is waiting for permission approval' - : runtimeMaterialized || sessionId - ? 'OpenCode session exists without verified runtime pid' + : runtimeMaterialized + ? 'OpenCode bridge did not report a runtime session or pid for this member' : undefined; const runtimeDiagnosticSeverity = failed ? 'error' - : pendingRuntimeObserved || - launchState === 'permission_blocked' || - runtimeMaterialized || - sessionId + : pendingRuntimeObserved || launchState === 'permission_blocked' || runtimeMaterialized ? 'warning' : undefined; return { @@ -578,8 +575,7 @@ function mapBridgeMemberToRuntimeEvidence( confirmed || pendingRuntimeObserved || launchState === 'permission_blocked' || - runtimeMaterialized || - Boolean(sessionId), + hasRuntimeHandle, runtimeAlive: confirmed, bootstrapConfirmed: confirmed, hardFailure: failed, diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index a72e5fb3..63480f94 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -376,6 +376,10 @@ export const CliLogsRichView = ({ const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]); const entries = useMemo(() => groupBySubagent(groups), [groups]); + const emptyMessage = + cliLogsTail.trim().length > 0 + ? 'No displayable assistant/runtime logs yet.' + : 'Waiting for response...'; // Derive expanded state: all groups expanded unless manually collapsed const expandedGroupIds = useMemo(() => { @@ -578,9 +582,7 @@ export const CliLogsRichView = ({ - - Waiting for response... - + {emptyMessage} {footer} diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index eed114d4..7e847467 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; +// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; @@ -293,11 +293,13 @@ export const MemberDetailDialog = ({
+ {/* + */}
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 5ecafbf4..04d70a7a 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1002,6 +1002,8 @@ export interface PersistedTeamLaunchMemberState { hardFailureReason?: string; pendingPermissionRequestIds?: string[]; runtimePid?: number; + /** OpenCode runtime run id that produced the current runtimeSessionId/liveness evidence. */ + runtimeRunId?: string; runtimeSessionId?: string; livenessKind?: TeamAgentRuntimeLivenessKind; pidSource?: TeamAgentRuntimePidSource; diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 04bd3892..ec9f7b99 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -7,6 +7,7 @@ import { MemberWorkSyncReconciler, MemberWorkSyncReporter, type MemberWorkSyncAgendaSourceResult, + type MemberWorkSyncAuditEvent, type MemberWorkSyncInboxNudgePort, type MemberWorkSyncOutboxStorePort, type MemberWorkSyncStatusStorePort, @@ -247,6 +248,7 @@ function createDeps(options?: { }) { const clock = new MutableClock(); const store = new InMemoryStatusStore(); + const auditEvents: MemberWorkSyncAuditEvent[] = []; const source: MemberWorkSyncAgendaSourceResult = { agenda: { teamName: 'team-a', @@ -286,13 +288,18 @@ function createDeps(options?: { lifecycle: { isTeamActive: () => options?.teamActive ?? true, }, + auditJournal: { + append: async (event) => { + auditEvents.push(event); + }, + }, }; - return { clock, deps, source, store }; + return { auditEvents, clock, deps, source, store }; } describe('MemberWorkSync use cases', () => { it('reconciles actionable work into needs_sync without side effects', async () => { - const { deps, store } = createDeps(); + const { auditEvents, deps, store } = createDeps(); const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ teamName: 'team-a', memberName: 'bob', @@ -308,10 +315,15 @@ describe('MemberWorkSync use cases', () => { fingerprintChanged: false, }); expect(store.pendingReports).toEqual([]); + expect(auditEvents.map((event) => event.event)).toEqual([ + 'reconcile_started', + 'agenda_loaded', + 'decision_made', + ]); }); it('accepts still_working as a bounded lease for the current fingerprint', async () => { - const { clock, deps } = createDeps(); + const { auditEvents, clock, deps } = createDeps(); const reader = new MemberWorkSyncDiagnosticsReader(deps); const reporter = new MemberWorkSyncReporter(deps); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); @@ -340,6 +352,7 @@ describe('MemberWorkSync use cases', () => { const expired = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); expect(expired.state).toBe('needs_sync'); expect(expired.diagnostics).toContain('report_lease_expired'); + expect(auditEvents.map((event) => event.event)).toContain('report_accepted'); }); it('uses app clock instead of model supplied reportedAt for lease timing', async () => { @@ -365,7 +378,7 @@ describe('MemberWorkSync use cases', () => { }); it('rejects stale reports without turning app-side validation failures into pending intents', async () => { - const { deps, store } = createDeps(); + const { auditEvents, deps, store } = createDeps(); const result = await new MemberWorkSyncReporter(deps).execute({ teamName: 'team-a', memberName: 'bob', @@ -384,6 +397,14 @@ describe('MemberWorkSync use cases', () => { }); expect(store.writes.at(-1)?.diagnostics).toContain('report_rejected:stale_fingerprint'); expect(store.pendingReports).toHaveLength(0); + expect(auditEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'report_rejected', + reason: 'stale_fingerprint', + }), + ]) + ); }); it('accepts caught_up only when the app-side agenda is empty', async () => { @@ -619,7 +640,7 @@ describe('MemberWorkSync use cases', () => { it('defers nudge dispatch while the member has active or recent tool activity', async () => { const outbox = new InMemoryOutboxStore(); const inbox = new InMemoryInboxNudge(); - const { deps, store } = createDeps({ + const { auditEvents, deps, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox, busySignal: { @@ -651,6 +672,14 @@ describe('MemberWorkSync use cases', () => { lastError: 'member_busy:active_tool_activity', nextAttemptAt: '2026-04-29T00:02:00.000Z', }); + expect(auditEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'member_busy', + reason: 'member_busy:active_tool_activity', + }), + ]) + ); }); it('uses bounded retry backoff when inbox delivery fails', async () => { diff --git a/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts b/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts new file mode 100644 index 00000000..3f1c0611 --- /dev/null +++ b/test/features/member-work-sync/main/FileMemberWorkSyncAuditJournal.test.ts @@ -0,0 +1,113 @@ +import { mkdtemp, readFile, readdir, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileMemberWorkSyncAuditJournal } from '@features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal'; +import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; + +function journalPath(root: string): string { + return join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'journal.jsonl'); +} + +describe('FileMemberWorkSyncAuditJournal', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'member-work-sync-audit-')); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('appends per-member JSONL audit events in order', async () => { + const journal = new FileMemberWorkSyncAuditJournal(new MemberWorkSyncStorePaths(root)); + + await journal.append({ + timestamp: '2026-04-30T00:00:00.000Z', + teamName: 'team-a', + memberName: 'bob', + event: 'reconcile_started', + source: 'test', + }); + await journal.append({ + timestamp: '2026-04-30T00:00:01.000Z', + teamName: 'team-a', + memberName: 'bob', + event: 'status_written', + source: 'test', + agendaFingerprint: 'agenda:v1:abc', + actionableCount: 1, + }); + + const lines = (await readFile(journalPath(root), 'utf8')).trim().split('\n'); + expect(lines.map((line) => JSON.parse(line).event)).toEqual([ + 'reconcile_started', + 'status_written', + ]); + expect(JSON.parse(lines[1])).toMatchObject({ + schemaVersion: 1, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + }); + }); + + it('truncates previews and rotates bounded journals', async () => { + const journal = new FileMemberWorkSyncAuditJournal( + new MemberWorkSyncStorePaths(root), + undefined, + { maxBytes: 200, rotatedFileCount: 2 } + ); + + for (let index = 0; index < 8; index += 1) { + await journal.append({ + timestamp: `2026-04-30T00:00:0${index}.000Z`, + teamName: 'team-a', + memberName: 'bob', + event: 'nudge_skipped', + source: 'test', + reason: 'r'.repeat(500), + diagnostics: ['d'.repeat(500)], + metadata: { long: 'm'.repeat(500), ['__proto__']: 'safe' }, + taskRefs: [{ taskId: 't'.repeat(500), displayId: 'x'.repeat(500) }], + messagePreview: 'x'.repeat(500), + }); + } + + const dirEntries = await readdir(join(root, 'team-a', 'members', 'bob', '.member-work-sync')); + expect(dirEntries).toEqual(expect.arrayContaining(['journal.jsonl', 'journal.jsonl.1'])); + expect(dirEntries).not.toContain('journal.jsonl.3'); + + const latestLine = (await readFile(journalPath(root), 'utf8')).trim().split('\n').at(-1); + const latest = JSON.parse(latestLine ?? '{}'); + expect(latest.messagePreview).toHaveLength(243); + expect(latest.reason).toHaveLength(243); + expect(latest.diagnostics[0]).toHaveLength(243); + expect(latest.metadata.long).toHaveLength(243); + expect(latest.metadata.__proto__).toBe('safe'); + expect(latest.taskRefs[0].taskId).toHaveLength(243); + }); + + it('logs and swallows append failures', async () => { + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const paths = new MemberWorkSyncStorePaths(root); + vi.spyOn(paths, 'ensureMemberWorkSyncDir').mockRejectedValue(new Error('boom')); + const journal = new FileMemberWorkSyncAuditJournal(paths, logger); + + await expect( + journal.append({ + timestamp: '2026-04-30T00:00:00.000Z', + teamName: 'team-a', + memberName: 'bob', + event: 'reconcile_started', + source: 'test', + }) + ).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + 'member work sync audit journal append failed', + expect.objectContaining({ error: 'Error: boom' }) + ); + }); +}); diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index 1f90fde5..7062c135 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -9,6 +9,7 @@ import type { MemberWorkSyncNudgePayload, MemberWorkSyncStatus, } from '@features/member-work-sync/contracts'; +import type { MemberWorkSyncAuditEvent } from '@features/member-work-sync/core/application'; function makeStatus(overrides: Partial): MemberWorkSyncStatus { return { @@ -58,6 +59,16 @@ function makeNudgePayload(overrides: Partial = {}): }; } +function memberWorkSyncDir(root: string, teamName: string, memberName: string): string { + return join( + root, + teamName, + 'members', + encodeURIComponent(memberName.trim().toLowerCase()), + '.member-work-sync' + ); +} + describe('JsonMemberWorkSyncStore', () => { let root: string; let store: JsonMemberWorkSyncStore; @@ -83,6 +94,56 @@ describe('JsonMemberWorkSyncStore', () => { expect(entries.some((entry) => entry.startsWith('status.json.invalid.'))).toBe(true); }); + it('writes status into member-scoped storage and keeps team metrics in an index', async () => { + await store.write(makeStatus({ providerId: 'opencode' })); + + const statusFile = JSON.parse( + await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'status.json'), 'utf8') + ); + expect(statusFile).toMatchObject({ + schemaVersion: 2, + status: { + teamName: 'team-a', + memberName: 'bob', + providerId: 'opencode', + }, + }); + + const metaFile = JSON.parse( + await readFile(join(root, 'team-a', 'members', 'bob', 'member.meta.json'), 'utf8') + ); + expect(metaFile).toMatchObject({ + schemaVersion: 1, + memberName: 'bob', + memberKey: 'bob', + }); + + const metricsIndex = JSON.parse( + await readFile(join(root, 'team-a', '.member-work-sync', 'indexes', 'metrics.json'), 'utf8') + ); + expect(metricsIndex.members.bob).toMatchObject({ + memberName: 'bob', + state: 'needs_sync', + actionableCount: 1, + }); + }); + + it('prefers member-scoped v2 status over legacy v1 status', async () => { + await store.write(makeStatus({ state: 'caught_up', agenda: { ...makeStatus({}).agenda, items: [] } })); + + const legacyStatusPath = join(root, 'team-a', '.member-work-sync', 'status.json'); + await mkdir(join(root, 'team-a', '.member-work-sync'), { recursive: true }); + await writeFile( + legacyStatusPath, + JSON.stringify({ schemaVersion: 1, members: { bob: makeStatus({ state: 'needs_sync' }) } }), + 'utf8' + ); + + await expect(store.read({ teamName: 'team-a', memberName: 'bob' })).resolves.toMatchObject({ + state: 'caught_up', + }); + }); + it('deduplicates pending report intents and marks them processed', async () => { const request = { teamName: 'team-a', @@ -114,12 +175,129 @@ describe('JsonMemberWorkSyncStore', () => { expect(await store.listPendingReports('team-a')).toEqual([]); const file = JSON.parse( - await readFile(join(root, 'team-a', '.member-work-sync', 'pending-reports.json'), 'utf8') + await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'reports.json'), 'utf8') ); expect(file.intents[pending[0].id]).toMatchObject({ status: 'accepted', resultCode: 'accepted', }); + const index = JSON.parse( + await readFile( + join(root, 'team-a', '.member-work-sync', 'indexes', 'pending-reports-index.json'), + 'utf8' + ) + ); + expect(index.items[pending[0].id]).toMatchObject({ + memberName: 'bob', + status: 'accepted', + }); + }); + + it('repairs a missing pending-report index from member-scoped report files', async () => { + const request = { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working' as const, + agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test', + source: 'mcp' as const, + }; + + await store.appendPendingReport(request, 'control_api_unavailable'); + await rm(join(root, 'team-a', '.member-work-sync', 'indexes', 'pending-reports-index.json'), { + force: true, + }); + + await expect(store.listPendingReports('team-a')).resolves.toHaveLength(1); + const repaired = JSON.parse( + await readFile( + join(root, 'team-a', '.member-work-sync', 'indexes', 'pending-reports-index.json'), + 'utf8' + ) + ); + expect(Object.values(repaired.items)).toEqual([ + expect.objectContaining({ memberName: 'bob', status: 'pending' }), + ]); + }); + + it('repairs a stale pending-report index route from member-scoped report files', async () => { + const bobRequest = { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working' as const, + agendaFingerprint: 'agenda:v1:bob', + reportToken: 'wrs:v1.bob', + source: 'mcp' as const, + }; + const tomRequest = { + ...bobRequest, + memberName: 'tom', + agendaFingerprint: 'agenda:v1:tom', + reportToken: 'wrs:v1.tom', + }; + + await store.appendPendingReport(bobRequest, 'control_api_unavailable'); + await store.appendPendingReport(tomRequest, 'control_api_unavailable'); + await writeFile( + join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'reports.json'), + JSON.stringify({ schemaVersion: 2, intents: {} }), + 'utf8' + ); + + const pending = await store.listPendingReports('team-a'); + expect(pending.map((intent) => intent.memberName)).toEqual(['tom']); + const repaired = JSON.parse( + await readFile( + join(root, 'team-a', '.member-work-sync', 'indexes', 'pending-reports-index.json'), + 'utf8' + ) + ); + expect(Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)).toEqual([ + 'tom', + ]); + }); + + it('repairs a partially missing pending-report index route from member-scoped report files', async () => { + const bobRequest = { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working' as const, + agendaFingerprint: 'agenda:v1:bob', + reportToken: 'wrs:v1.bob', + source: 'mcp' as const, + }; + const tomRequest = { + ...bobRequest, + memberName: 'tom', + agendaFingerprint: 'agenda:v1:tom', + reportToken: 'wrs:v1.tom', + }; + + await store.appendPendingReport(bobRequest, 'control_api_unavailable'); + await store.appendPendingReport(tomRequest, 'control_api_unavailable'); + const indexPath = join( + root, + 'team-a', + '.member-work-sync', + 'indexes', + 'pending-reports-index.json' + ); + const index = JSON.parse(await readFile(indexPath, 'utf8')); + for (const [id, route] of Object.entries(index.items)) { + if ((route as { memberName: string }).memberName === 'tom') { + delete index.items[id]; + } + } + await writeFile(indexPath, JSON.stringify(index), 'utf8'); + + const pending = await store.listPendingReports('team-a'); + expect(pending.map((intent) => intent.memberName).sort()).toEqual(['bob', 'tom']); + const repaired = JSON.parse(await readFile(indexPath, 'utf8')); + expect( + Object.values(repaired.items) + .map((item) => (item as { memberName: string }).memberName) + .sort() + ).toEqual(['bob', 'tom']); }); it('records bounded shadow metrics from status writes', async () => { @@ -263,7 +441,7 @@ describe('JsonMemberWorkSyncStore', () => { teamName: 'team-a', claimedBy: 'dispatcher-a', nowIso: '2026-04-29T00:01:00.000Z', - limit: 5, + limit: 1, }); expect(claimed).toHaveLength(1); expect(claimed[0]).toMatchObject({ @@ -305,12 +483,287 @@ describe('JsonMemberWorkSyncStore', () => { }); const file = JSON.parse( - await readFile(join(root, 'team-a', '.member-work-sync', 'outbox.json'), 'utf8') + await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8') ); expect(file.items[input.id]).toMatchObject({ status: 'delivered', deliveredMessageId: 'message-1', attemptGeneration: 2, }); + const index = JSON.parse( + await readFile(join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), 'utf8') + ); + expect(index.items[input.id]).toMatchObject({ + memberName: 'bob', + status: 'delivered', + }); + }); + + it('claims due outbox items from the index without scanning unrelated member outboxes', async () => { + const bobInput = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + await store.ensurePending(bobInput); + + await mkdir(join(root, 'team-a', 'members', 'tom', '.member-work-sync'), { recursive: true }); + await writeFile( + join(root, 'team-a', 'members', 'tom', 'member.meta.json'), + JSON.stringify({ + schemaVersion: 1, + memberName: 'tom', + memberKey: 'tom', + updatedAt: '2026-04-29T00:00:00.000Z', + }), + 'utf8' + ); + await writeFile( + join(root, 'team-a', 'members', 'tom', '.member-work-sync', 'outbox.json'), + JSON.stringify({ + schemaVersion: 2, + items: { + 'member-work-sync:team-a:tom:agenda:v1:other': { + ...bobInput, + id: 'member-work-sync:team-a:tom:agenda:v1:other', + memberName: 'tom', + status: 'pending', + attemptGeneration: 0, + createdAt: '2026-04-29T00:00:00.000Z', + updatedAt: '2026-04-29T00:00:00.000Z', + }, + }, + }), + 'utf8' + ); + + const claimed = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 1, + }); + expect(claimed.map((item) => item.memberName)).toEqual(['bob']); + }); + + it('repairs a missing outbox index from member-scoped outbox files for delivered counts', async () => { + const input = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + await store.ensurePending(input); + const [claimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 1, + }); + await store.markDelivered({ + teamName: 'team-a', + id: input.id, + attemptGeneration: claimed.attemptGeneration, + deliveredMessageId: 'message-1', + nowIso: '2026-04-29T00:02:00.000Z', + }); + await rm(join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), { + force: true, + }); + + await expect( + store.countRecentDelivered({ + teamName: 'team-a', + memberName: 'bob', + sinceIso: '2026-04-29T00:00:00.000Z', + }) + ).resolves.toBe(1); + }); + + it('counts delivered nudges from the member outbox when the outbox index is partially stale', async () => { + const bobInput = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + const tomInput = { + ...bobInput, + id: 'member-work-sync:team-a:tom:agenda:v1:def', + memberName: 'tom', + payload: makeNudgePayload({ to: 'tom' }), + }; + await store.ensurePending(bobInput); + await store.ensurePending(tomInput); + const [claimedBob] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 1, + }); + await store.markDelivered({ + teamName: 'team-a', + id: bobInput.id, + attemptGeneration: claimedBob.attemptGeneration, + deliveredMessageId: 'message-1', + nowIso: '2026-04-29T00:02:00.000Z', + }); + + const indexPath = join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'); + const index = JSON.parse(await readFile(indexPath, 'utf8')); + delete index.items[bobInput.id]; + await writeFile(indexPath, JSON.stringify(index), 'utf8'); + + await expect( + store.countRecentDelivered({ + teamName: 'team-a', + memberName: 'bob', + sinceIso: '2026-04-29T00:00:00.000Z', + }) + ).resolves.toBe(1); + const repaired = JSON.parse(await readFile(indexPath, 'utf8')); + expect(repaired.items[bobInput.id]).toMatchObject({ memberName: 'bob', status: 'delivered' }); + }); + + it('repairs stale due outbox index routes before persisting claim results', async () => { + const bobInput = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + const tomInput = { + ...bobInput, + id: 'member-work-sync:team-a:tom:agenda:v1:def', + memberName: 'tom', + payload: makeNudgePayload({ to: 'tom' }), + }; + await store.ensurePending(bobInput); + await store.ensurePending(tomInput); + await writeFile( + join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'outbox.json'), + JSON.stringify({ schemaVersion: 2, items: {} }), + 'utf8' + ); + + const claimed = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 5, + }); + expect(claimed.map((item) => item.memberName)).toEqual(['tom']); + const repaired = JSON.parse( + await readFile(join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), 'utf8') + ); + expect(Object.values(repaired.items).map((item) => (item as { memberName: string }).memberName)).toEqual([ + 'tom', + ]); + }); + + it('repairs partially missing due outbox index routes before claiming', async () => { + const bobInput = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + const tomInput = { + ...bobInput, + id: 'member-work-sync:team-a:tom:agenda:v1:def', + memberName: 'tom', + payload: makeNudgePayload({ to: 'tom' }), + }; + await store.ensurePending(bobInput); + await store.ensurePending(tomInput); + const indexPath = join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'); + const index = JSON.parse(await readFile(indexPath, 'utf8')); + delete index.items[tomInput.id]; + await writeFile(indexPath, JSON.stringify(index), 'utf8'); + + const claimed = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 5, + }); + expect(claimed.map((item) => item.memberName).sort()).toEqual(['bob', 'tom']); + }); + + it('falls back to legacy v1 status and materializes legacy outbox during claim', async () => { + const auditEvents: MemberWorkSyncAuditEvent[] = []; + store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(root), { + auditJournal: { + append: async (event) => { + auditEvents.push(event); + }, + }, + now: () => new Date('2026-04-29T00:02:00.000Z'), + }); + const legacyStatusPath = join(root, 'team-a', '.member-work-sync', 'status.json'); + await mkdir(join(root, 'team-a', '.member-work-sync'), { recursive: true }); + await writeFile( + legacyStatusPath, + JSON.stringify({ schemaVersion: 1, members: { bob: makeStatus({}) } }), + 'utf8' + ); + + await expect(store.read({ teamName: 'team-a', memberName: 'bob' })).resolves.toMatchObject({ + memberName: 'bob', + state: 'needs_sync', + }); + + const input = { + id: 'member-work-sync:team-a:bob:agenda:v1:legacy', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:legacy', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + status: 'pending' as const, + attemptGeneration: 0, + createdAt: '2026-04-29T00:00:00.000Z', + updatedAt: '2026-04-29T00:00:00.000Z', + }; + await writeFile( + join(root, 'team-a', '.member-work-sync', 'outbox.json'), + JSON.stringify({ schemaVersion: 1, items: { [input.id]: input } }), + 'utf8' + ); + + const claimed = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 1, + }); + expect(claimed).toHaveLength(1); + expect( + JSON.parse(await readFile(join(memberWorkSyncDir(root, 'team-a', 'bob'), 'outbox.json'), 'utf8')) + .items[input.id] + ).toMatchObject({ status: 'claimed' }); + expect(auditEvents.map((event) => `${event.event}:${event.reason}`)).toEqual( + expect.arrayContaining([ + 'legacy_fallback_used:status_v1', + 'index_repaired:outbox', + 'legacy_fallback_used:outbox_v1', + ]) + ); }); }); diff --git a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts index dce25bb0..e0e8f66b 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncEventQueue.test.ts @@ -13,12 +13,18 @@ describe('MemberWorkSyncEventQueue', () => { it('coalesces duplicate member events into one queue reconcile', async () => { const reconciles: unknown[] = []; + const auditEvents: string[] = []; const queue = new MemberWorkSyncEventQueue({ quietWindowMs: 100, reconcile: async (request, context) => { reconciles.push({ request, context }); }, isTeamActive: () => true, + auditJournal: { + append: async (event) => { + auditEvents.push(event.event); + }, + }, }); queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' }); @@ -35,6 +41,7 @@ describe('MemberWorkSyncEventQueue', () => { }, }); expect(queue.getDiagnostics()).toMatchObject({ reconciled: 1, coalesced: 1 }); + expect(auditEvents).toEqual(['queue_enqueued', 'queue_coalesced', 'queue_reconciled']); await queue.stop(); }); diff --git a/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts b/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts index d536ba8d..53017f16 100644 --- a/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts +++ b/test/features/member-work-sync/main/RuntimeTurnSettledIngestor.test.ts @@ -6,6 +6,7 @@ import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/ import { OpenCodeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer'; import type { + MemberWorkSyncAuditEvent, RuntimeTurnSettledClaimedPayload, RuntimeTurnSettledEventStorePort, RuntimeTurnSettledInvalidResult, @@ -56,6 +57,7 @@ describe('RuntimeTurnSettledIngestor', () => { resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'alice' })), }; const enqueueRuntimeTurnSettled = vi.fn(); + const auditEvents: MemberWorkSyncAuditEvent[] = []; const ingestor = new RuntimeTurnSettledIngestor({ eventStore: store, @@ -63,6 +65,11 @@ describe('RuntimeTurnSettledIngestor', () => { targetResolver: resolver, reconcileQueue: { enqueueRuntimeTurnSettled }, clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, + auditJournal: { + append: async (event) => { + auditEvents.push(event); + }, + }, }); await expect(ingestor.drainPending()).resolves.toEqual({ @@ -185,6 +192,7 @@ describe('RuntimeTurnSettledIngestor', () => { resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'jack' })), }; const enqueueRuntimeTurnSettled = vi.fn(); + const auditEvents: MemberWorkSyncAuditEvent[] = []; const ingestor = new RuntimeTurnSettledIngestor({ eventStore: store, @@ -192,6 +200,11 @@ describe('RuntimeTurnSettledIngestor', () => { targetResolver: resolver, reconcileQueue: { enqueueRuntimeTurnSettled }, clock: { now: () => new Date('2026-04-29T12:01:00.000Z') }, + auditJournal: { + append: async (event) => { + auditEvents.push(event); + }, + }, }); await expect(ingestor.drainPending()).resolves.toMatchObject({ @@ -220,6 +233,10 @@ describe('RuntimeTurnSettledIngestor', () => { teamName: 'team-a', memberName: 'jack', }); + expect(auditEvents.map((event) => event.event)).toEqual([ + 'turn_settled_claimed', + 'turn_settled_resolved', + ]); }); it.each([ diff --git a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts index 945c9da0..c8473d60 100644 --- a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts +++ b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts @@ -264,6 +264,9 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => { ).resolves.toEqual({ laneDirectoryExists: false, hasStateOnDisk: false, + hasRuntimeEvidenceOnDisk: false, + manifestEntryCount: null, + manifestUpdatedAt: null, fileNames: [], }); @@ -293,6 +296,117 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => { }); }); + it('degrades an active lane that only has a stale empty runtime manifest', async () => { + const teamName = 'team-empty-manifest'; + const laneId = 'secondary:opencode:bob'; + + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempDir, + teamName, + laneId, + state: 'active', + }); + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: tempDir, + teamName, + laneId, + runId: 'run-empty', + clock: () => new Date('2026-04-22T09:55:00.000Z'), + }); + await fs.writeFile( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempDir, + teamName, + laneId, + fileName: 'opencode-prompt-delivery-ledger.json', + }), + JSON.stringify({ records: [] }), + 'utf8' + ); + + await expect( + inspectOpenCodeRuntimeLaneStorage({ + teamsBasePath: tempDir, + teamName, + laneId, + }) + ).resolves.toMatchObject({ + laneDirectoryExists: true, + hasStateOnDisk: true, + hasRuntimeEvidenceOnDisk: false, + manifestEntryCount: 0, + fileNames: ['manifest.json', 'opencode-prompt-delivery-ledger.json'], + }); + + const result = await recoverStaleOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempDir, + teamName, + laneId, + clock: () => now, + emptyLaneStaleAfterMs: 150_000, + }); + + expect(result).toEqual({ + stale: true, + degraded: true, + diagnostics: [ + `OpenCode lane ${laneId} is marked active in lanes.json, but its runtime manifest has no committed runtime evidence after launch grace.`, + ], + }); + await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({ + lanes: { + [laneId]: { + laneId, + state: 'degraded', + diagnostics: [ + `OpenCode lane ${laneId} is marked active in lanes.json, but its runtime manifest has no committed runtime evidence after launch grace.`, + ], + }, + }, + }); + }); + + it('does not degrade a fresh active lane while the empty runtime manifest is still inside launch grace', async () => { + const teamName = 'team-fresh-empty-manifest'; + const laneId = 'secondary:opencode:bob'; + + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempDir, + teamName, + laneId, + state: 'active', + }); + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: tempDir, + teamName, + laneId, + runId: 'run-fresh', + clock: () => new Date('2026-04-22T09:59:00.000Z'), + }); + + const result = await recoverStaleOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempDir, + teamName, + laneId, + clock: () => now, + emptyLaneStaleAfterMs: 150_000, + }); + + expect(result).toEqual({ + stale: false, + degraded: false, + diagnostics: [], + }); + await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({ + lanes: { + [laneId]: { + laneId, + state: 'active', + }, + }, + }); + }); + it('quarantines malformed lanes.json and falls back to an empty index', async () => { const teamName = 'team-zeta'; const runtimeDir = getOpenCodeTeamRuntimeDirectory(tempDir, teamName); diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 924502ac..47bbc2d9 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -620,7 +620,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); - it('treats materialized bridge members without session or pid as accepted but not alive', async () => { + it('does not treat bridge members without session or pid as runtime candidates', async () => { const launchOpenCodeTeam = vi.fn( async () => ({ @@ -648,10 +648,10 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(result.members.alice).toMatchObject({ launchState: 'runtime_pending_bootstrap', - agentToolAccepted: true, + agentToolAccepted: false, runtimeAlive: false, - livenessKind: 'runtime_process_candidate', - runtimeDiagnostic: 'OpenCode session exists without verified runtime pid', + livenessKind: 'registered_only', + runtimeDiagnostic: 'OpenCode bridge did not report a runtime session or pid for this member', }); }); diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 2ddce750..91eeac67 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -780,7 +780,7 @@ describe('Team agent launch matrix safe e2e', () => { const second = await secondPromise; expect(second.runId).toBeTruthy(); expect(second.runId).not.toBe(firstRunId); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); expect(svc.isTeamAlive(teamName)).toBe(true); expect(svc.getAliveTeams()).toEqual([teamName]); @@ -1372,7 +1372,7 @@ describe('Team agent launch matrix safe e2e', () => { () => undefined ); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); const cancelledRunId = adapter.pendingLaunchInputs.find( (input) => input.teamName === cancelledTeamName )?.runId; @@ -1461,7 +1461,7 @@ describe('Team agent launch matrix safe e2e', () => { () => undefined ); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); const cancelledRunId = adapter.pendingLaunchInputs.find( (input) => input.teamName === cancelledTeamName )?.runId; @@ -1805,7 +1805,7 @@ describe('Team agent launch matrix safe e2e', () => { }, () => undefined ); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); const firstRunId = adapter.pendingLaunchInputs.find( (input) => input.teamName === firstTeamName )?.runId; @@ -1817,7 +1817,7 @@ describe('Team agent launch matrix safe e2e', () => { svc.stopAllTeams(); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.teamName).sort()).toEqual([ firstTeamName, secondTeamName, @@ -1867,7 +1867,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(svc.getAliveTeams()).toEqual([]); adapter.releaseStops(); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); await expect( readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), firstTeamName) ).resolves.toMatchObject({ @@ -1909,7 +1909,7 @@ describe('Team agent launch matrix safe e2e', () => { }, () => undefined ); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); const firstRunId = adapter.pendingLaunchInputs.find( (input) => input.teamName === firstTeamName )?.runId; @@ -1921,7 +1921,7 @@ describe('Team agent launch matrix safe e2e', () => { svc.stopAllTeams(); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.teamName).sort()).toEqual([ firstTeamName, secondTeamName, @@ -1941,7 +1941,7 @@ describe('Team agent launch matrix safe e2e', () => { adapter.releaseLaunches(); await expect(firstPromise).resolves.toEqual({ runId: firstRunId }); await expect(secondPromise).resolves.toEqual({ runId: secondRunId }); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); expect(svc.getAliveTeams()).toEqual([]); await expect( @@ -2070,19 +2070,18 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopAllTeams(); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); expect(svc.isTeamAlive(teamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 2); + await waitForCondition(() => adapter.rejectedLaunchCount === 1); await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( { @@ -2113,19 +2112,18 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopAllTeams(); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); expect(svc.isTeamAlive(teamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( { lanes: {}, @@ -2139,7 +2137,7 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, freshRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(freshRun); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => freshRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -2200,27 +2198,23 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(firstRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(secondRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 4); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopAllTeams(); - await waitForCondition(() => adapter.stopInputs.length === 4); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.teamName).sort()).toEqual([ firstTeamName, - firstTeamName, - secondTeamName, secondTeamName, ]); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', 'secondary:opencode:bob', - 'secondary:opencode:tom', - 'secondary:opencode:tom', ]); expect(svc.getAliveTeams()).toEqual([]); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 4); + await waitForCondition(() => adapter.rejectedLaunchCount === 1); await expect( readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), firstTeamName) @@ -5619,14 +5613,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('clean_success'); + expect(statuses.teamLaunchState).toBe('partial_pending'); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', - launchState: 'confirmed_alive', - bootstrapConfirmed: true, + status: 'spawning', + launchState: 'starting', + bootstrapConfirmed: false, hardFailure: false, - livenessSource: 'heartbeat', }); + expect(statuses.statuses.bob?.livenessSource).not.toBe('heartbeat'); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -6387,14 +6381,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await new TeamProvisioningService().getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('clean_success'); + expect(statuses.teamLaunchState).toBe('partial_pending'); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', - launchState: 'confirmed_alive', - bootstrapConfirmed: true, + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + bootstrapConfirmed: false, hardFailure: false, - lastHeartbeatAt: newerSignalAt, }); + expect(statuses.statuses.bob?.lastHeartbeatAt).not.toBe(newerSignalAt); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -6672,14 +6666,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await new TeamProvisioningService().getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('clean_success'); + expect(statuses.teamLaunchState).toBe('partial_pending'); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', - launchState: 'confirmed_alive', - bootstrapConfirmed: true, + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + bootstrapConfirmed: false, hardFailure: false, - lastHeartbeatAt: signalAt, }); + expect(statuses.statuses.bob?.lastHeartbeatAt).not.toBe(signalAt); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -10076,7 +10070,7 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 4); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(cancelledRun.runId); @@ -10086,7 +10080,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.stopInputs.some((input) => input.teamName === survivingTeamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => survivingRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -10103,7 +10097,7 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, freshRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(freshRun); - await waitForCondition(() => adapter.launchInputs.length === 6); + await waitForCondition(() => adapter.launchInputs.length === 5); await waitForCondition(() => freshRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -10201,19 +10195,19 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 4); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); await svc.cancelProvisioning(cancelledRun.runId); await waitForCondition( - () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 2 + () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 1 ); expect(adapter.stopInputs.some((input) => input.teamName === survivingTeamName)).toBe(false); expect(svc.isTeamAlive(cancelledTeamName)).toBe(false); expect(svc.isTeamAlive(survivingTeamName)).toBe(true); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => survivingRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -11004,7 +10998,7 @@ describe('Team agent launch matrix safe e2e', () => { await waitForCondition(() => adapter.launchInputs.length === 2); svc.stopTeam(teamName); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); await waitForCondition(() => !svc.isTeamAlive(teamName)); expect((await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).lanes).toEqual({}); @@ -12777,18 +12771,17 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, currentRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(currentRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expectDirectChildKillCount(staleKillCount, 0); expectDirectChildKillCount(currentKillCount, 1); expect(staleRun.cancelRequested).toBe(false); expect(currentRun.cancelRequested).toBe(true); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); expect(await svc.getRuntimeState(teamName)).toMatchObject({ teamName, @@ -12835,7 +12828,7 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, currentRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(currentRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(staleRun.runId); @@ -14691,7 +14684,7 @@ describe('Team agent launch matrix safe e2e', () => { const initialSnapshot = await (svc as any).launchMixedSecondaryLaneIfNeeded(run); expect(initialSnapshot.teamLaunchState).toBe('partial_pending'); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); const inFlightStatuses = await svc.getMemberSpawnStatuses(teamName); expect(inFlightStatuses.teamLaunchState).toBe('partial_pending'); @@ -14747,7 +14740,7 @@ describe('Team agent launch matrix safe e2e', () => { const initialSnapshot = await (svc as any).launchMixedSecondaryLaneIfNeeded(run); expect(initialSnapshot.teamLaunchState).toBe('partial_pending'); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); const inFlightStatuses = await svc.getMemberSpawnStatuses(teamName); expect(inFlightStatuses.teamLaunchState).toBe('partial_pending'); @@ -14806,14 +14799,14 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); const firstLaneRunIds = run.mixedSecondaryLanes.map( (lane: { runId: string | null }) => lane.runId ); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - expect(adapter.pendingLaunchInputs).toHaveLength(2); + expect(adapter.pendingLaunchInputs).toHaveLength(1); expect(adapter.launchInputs).toHaveLength(0); expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ 'launching', @@ -14857,14 +14850,14 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); const firstLaneRunIds = run.mixedSecondaryLanes.map( (lane: { runId: string | null }) => lane.runId ); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - expect(adapter.pendingLaunchInputs).toHaveLength(2); + expect(adapter.pendingLaunchInputs).toHaveLength(1); expect(adapter.launchInputs).toHaveLength(0); expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ 'launching', @@ -15011,19 +15004,18 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(svc.isTeamAlive(teamName)).toBe(false); @@ -15045,19 +15037,18 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(svc.isTeamAlive(teamName)).toBe(false); @@ -15124,19 +15115,18 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(svc.isTeamAlive(teamName)).toBe(false); @@ -15175,19 +15165,19 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(stoppedRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 4); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); svc.stopTeam(stoppedTeamName); await waitForCondition( - () => adapter.stopInputs.filter((input) => input.teamName === stoppedTeamName).length === 2 + () => adapter.stopInputs.filter((input) => input.teamName === stoppedTeamName).length === 1 ); expect(adapter.stopInputs.some((input) => input.teamName === survivingTeamName)).toBe(false); expect(svc.isTeamAlive(stoppedTeamName)).toBe(false); expect(svc.isTeamAlive(survivingTeamName)).toBe(true); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => survivingRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -15223,11 +15213,11 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, oldRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(oldRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); await writeMixedTeamLaunchState({ teamName, @@ -15275,7 +15265,7 @@ describe('Team agent launch matrix safe e2e', () => { }); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.teamLaunchState).toBe('partial_failure'); @@ -15307,11 +15297,11 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, oldRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(oldRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); await writeMixedTeamLaunchState({ teamName, @@ -15358,7 +15348,7 @@ describe('Team agent launch matrix safe e2e', () => { }); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.teamLaunchState).toBe('partial_failure'); @@ -15428,11 +15418,11 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, oldRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(oldRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); await writeMixedTeamLaunchState({ teamName, @@ -15526,14 +15516,14 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 2); + await waitForCondition(() => adapter.rejectedLaunchCount === 1); await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( { @@ -15564,14 +15554,14 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 2); + await waitForCondition(() => adapter.rejectedLaunchCount === 1); await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( { @@ -15642,14 +15632,14 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(teamName); await waitForCondition(() => !svc.isTeamAlive(teamName)); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 2); + await waitForCondition(() => adapter.rejectedLaunchCount === 1); await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( { @@ -15694,7 +15684,6 @@ describe('Team agent launch matrix safe e2e', () => { await waitForCondition(() => adapter.stopInputs.length === 2); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); expect(svc.isTeamAlive(teamName)).toBe(false); @@ -15727,12 +15716,11 @@ describe('Team agent launch matrix safe e2e', () => { await waitForCondition(() => adapter.stopInputs.length === 2); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); expect(svc.isTeamAlive(teamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.teamLaunchState).not.toBe('clean_success'); @@ -15767,19 +15755,18 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(run.runId); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); expect(svc.isTeamAlive(teamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.teamLaunchState).not.toBe('clean_success'); @@ -15825,15 +15812,14 @@ describe('Team agent launch matrix safe e2e', () => { await svc.cancelProvisioning(cancelledRun.runId); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', - 'secondary:opencode:tom', ]); expect(svc.isTeamAlive(teamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const cancelledStatuses = await svc.getMemberSpawnStatuses(teamName); expect(cancelledStatuses.teamLaunchState).not.toBe('clean_success'); @@ -15849,7 +15835,7 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, freshRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(freshRun); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => freshRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -15902,19 +15888,19 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 4); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); await svc.cancelProvisioning(cancelledRun.runId); await waitForCondition( - () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 2 + () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 1 ); expect(adapter.stopInputs.some((input) => input.teamName === survivingTeamName)).toBe(false); expect(svc.isTeamAlive(cancelledTeamName)).toBe(false); expect(svc.isTeamAlive(survivingTeamName)).toBe(true); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => survivingRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -15987,19 +15973,19 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 4); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); await svc.cancelProvisioning(cancelledRun.runId); await waitForCondition( - () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 2 + () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 1 ); expect(adapter.stopInputs.some((input) => input.teamName === survivingTeamName)).toBe(false); expect(svc.isTeamAlive(cancelledTeamName)).toBe(false); expect(svc.isTeamAlive(survivingTeamName)).toBe(true); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => survivingRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -16051,17 +16037,17 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 4); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); await svc.cancelProvisioning(cancelledRun.runId); await waitForCondition( - () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 2 + () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 1 ); expect(adapter.stopInputs.some((input) => input.teamName === survivingTeamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => survivingRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -16073,7 +16059,7 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, freshRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(freshRun); - await waitForCondition(() => adapter.launchInputs.length === 6); + await waitForCondition(() => adapter.launchInputs.length === 5); await waitForCondition(() => freshRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -16154,17 +16140,17 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 4); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); await svc.cancelProvisioning(cancelledRun.runId); await waitForCondition( - () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 2 + () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 1 ); expect(adapter.stopInputs.some((input) => input.teamName === survivingTeamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 4); + await waitForCondition(() => adapter.launchInputs.length === 3); await waitForCondition(() => survivingRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); @@ -16181,7 +16167,7 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, freshRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(freshRun); - await waitForCondition(() => adapter.launchInputs.length === 6); + await waitForCondition(() => adapter.launchInputs.length === 5); await waitForCondition(() => freshRun.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') ); diff --git a/test/main/services/team/TeamBackupService.test.ts b/test/main/services/team/TeamBackupService.test.ts index b67d7a3d..e22e7d62 100644 --- a/test/main/services/team/TeamBackupService.test.ts +++ b/test/main/services/team/TeamBackupService.test.ts @@ -315,4 +315,84 @@ describe('TeamBackupService', () => { warnSpy.mockRestore(); } }); + + it('backs up member-scoped work sync files', async () => { + const service = new TeamBackupService(); + const teamName = 'member-work-sync-team'; + const teamDir = path.join(hoisted.teamsBase, teamName); + const memberDir = path.join(teamDir, 'members', 'jack'); + const workSyncDir = path.join(memberDir, '.member-work-sync'); + const status = { + teamName, + memberName: 'jack', + state: 'caught_up', + evaluatedAt: '2026-04-30T12:00:00.000Z', + agenda: { fingerprint: 'abc123', items: [] }, + }; + + try { + await fs.mkdir(workSyncDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ name: 'Member Work Sync Team' }), + 'utf8' + ); + await fs.writeFile( + path.join(memberDir, 'member.meta.json'), + JSON.stringify({ + schemaVersion: 1, + memberName: 'jack', + memberKey: 'jack', + updatedAt: '2026-04-30T12:00:00.000Z', + }), + 'utf8' + ); + await fs.writeFile( + path.join(workSyncDir, 'status.json'), + JSON.stringify({ schemaVersion: 2, status }), + 'utf8' + ); + await fs.writeFile( + path.join(workSyncDir, 'journal.jsonl'), + `${JSON.stringify({ + schemaVersion: 1, + timestamp: '2026-04-30T12:00:00.000Z', + teamName, + memberName: 'jack', + event: 'status_written', + source: 'test', + })}\n`, + 'utf8' + ); + await fs.writeFile(path.join(workSyncDir, '.tmp.deadbeef'), '{"partial":', 'utf8'); + await fs.writeFile(path.join(workSyncDir, 'journal.jsonl.lock'), '123\n', 'utf8'); + + await service.initialize(); + await service.backupTeam(teamName); + + const backupMemberDir = path.join(hoisted.backupsBase, 'teams', teamName, 'members', 'jack'); + await expect(fs.readFile(path.join(backupMemberDir, 'member.meta.json'), 'utf8')).resolves.toBe( + JSON.stringify({ + schemaVersion: 1, + memberName: 'jack', + memberKey: 'jack', + updatedAt: '2026-04-30T12:00:00.000Z', + }) + ); + await expect( + fs.readFile(path.join(backupMemberDir, '.member-work-sync', 'status.json'), 'utf8') + ).resolves.toBe(JSON.stringify({ schemaVersion: 2, status })); + await expect( + fs.readFile(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl'), 'utf8') + ).resolves.toContain('"event":"status_written"'); + await expect( + fs.stat(path.join(backupMemberDir, '.member-work-sync', '.tmp.deadbeef')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.stat(path.join(backupMemberDir, '.member-work-sync', 'journal.jsonl.lock')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + service.dispose(); + } + }); }); diff --git a/test/main/services/team/TeamMemberStoragePaths.test.ts b/test/main/services/team/TeamMemberStoragePaths.test.ts new file mode 100644 index 00000000..501ee28f --- /dev/null +++ b/test/main/services/team/TeamMemberStoragePaths.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + encodeTeamMemberStorageKey, + TeamMemberStoragePaths, +} from '@main/services/team/TeamMemberStoragePaths'; + +describe('TeamMemberStoragePaths', () => { + let root: string; + let paths: TeamMemberStoragePaths; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'team-member-storage-paths-')); + paths = new TeamMemberStoragePaths(root); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('builds stable path-safe keys from canonical member names', () => { + expect(encodeTeamMemberStorageKey(' Bob ')).toBe('bob'); + expect(encodeTeamMemberStorageKey('Jack Smith')).toBe('jack%20smith'); + expect(encodeTeamMemberStorageKey('../Alice')).toBe('..%2Falice'); + expect(encodeTeamMemberStorageKey('.')).toBe('%2E'); + expect(encodeTeamMemberStorageKey('..')).toBe('%2E%2E'); + expect(encodeTeamMemberStorageKey('Том')).toBe('%D1%82%D0%BE%D0%BC'); + }); + + it('keeps member storage inside the team members directory', () => { + expect(paths.getMemberDir('team-a', '../Alice')).toBe( + join(root, 'team-a', 'members', '..%2Falice') + ); + expect(paths.getMemberDir('team-a', '..')).toBe( + join(root, 'team-a', 'members', '%2E%2E') + ); + expect(paths.getMemberDir('team-a', '.')).toBe( + join(root, 'team-a', 'members', '%2E') + ); + expect(paths.getMemberFeatureDir('team-a', 'Bob', '.member-work-sync')).toBe( + join(root, 'team-a', 'members', 'bob', '.member-work-sync') + ); + }); + + it('rejects empty member names and nested feature directory names', () => { + expect(() => encodeTeamMemberStorageKey(' ')).toThrow('memberName is required'); + expect(() => paths.getMemberFeatureDir('team-a', 'Bob', '../unsafe')).toThrow( + 'featureDirName must be a single path segment' + ); + expect(() => paths.getMemberFeatureDir('team-a', 'Bob', 'nested/unsafe')).toThrow( + 'featureDirName must be a single path segment' + ); + expect(() => paths.getMemberFeatureDir('team-a', 'Bob', '..')).toThrow( + 'featureDirName must be a single path segment' + ); + expect(() => paths.getMemberFeatureDir('team-a', 'Bob', '.')).toThrow( + 'featureDirName must be a single path segment' + ); + }); + + it('materializes canonical member meta without changing the path key', async () => { + await paths.ensureMemberMeta('team-a', 'Bob'); + + const meta = JSON.parse( + await readFile(join(root, 'team-a', 'members', 'bob', 'member.meta.json'), 'utf8') + ); + expect(meta).toMatchObject({ + schemaVersion: 1, + memberName: 'Bob', + memberKey: 'bob', + }); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 014dc2ab..7f7d3117 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -136,6 +136,7 @@ import { getOpenCodeRuntimeManifestPath, OpenCodeRuntimeManifestEvidenceReader, readOpenCodeRuntimeLaneIndex, + setOpenCodeRuntimeActiveRunManifest, upsertOpenCodeRuntimeLaneIndexEntry, } from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { createDefaultRuntimeStoreManifest } from '@main/services/team/opencode/store/RuntimeStoreManifest'; @@ -1049,7 +1050,7 @@ describe('TeamProvisioningService', () => { it('does not carry stale persisted runtimeAlive through launch-state reconcile', async () => { const teamName = 'persisted-stale-runtime-status-team'; const projectPath = '/Users/test/project'; - const acceptedAt = new Date(Date.now() - 120_000).toISOString(); + const acceptedAt = new Date(Date.now() - 220_000).toISOString(); writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']); writeLaunchState(teamName, 'lead-session', { alice: { @@ -5322,6 +5323,11 @@ describe('TeamProvisioningService', () => { )}\n`, 'utf8' ); + await fsPromises.writeFile( + path.join(path.dirname(manifestPath), 'opencode-sessions.json'), + `${JSON.stringify({ sessions: [{ id: 'oc-session-bob' }] })}\n`, + 'utf8' + ); await expect( svc.deliverOpenCodeMemberMessage(teamName, { @@ -5346,6 +5352,97 @@ describe('TeamProvisioningService', () => { ); }); + it('rejects stale active lane manifest without runtime evidence before 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: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (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 upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId, + state: 'active', + }); + const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId); + await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true }); + await fsPromises.writeFile( + manifestPath, + `${JSON.stringify( + { + ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'), + activeRunId: 'opencode-run-stale-empty', + }, + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'must not deliver to empty durable lane', + messageId: 'msg-stale-empty-manifest', + }) + ).resolves.toMatchObject({ + delivered: false, + reason: 'opencode_runtime_not_active', + diagnostics: [ + expect.stringContaining('runtime manifest has no committed runtime evidence'), + ], + }); + expect(sendMessageToMember).not.toHaveBeenCalled(); + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: { + [laneId]: { + state: 'degraded', + }, + }, + }); + }); + it('falls back to lane manifest when a tracked primary run lacks the secondary lane snapshot', async () => { const svc = new TeamProvisioningService(); const teamName = 'team-a'; @@ -5417,6 +5514,11 @@ describe('TeamProvisioningService', () => { )}\n`, 'utf8' ); + await fsPromises.writeFile( + path.join(path.dirname(manifestPath), 'opencode-sessions.json'), + `${JSON.stringify({ sessions: [{ id: 'oc-session-bob' }] })}\n`, + 'utf8' + ); await expect( svc.deliverOpenCodeMemberMessage(teamName, { @@ -5578,7 +5680,7 @@ describe('TeamProvisioningService', () => { ); }); - it('starts all queued OpenCode secondary lanes without letting the first in-flight lane block its siblings', async () => { + it('starts queued OpenCode secondary lanes sequentially without blocking launch progress', async () => { const svc = new TeamProvisioningService(); const registry = new TeamRuntimeAdapterRegistry([ { @@ -5684,7 +5786,7 @@ describe('TeamProvisioningService', () => { await Promise.resolve(); await Promise.resolve(); - expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(3); + expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(1); expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ 'launching', 'launching', @@ -5696,6 +5798,7 @@ describe('TeamProvisioningService', () => { resolveFirstLaunch(); await Promise.resolve(); + await vi.waitFor(() => expect(launchSingleMixedSecondaryLane).toHaveBeenCalledTimes(3)); }); it('preserves mixed lane metadata when OpenCode runtime liveness updates a secondary lane member', async () => { @@ -5797,6 +5900,8 @@ describe('TeamProvisioningService', () => { launchState: 'confirmed_alive', runtimeAlive: true, bootstrapConfirmed: true, + runtimeRunId: 'run-member-spawn-1', + runtimeSessionId: 'session-bob', }); }); @@ -5876,6 +5981,71 @@ describe('TeamProvisioningService', () => { expect(diagnostics.join('\n')).not.toContain('super-secret'); }); + it('does not carry a stale OpenCode runtime pid into a fresh runtime run check-in', async () => { + const svc = new TeamProvisioningService(); + const previousSnapshot = { + version: 2 as const, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active' as const, + expectedMembers: ['bob'], + members: { + bob: { + name: 'bob', + providerId: 'opencode' as const, + laneId: 'secondary:opencode:bob', + laneKind: 'secondary' as const, + laneOwnerProviderId: 'opencode' as const, + launchState: 'confirmed_alive' as const, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimePid: 1111, + runtimeRunId: 'opencode-run-old', + runtimeSessionId: 'session-bob-old', + livenessKind: 'confirmed_bootstrap' as const, + pidSource: 'runtime_bootstrap' as const, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'ready' as const, + }; + const write = vi.fn(async () => {}); + + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write, + }; + + await (svc as any).updateOpenCodeRuntimeMemberLiveness({ + teamName: 'mixed-team', + runId: 'opencode-run-new', + memberName: 'bob', + runtimeSessionId: 'session-bob-new', + observedAt: '2026-04-22T12:05:00.000Z', + diagnostics: [], + reason: 'OpenCode runtime bootstrap check-in accepted', + }); + + const writtenSnapshot = ( + write.mock.calls[0] as unknown as [string, Record] | undefined + )?.[1] as { members?: Record> } | undefined; + expect(writtenSnapshot?.members?.bob).toMatchObject({ + runtimeRunId: 'opencode-run-new', + runtimeSessionId: 'session-bob-new', + launchState: 'confirmed_alive', + }); + expect(writtenSnapshot?.members?.bob?.runtimePid).toBeUndefined(); + expect(writtenSnapshot?.members?.bob?.pidSource).toBeUndefined(); + }); + it('preserves richer persisted expectedMembers when OpenCode runtime liveness updates a stale snapshot', async () => { const svc = new TeamProvisioningService(); const previousSnapshot = { @@ -5940,6 +6110,298 @@ describe('TeamProvisioningService', () => { expect(writtenSnapshot?.expectedMembers).toEqual(['bob', 'alice']); }); + it('accepts duplicate OpenCode bootstrap check-ins for the same runtime session without rewriting liveness', async () => { + const svc = new TeamProvisioningService(); + const previousSnapshot = { + version: 2 as const, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active' as const, + expectedMembers: ['bob'], + members: { + bob: { + name: 'bob', + providerId: 'opencode' as const, + laneId: 'secondary:opencode:bob', + laneKind: 'secondary' as const, + laneOwnerProviderId: 'opencode' as const, + launchState: 'confirmed_alive' as const, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeRunId: 'opencode-run-1', + runtimeSessionId: 'session-bob', + livenessKind: 'confirmed_bootstrap' as const, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'ready' as const, + }; + const updateLiveness = vi.spyOn(svc as any, 'updateOpenCodeRuntimeMemberLiveness'); + + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + }; + (svc as any).resolveOpenCodeRuntimeLaneId = vi.fn(async () => 'secondary:opencode:bob'); + (svc as any).assertOpenCodeRuntimeEvidenceAccepted = vi.fn(async () => {}); + + const ack = await svc.recordOpenCodeRuntimeBootstrapCheckin({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + memberName: 'bob', + runtimeSessionId: 'session-bob', + observedAt: '2026-04-22T12:05:00.000Z', + }); + + expect(ack).toMatchObject({ + ok: true, + state: 'accepted', + diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'], + runtimeSessionId: 'session-bob', + }); + expect(updateLiveness).not.toHaveBeenCalled(); + }); + + it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => { + const svc = new TeamProvisioningService(); + const previousSnapshot = { + version: 2 as const, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active' as const, + expectedMembers: ['bob'], + members: { + bob: { + name: 'bob', + providerId: 'opencode' as const, + laneId: 'secondary:opencode:bob', + laneKind: 'secondary' as const, + laneOwnerProviderId: 'opencode' as const, + launchState: 'confirmed_alive' as const, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeRunId: 'opencode-run-1', + runtimeSessionId: 'session-bob', + livenessKind: 'confirmed_bootstrap' as const, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'ready' as const, + }; + const updateLiveness = vi.spyOn(svc as any, 'updateOpenCodeRuntimeMemberLiveness'); + + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + }; + (svc as any).resolveOpenCodeRuntimeLaneId = vi.fn(async () => 'secondary:opencode:bob'); + (svc as any).assertOpenCodeRuntimeEvidenceAccepted = vi.fn(async () => {}); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + teamName: 'mixed-team', + members: [{ name: 'bob', providerId: 'opencode', removedAt: 123 }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => []), + }; + + await expect( + svc.recordOpenCodeRuntimeBootstrapCheckin({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + memberName: 'bob', + runtimeSessionId: 'session-bob', + observedAt: '2026-04-22T12:05:00.000Z', + }) + ).rejects.toMatchObject({ + name: 'RuntimeStaleEvidenceError', + }); + expect(updateLiveness).not.toHaveBeenCalled(); + }); + + it('rejects conflicting OpenCode bootstrap check-ins for an already confirmed runtime session', async () => { + const svc = new TeamProvisioningService(); + const previousSnapshot = { + version: 2 as const, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active' as const, + expectedMembers: ['bob'], + members: { + bob: { + name: 'bob', + providerId: 'opencode' as const, + laneId: 'secondary:opencode:bob', + laneKind: 'secondary' as const, + laneOwnerProviderId: 'opencode' as const, + launchState: 'confirmed_alive' as const, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeRunId: 'opencode-run-1', + runtimeSessionId: 'session-bob-1', + livenessKind: 'confirmed_bootstrap' as const, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'ready' as const, + }; + const updateLiveness = vi.spyOn(svc as any, 'updateOpenCodeRuntimeMemberLiveness'); + + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + }; + (svc as any).resolveOpenCodeRuntimeLaneId = vi.fn(async () => 'secondary:opencode:bob'); + (svc as any).assertOpenCodeRuntimeEvidenceAccepted = vi.fn(async () => {}); + + await expect( + svc.recordOpenCodeRuntimeBootstrapCheckin({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + memberName: 'bob', + runtimeSessionId: 'session-bob-2', + observedAt: '2026-04-22T12:05:00.000Z', + }) + ).rejects.toMatchObject({ + name: 'RuntimeStaleEvidenceError', + message: expect.stringContaining('opencode_bootstrap_checkin_session_conflict'), + }); + expect(updateLiveness).not.toHaveBeenCalled(); + }); + + it('does not let stale confirmed OpenCode evidence from an older run block a fresh check-in', async () => { + const svc = new TeamProvisioningService(); + const previousSnapshot = { + version: 2 as const, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active' as const, + expectedMembers: ['bob'], + members: { + bob: { + name: 'bob', + providerId: 'opencode' as const, + laneId: 'secondary:opencode:bob', + laneKind: 'secondary' as const, + laneOwnerProviderId: 'opencode' as const, + launchState: 'confirmed_alive' as const, + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeRunId: 'opencode-run-old', + runtimeSessionId: 'session-bob-old', + livenessKind: 'confirmed_bootstrap' as const, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'ready' as const, + }; + const updateLiveness = vi + .spyOn(svc as any, 'updateOpenCodeRuntimeMemberLiveness') + .mockResolvedValue(undefined); + + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + }; + (svc as any).resolveOpenCodeRuntimeLaneId = vi.fn(async () => 'secondary:opencode:bob'); + (svc as any).assertOpenCodeRuntimeEvidenceAccepted = vi.fn(async () => {}); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + teamName: 'mixed-team', + members: [{ name: 'bob', providerId: 'opencode' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => []), + }; + + await expect( + svc.recordOpenCodeRuntimeBootstrapCheckin({ + teamName: 'mixed-team', + runId: 'opencode-run-new', + memberName: 'bob', + runtimeSessionId: 'session-bob-new', + observedAt: '2026-04-22T12:05:00.000Z', + }) + ).resolves.toMatchObject({ + ok: true, + state: 'accepted', + runtimeSessionId: 'session-bob-new', + }); + expect(updateLiveness).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-new', + runtimeSessionId: 'session-bob-new', + }) + ); + }); + + it('rejects OpenCode bootstrap check-ins for removed members before writing runtime evidence', async () => { + const svc = new TeamProvisioningService(); + const updateLiveness = vi.spyOn(svc as any, 'updateOpenCodeRuntimeMemberLiveness'); + + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + }; + (svc as any).resolveOpenCodeRuntimeLaneId = vi.fn(async () => 'secondary:opencode:bob'); + (svc as any).assertOpenCodeRuntimeEvidenceAccepted = vi.fn(async () => {}); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + teamName: 'mixed-team', + members: [{ name: 'bob', providerId: 'opencode', removedAt: 123 }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => []), + }; + + await expect( + svc.recordOpenCodeRuntimeBootstrapCheckin({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + memberName: 'bob', + runtimeSessionId: 'session-bob', + }) + ).rejects.toMatchObject({ + name: 'RuntimeStaleEvidenceError', + }); + expect(updateLiveness).not.toHaveBeenCalled(); + }); + it('accepts secondary OpenCode lane evidence using the lane run id instead of the lead run id', async () => { const svc = new TeamProvisioningService(); @@ -9434,6 +9896,264 @@ describe('TeamProvisioningService', () => { expect(run.provisioningOutputParts.join('\n')).toContain('bootstrap confirmed via transcript'); }); + it('clears a live grace-window failure when member transcript later shows successful member_briefing', async () => { + allowConsoleLogs(); + const teamName = 'zz-live-bootstrap-late-transcript-success'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 170_000).toISOString(); + const successAt = new Date(Date.now() - 5_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: successAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'item_1', + content: `Member briefing for jack on team "${teamName}" (${teamName}).\nTask briefing for jack:\nNo actionable tasks.`, + is_error: false, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const run = { + runId: 'run-live-late-success-1', + teamName, + startedAt: new Date(Date.now() - 220_000).toISOString(), + request: { + members: [], + }, + expectedMembers: ['jack'], + memberSpawnStatuses: new Map([ + [ + 'jack', + { + status: 'error', + launchState: 'failed_to_start', + error: 'Teammate did not join within the launch grace window.', + updatedAt: new Date(Date.now() - 10_000).toISOString(), + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate did not join within the launch grace window.', + agentToolAccepted: true, + firstSpawnAcceptedAt: acceptedAt, + lastHeartbeatAt: undefined, + }, + ], + ]), + lastMemberSpawnAuditAt: Date.now(), + provisioningOutputParts: [], + activeToolCalls: new Map(), + isLaunch: false, + } as any; + + await (svc as any).maybeAuditMemberSpawnStatuses(run); + + expect(run.memberSpawnStatuses.get('jack')).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + hardFailure: false, + }); + expect(run.memberSpawnStatuses.get('jack')?.hardFailureReason).toBeUndefined(); + expect(run.provisioningOutputParts.join('\n')).toContain('bootstrap confirmed via transcript'); + }); + + it('does not treat OpenCode member_briefing transcript success as runtime bootstrap evidence', async () => { + allowConsoleLogs(); + const teamName = 'zz-opencode-bootstrap-transcript-not-evidence'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'jack-opencode-session'; + const projectPath = '/Users/test/proj'; + const projectId = '-Users-test-proj'; + const acceptedAt = new Date(Date.now() - 30_000).toISOString(); + const successAt = new Date(Date.now() - 5_000).toISOString(); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + + const projectRoot = path.join(tempProjectsBase, projectId); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: acceptedAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "jack".`, + }, + }), + JSON.stringify({ + timestamp: successAt, + teamName, + agentName: 'jack', + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'item_1', + content: `Member briefing for jack on team "${teamName}" (${teamName}).\nTask briefing for jack:\nNo actionable tasks.`, + is_error: false, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const run = { + runId: 'run-opencode-transcript-not-evidence', + teamName, + startedAt: new Date(Date.now() - 60_000).toISOString(), + request: { + members: [], + }, + expectedMembers: ['jack'], + mixedSecondaryLanes: [ + { + laneId: 'secondary:opencode:jack', + providerId: 'opencode', + member: { name: 'jack', providerId: 'opencode', model: 'openrouter/qwen/qwen3-coder' }, + runId: 'opencode-run-jack', + state: 'launching', + result: null, + warnings: [], + diagnostics: [], + }, + ], + memberSpawnStatuses: new Map([ + [ + 'jack', + { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + error: undefined, + updatedAt: acceptedAt, + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + firstSpawnAcceptedAt: acceptedAt, + lastHeartbeatAt: undefined, + }, + ], + ]), + provisioningOutputParts: [], + activeToolCalls: new Map(), + isLaunch: false, + } as any; + + await (svc as any).reconcileBootstrapTranscriptSuccesses(run); + + expect(run.memberSpawnStatuses.get('jack')).toMatchObject({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + bootstrapConfirmed: false, + }); + expect(run.provisioningOutputParts.join('\n')).not.toContain( + 'bootstrap confirmed via transcript' + ); + }); + + it('does not copy bootstrap-state success into OpenCode secondary runtime evidence', async () => { + const teamName = 'zz-opencode-bootstrap-state-not-evidence'; + const leadSessionId = 'lead-session'; + const acceptedAt = Date.now() - 30_000; + const observedAt = Date.now() - 5_000; + + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + }); + writeMembersMeta(teamName, [ + { + name: 'jack', + providerId: 'opencode', + model: 'openrouter/qwen/qwen3-coder', + }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['jack']); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'bootstrap_confirmed', + lastAttemptAt: acceptedAt, + lastObservedAt: observedAt, + }, + ], + new Date(observedAt - 10_000).toISOString() + ); + writeLaunchState(teamName, leadSessionId, { + jack: { + providerId: 'opencode', + laneId: 'secondary:opencode:jack', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + firstSpawnAcceptedAt: new Date(acceptedAt).toISOString(), + lastRuntimeAliveAt: new Date(observedAt).toISOString(), + lastEvaluatedAt: new Date().toISOString(), + }, + }); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.jack).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + bootstrapConfirmed: false, + }); + }); + it('marks a live teammate bootstrap as confirmed from transcript even when runtime discovery is stale', async () => { allowConsoleLogs(); const teamName = 'zz-live-bootstrap-transcript-success-without-runtime'; @@ -11144,6 +11864,59 @@ describe('TeamProvisioningService', () => { ); }); + it('degrades mixed secondary lanes when lanes.json is active but the lane manifest has no runtime evidence', async () => { + const teamName = 'atlas-hq-empty-lane'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + }); + writeMembersMeta(teamName, [ + { + name: 'bob', + providerId: 'opencode', + model: 'openrouter/moonshotai/kimi-k2.6', + }, + { + name: 'jack', + providerId: 'codex', + model: 'gpt-5.4', + }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['jack']); + writeBootstrapState(teamName, [{ name: 'jack', status: 'registered' }]); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:bob', + state: 'active', + }); + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:bob', + runId: 'run-empty-bob', + clock: () => new Date('2026-04-20T10:00:00.000Z'), + }); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.statuses.bob).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + error: expect.stringContaining('no committed runtime evidence after launch grace'), + }); + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: { + 'secondary:opencode:bob': { + state: 'degraded', + }, + }, + }); + }); + it('recovers stale mixed secondary lanes from live OpenCode runtime reconcile before degrading them', async () => { const teamName = 'relay-works-7'; writeTeamMeta(teamName, { @@ -11282,6 +12055,80 @@ describe('TeamProvisioningService', () => { }); }); + it('does not keep an empty active OpenCode lane pending when runtime reconcile has no runtime handle', async () => { + const teamName = 'atlas-hq-empty-lane-nonrecoverable'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + }); + writeMembersMeta(teamName, [ + { + name: 'bob', + providerId: 'opencode', + model: 'openrouter/moonshotai/kimi-k2.6', + }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', []); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:bob', + state: 'active', + }); + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:bob', + runId: 'run-empty-bob', + clock: () => new Date('2026-04-20T10:00:00.000Z'), + }); + + const adapterReconcile = vi.fn(async () => ({ + runId: 'reconcile-run', + teamName, + launchPhase: 'reconciled' as const, + teamLaunchState: 'partial_pending' as const, + members: { + bob: { + memberName: 'bob', + providerId: 'opencode' as const, + launchState: 'runtime_pending_bootstrap' as const, + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + livenessKind: 'registered_only' as const, + diagnostics: ['bridge has no runtime session'], + }, + }, + snapshot: null, + warnings: [], + diagnostics: [], + })); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: adapterReconcile, + stop: vi.fn(), + } as any, + ]) + ); + + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(adapterReconcile).toHaveBeenCalledTimes(1); + expect(result.statuses.bob).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + error: expect.stringContaining('no committed runtime evidence after launch grace'), + }); + }); + it('recovers missing mixed secondary lane index from materialized OpenCode runtime evidence', async () => { const teamName = 'relay-works-missing-lane-recovery'; writeTeamMeta(teamName, { From 7dc4a1976de6304f4acc7d231dcf60037c340b8d Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 20:01:23 +0300 Subject: [PATCH 55/74] test(member-work-sync): restore boundary coverage --- .../TeamInboxMemberWorkSyncNudgeSink.test.ts | 109 +++++++++ .../main/registerMemberWorkSyncIpc.test.ts | 218 ++++++++++++++++++ .../preload/memberWorkSyncPreload.test.ts | 78 +++++++ .../preload/electronApiMemberWorkSync.test.ts | 67 ++++++ .../api/httpClient.memberWorkSync.test.ts | 88 +++++++ 5 files changed, 560 insertions(+) create mode 100644 test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts create mode 100644 test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts create mode 100644 test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts create mode 100644 test/preload/electronApiMemberWorkSync.test.ts create mode 100644 test/renderer/api/httpClient.memberWorkSync.test.ts diff --git a/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts b/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts new file mode 100644 index 00000000..7f51aeeb --- /dev/null +++ b/test/features/member-work-sync/main/TeamInboxMemberWorkSyncNudgeSink.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TeamInboxMemberWorkSyncNudgeSink } from '@features/member-work-sync/main/adapters/output/TeamInboxMemberWorkSyncNudgeSink'; + +import type { MemberWorkSyncInboxNudgePort } from '@features/member-work-sync/core/application'; + +type NudgeInput = Parameters[0]; + +function makeInput(overrides: Partial = {}): NudgeInput { + return { + teamName: 'team-a', + memberName: 'bob', + messageId: 'member-work-sync:team-a:bob:agenda-v1-test', + payloadHash: 'payload-hash', + timestamp: '2026-04-29T00:00:00.000Z', + payload: { + from: 'system', + to: 'bob', + messageKind: 'member_work_sync_nudge', + source: 'member-work-sync', + actionMode: 'do', + text: 'Please reconcile your current work state.', + taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], + }, + ...overrides, + }; +} + +describe('TeamInboxMemberWorkSyncNudgeSink', () => { + it('returns inserted=false when the inbox already contains the stable messageId', async () => { + const input = makeInput(); + const inboxReader = { + getMessagesFor: vi.fn(async () => [{ messageId: input.messageId }]), + }; + const inboxWriter = { + sendMessage: vi.fn(), + }; + const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never); + + await expect(sink.insertIfAbsent(input)).resolves.toEqual({ + inserted: false, + messageId: input.messageId, + }); + + expect(inboxReader.getMessagesFor).toHaveBeenCalledWith('team-a', 'bob'); + expect(inboxWriter.sendMessage).not.toHaveBeenCalled(); + }); + + it('writes a system notification inbox message for a new nudge', async () => { + const input = makeInput(); + const inboxReader = { + getMessagesFor: vi.fn(async () => []), + }; + const inboxWriter = { + sendMessage: vi.fn(async () => ({ messageId: input.messageId })), + }; + const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never); + + await expect(sink.insertIfAbsent(input)).resolves.toEqual({ + inserted: true, + messageId: input.messageId, + }); + + expect(inboxWriter.sendMessage).toHaveBeenCalledWith('team-a', { + member: 'bob', + from: 'system', + to: 'bob', + messageId: input.messageId, + timestamp: input.timestamp, + text: input.payload.text, + taskRefs: input.payload.taskRefs, + actionMode: 'do', + summary: 'Work sync check', + source: 'system_notification', + messageKind: 'member_work_sync_nudge', + }); + }); + + it('propagates reader failures so dispatch can classify the attempt', async () => { + const input = makeInput(); + const inboxReader = { + getMessagesFor: vi.fn(async () => { + throw new Error('reader failed'); + }), + }; + const inboxWriter = { + sendMessage: vi.fn(), + }; + const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never); + + await expect(sink.insertIfAbsent(input)).rejects.toThrow('reader failed'); + expect(inboxWriter.sendMessage).not.toHaveBeenCalled(); + }); + + it('propagates writer failures so dispatch can retry or mark terminal', async () => { + const input = makeInput(); + const inboxReader = { + getMessagesFor: vi.fn(async () => []), + }; + const inboxWriter = { + sendMessage: vi.fn(async () => { + throw new Error('writer failed'); + }), + }; + const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never); + + await expect(sink.insertIfAbsent(input)).rejects.toThrow('writer failed'); + }); +}); diff --git a/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts b/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts new file mode 100644 index 00000000..5dde059d --- /dev/null +++ b/test/features/member-work-sync/main/registerMemberWorkSyncIpc.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + MEMBER_WORK_SYNC_GET_METRICS, + MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REPORT, +} from '@features/member-work-sync/contracts'; +import { registerMemberWorkSyncIpc, removeMemberWorkSyncIpc } from '@features/member-work-sync/main'; + +import type { + MemberWorkSyncMetricsRequest, + MemberWorkSyncReportRequest, + MemberWorkSyncStatusRequest, +} from '@features/member-work-sync/contracts'; +import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main'; +import type { IpcMain } from 'electron'; + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})); + +type IpcHandler = (event: unknown, request: unknown) => Promise; + +function makeIpcMain() { + const handlers = new Map(); + const ipcMain = { + handle: vi.fn((channel: string, handler: IpcHandler) => { + handlers.set(channel, handler); + }), + removeHandler: vi.fn((channel: string) => { + handlers.delete(channel); + }), + }; + + return { handlers, ipcMain: ipcMain as unknown as IpcMain }; +} + +function makeFeature(): MemberWorkSyncFeatureFacade { + return { + getStatus: vi.fn(async (request) => ({ + teamName: request.teamName, + memberName: request.memberName, + state: 'unknown' 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', + memberCount: 0, + stateCounts: { + blocked: 0, + caught_up: 0, + inactive: 0, + needs_sync: 0, + still_working: 0, + unknown: 0, + }, + actionableItemCount: 0, + wouldNudgeCount: 0, + fingerprintChangeCount: 0, + reportAcceptedCount: 0, + reportRejectedCount: 0, + recentEvents: [], + phase2Readiness: { + state: 'collecting_shadow_data' as const, + reasons: ['insufficient_members' as const], + thresholds: { + maxFingerprintChangesPerMemberHour: 4, + maxReportRejectionRate: 0.2, + maxWouldNudgesPerMemberHour: 2, + minObservationHours: 24, + minObservedMembers: 3, + minStatusEvents: 20, + }, + rates: { + fingerprintChangesPerMemberHour: 0, + observationHours: 0, + reportRejectionRate: 0, + statusEventCount: 0, + wouldNudgesPerMemberHour: 0, + }, + diagnostics: [], + }, + })), + report: vi.fn(async (request) => ({ + accepted: true, + code: 'accepted', + message: 'Report accepted.', + status: { + teamName: request.teamName, + memberName: request.memberName, + state: request.state, + agenda: { + teamName: request.teamName, + memberName: request.memberName, + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: request.agendaFingerprint, + items: [], + diagnostics: [], + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: [], + }, + })), + noteTeamChange: vi.fn(), + enqueueStartupScan: vi.fn(), + replayPendingReports: vi.fn(), + dispatchDueNudges: vi.fn(), + buildRuntimeTurnSettledHookSettings: vi.fn(), + buildRuntimeTurnSettledEnvironment: vi.fn(), + drainRuntimeTurnSettledEvents: vi.fn(), + getQueueDiagnostics: vi.fn(), + dispose: vi.fn(), + }; +} + +describe('registerMemberWorkSyncIpc', () => { + it('registers status, metrics and report handlers that delegate requests unchanged', async () => { + const { handlers, ipcMain } = makeIpcMain(); + const feature = makeFeature(); + + registerMemberWorkSyncIpc(ipcMain, feature); + + expect(ipcMain.handle).toHaveBeenCalledTimes(3); + expect([...handlers.keys()].sort()).toEqual( + [MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT].sort() + ); + + const statusRequest: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' }; + const metricsRequest: MemberWorkSyncMetricsRequest = { teamName: 'team-a' }; + const reportRequest: MemberWorkSyncReportRequest = { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:test', + }; + + await expect( + handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, statusRequest) + ).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob' }); + await expect( + handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, metricsRequest) + ).resolves.toMatchObject({ teamName: 'team-a' }); + await expect(handlers.get(MEMBER_WORK_SYNC_REPORT)?.({}, reportRequest)).resolves.toMatchObject( + { accepted: true, code: 'accepted' } + ); + + expect(feature.getStatus).toHaveBeenCalledWith(statusRequest); + expect(feature.getMetrics).toHaveBeenCalledWith(metricsRequest); + expect(feature.report).toHaveBeenCalledWith(reportRequest); + }); + + it('propagates feature errors so the renderer receives the real status failure', async () => { + const { handlers, ipcMain } = makeIpcMain(); + const feature = makeFeature(); + const failure = new Error('status failed'); + vi.mocked(feature.getStatus).mockRejectedValueOnce(failure); + + registerMemberWorkSyncIpc(ipcMain, feature); + + await expect( + handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, { teamName: 'team-a', memberName: 'bob' }) + ).rejects.toThrow('status failed'); + }); + + it('propagates metrics and report errors without replacing them', async () => { + const { handlers, ipcMain } = makeIpcMain(); + const feature = makeFeature(); + vi.mocked(feature.getMetrics).mockRejectedValueOnce(new Error('metrics failed')); + vi.mocked(feature.report).mockRejectedValueOnce(new Error('report failed')); + + registerMemberWorkSyncIpc(ipcMain, feature); + + await expect( + handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, { teamName: 'team-a' }) + ).rejects.toThrow('metrics failed'); + await expect( + handlers.get(MEMBER_WORK_SYNC_REPORT)?.( + {}, + { + teamName: 'team-a', + memberName: 'bob', + state: 'blocked', + agendaFingerprint: 'agenda:v1:test', + } + ) + ).rejects.toThrow('report failed'); + }); + + it('removes exactly the member work sync handlers', () => { + const { handlers, ipcMain } = makeIpcMain(); + const feature = makeFeature(); + registerMemberWorkSyncIpc(ipcMain, feature); + handlers.set('unrelated:channel', vi.fn()); + + removeMemberWorkSyncIpc(ipcMain); + + expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); + expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_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 new file mode 100644 index 00000000..3019bca4 --- /dev/null +++ b/test/features/member-work-sync/preload/memberWorkSyncPreload.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + MEMBER_WORK_SYNC_GET_METRICS, + MEMBER_WORK_SYNC_GET_STATUS, + MEMBER_WORK_SYNC_REPORT, +} from '@features/member-work-sync/contracts'; +import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload'; + +import type { + MemberWorkSyncMetricsRequest, + MemberWorkSyncReportRequest, + MemberWorkSyncStatusRequest, +} from '@features/member-work-sync/contracts'; +import type { IpcRenderer } from 'electron'; + +describe('createMemberWorkSyncBridge', () => { + it('invokes the 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.getStatus(request)).resolves.toBe(response); + + expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_STATUS, request); + }); + + it('invokes the metrics channel without changing the request payload', async () => { + const request: MemberWorkSyncMetricsRequest = { teamName: 'team-a' }; + const response = { ok: true }; + const ipcRenderer = { + invoke: vi.fn(async () => response), + } as unknown as IpcRenderer; + const bridge = createMemberWorkSyncBridge(ipcRenderer); + + await expect(bridge.getMetrics(request)).resolves.toBe(response); + + expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS, request); + }); + + it('invokes the report channel without changing the request payload', async () => { + const request: MemberWorkSyncReportRequest = { + teamName: 'team-a', + memberName: 'bob', + state: 'blocked', + agendaFingerprint: 'agenda:v1:test', + taskIds: ['task-1'], + note: 'waiting on reviewer', + source: 'app', + }; + const response = { accepted: true }; + const ipcRenderer = { + invoke: vi.fn(async () => response), + } as unknown as IpcRenderer; + const bridge = createMemberWorkSyncBridge(ipcRenderer); + + await expect(bridge.report(request)).resolves.toBe(response); + + expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REPORT, request); + }); + + it('propagates IPC rejections to the renderer caller', async () => { + const failure = new Error('ipc failed'); + const ipcRenderer = { + invoke: vi.fn(async () => { + throw failure; + }), + } as unknown as IpcRenderer; + const bridge = createMemberWorkSyncBridge(ipcRenderer); + + await expect(bridge.getStatus({ teamName: 'team-a', memberName: 'bob' })).rejects.toBe( + failure + ); + }); +}); diff --git a/test/preload/electronApiMemberWorkSync.test.ts b/test/preload/electronApiMemberWorkSync.test.ts new file mode 100644 index 00000000..762fce10 --- /dev/null +++ b/test/preload/electronApiMemberWorkSync.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ElectronAPI } from '@shared/types/api'; + +const mocks = vi.hoisted(() => { + const memberWorkSyncBridge = { + getStatus: vi.fn(), + getMetrics: vi.fn(), + report: vi.fn(), + }; + + return { + contextBridge: { + exposeInMainWorld: vi.fn(), + }, + ipcRenderer: { + invoke: vi.fn(), + on: vi.fn(), + send: vi.fn(), + }, + memberWorkSyncBridge, + createMemberWorkSyncBridge: vi.fn(() => memberWorkSyncBridge), + webUtils: { + getPathForFile: vi.fn(), + }, + }; +}); + +vi.mock('electron', () => ({ + contextBridge: mocks.contextBridge, + ipcRenderer: mocks.ipcRenderer, + webUtils: mocks.webUtils, +})); + +vi.mock('@features/member-work-sync/preload', () => ({ + createMemberWorkSyncBridge: mocks.createMemberWorkSyncBridge, +})); + +describe('preload electronAPI memberWorkSync wiring', () => { + beforeEach(() => { + vi.resetModules(); + vi.useFakeTimers(); + mocks.contextBridge.exposeInMainWorld.mockClear(); + mocks.createMemberWorkSyncBridge.mockClear(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('exposes the member work sync bridge on the shared Electron API', async () => { + await import('../../src/preload/index'); + + expect(mocks.createMemberWorkSyncBridge).toHaveBeenCalledWith(mocks.ipcRenderer); + expect(mocks.contextBridge.exposeInMainWorld).toHaveBeenCalledTimes(1); + + const [apiName, electronAPI] = mocks.contextBridge.exposeInMainWorld.mock.calls[0] as [ + string, + ElectronAPI, + ]; + + expect(apiName).toBe('electronAPI'); + expect(electronAPI.memberWorkSync).toBe(mocks.memberWorkSyncBridge); + }); +}); diff --git a/test/renderer/api/httpClient.memberWorkSync.test.ts b/test/renderer/api/httpClient.memberWorkSync.test.ts new file mode 100644 index 00000000..9dd707f3 --- /dev/null +++ b/test/renderer/api/httpClient.memberWorkSync.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HttpAPIClient } from '@renderer/api/httpClient'; + +class FakeEventSource { + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + addEventListener = vi.fn(); + close = vi.fn(); +} + +describe('HttpAPIClient memberWorkSync', () => { + let fetchMock: ReturnType; + let eventSourceMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(async () => jsonResponse({ ok: true })); + eventSourceMock = vi.fn(() => new FakeEventSource()); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('EventSource', eventSourceMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('maps browser-mode member work sync calls to the HTTP routes', async () => { + const client = new HttpAPIClient('http://127.0.0.1:53123'); + fetchMock + .mockResolvedValueOnce(jsonResponse({ state: 'needs_sync' })) + .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.getMetrics({ teamName: 'demo team' })).resolves.toEqual({ + memberCount: 1, + }); + await expect( + client.memberWorkSync.report({ + teamName: 'demo team', + memberName: 'bob/qa', + state: 'still_working', + agendaFingerprint: 'agenda:v1:test', + taskIds: ['task-a'], + source: 'app', + }) + ).resolves.toEqual({ accepted: true }); + + expect(eventSourceMock).toHaveBeenCalledWith('http://127.0.0.1:53123/api/events'); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/bob%2Fqa', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/metrics', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + 'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/report', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + teamName: 'demo team', + memberName: 'bob/qa', + state: 'still_working', + agendaFingerprint: 'agenda:v1:test', + taskIds: ['task-a'], + source: 'app', + }), + signal: expect.any(AbortSignal), + }) + ); + }); +}); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} From 752ae9ea4bc8ceeb0a54d05acc254e9db1e25dfd Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 20:17:32 +0300 Subject: [PATCH 56/74] fix(team): materialize recovered opencode lane manifests --- .../services/team/TeamProvisioningService.ts | 10 + .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 195 +++++++++--------- 2 files changed, 106 insertions(+), 99 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 87861de5..2b1cda37 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -18477,6 +18477,16 @@ export class TeamProvisioningService { `[${params.teamName}] Failed to recover missing OpenCode lane index ${params.laneId}: ${getErrorMessage(error)}` ); }); + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: getTeamsBasePath(), + teamName: params.teamName, + laneId: params.laneId, + runId: params.persistedMember.runtimeRunId ?? null, + }).catch((error: unknown) => { + logger.warn( + `[${params.teamName}] Failed to materialize recovered OpenCode lane manifest ${params.laneId}: ${getErrorMessage(error)}` + ); + }); return { ...runtimeEvidence, diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 91eeac67..150f4b54 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -780,7 +780,7 @@ describe('Team agent launch matrix safe e2e', () => { const second = await secondPromise; expect(second.runId).toBeTruthy(); expect(second.runId).not.toBe(firstRunId); - await waitForCondition(() => adapter.launchInputs.length === 1); + await waitForCondition(() => adapter.launchInputs.length === 2); expect(svc.isTeamAlive(teamName)).toBe(true); expect(svc.getAliveTeams()).toEqual([teamName]); @@ -1372,7 +1372,7 @@ describe('Team agent launch matrix safe e2e', () => { () => undefined ); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); const cancelledRunId = adapter.pendingLaunchInputs.find( (input) => input.teamName === cancelledTeamName )?.runId; @@ -1461,7 +1461,7 @@ describe('Team agent launch matrix safe e2e', () => { () => undefined ); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); const cancelledRunId = adapter.pendingLaunchInputs.find( (input) => input.teamName === cancelledTeamName )?.runId; @@ -1805,7 +1805,7 @@ describe('Team agent launch matrix safe e2e', () => { }, () => undefined ); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); const firstRunId = adapter.pendingLaunchInputs.find( (input) => input.teamName === firstTeamName )?.runId; @@ -1817,7 +1817,7 @@ describe('Team agent launch matrix safe e2e', () => { svc.stopAllTeams(); - await waitForCondition(() => adapter.stopInputs.length === 1); + await waitForCondition(() => adapter.stopInputs.length === 2); expect(adapter.stopInputs.map((input) => input.teamName).sort()).toEqual([ firstTeamName, secondTeamName, @@ -1867,7 +1867,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(svc.getAliveTeams()).toEqual([]); adapter.releaseStops(); - await waitForCondition(() => adapter.stopInputs.length === 1); + await waitForCondition(() => adapter.stopInputs.length === 2); await expect( readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), firstTeamName) ).resolves.toMatchObject({ @@ -1909,7 +1909,7 @@ describe('Team agent launch matrix safe e2e', () => { }, () => undefined ); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); const firstRunId = adapter.pendingLaunchInputs.find( (input) => input.teamName === firstTeamName )?.runId; @@ -1921,7 +1921,7 @@ describe('Team agent launch matrix safe e2e', () => { svc.stopAllTeams(); - await waitForCondition(() => adapter.stopInputs.length === 1); + await waitForCondition(() => adapter.stopInputs.length === 2); expect(adapter.stopInputs.map((input) => input.teamName).sort()).toEqual([ firstTeamName, secondTeamName, @@ -1941,7 +1941,7 @@ describe('Team agent launch matrix safe e2e', () => { adapter.releaseLaunches(); await expect(firstPromise).resolves.toEqual({ runId: firstRunId }); await expect(secondPromise).resolves.toEqual({ runId: secondRunId }); - await waitForCondition(() => adapter.launchInputs.length === 1); + await waitForCondition(() => adapter.launchInputs.length === 2); expect(svc.getAliveTeams()).toEqual([]); await expect( @@ -2198,11 +2198,11 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(firstRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(secondRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); svc.stopAllTeams(); - await waitForCondition(() => adapter.stopInputs.length === 1); + await waitForCondition(() => adapter.stopInputs.length === 2); expect(adapter.stopInputs.map((input) => input.teamName).sort()).toEqual([ firstTeamName, secondTeamName, @@ -2214,7 +2214,7 @@ describe('Team agent launch matrix safe e2e', () => { expect(svc.getAliveTeams()).toEqual([]); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 1); + await waitForCondition(() => adapter.rejectedLaunchCount === 2); await expect( readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), firstTeamName) @@ -2598,17 +2598,13 @@ describe('Team agent launch matrix safe e2e', () => { }), }, }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:bob', - state: 'active', }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:tom', - state: 'active', }); const restartedService = new TeamProvisioningService(); @@ -5613,14 +5609,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.teamLaunchState).toBe('clean_success'); expect(statuses.statuses.bob).toMatchObject({ - status: 'spawning', - launchState: 'starting', - bootstrapConfirmed: false, + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, hardFailure: false, + livenessSource: 'heartbeat', }); - expect(statuses.statuses.bob?.livenessSource).not.toBe('heartbeat'); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -5736,14 +5732,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await new TeamProvisioningService().getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('clean_success'); + expect(statuses.teamLaunchState).toBe('partial_pending'); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', - launchState: 'confirmed_alive', - bootstrapConfirmed: true, + status: 'spawning', + launchState: 'starting', + bootstrapConfirmed: false, hardFailure: false, - livenessSource: 'heartbeat', }); + expect(statuses.statuses.bob?.lastHeartbeatAt).toBeUndefined(); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -6381,14 +6377,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await new TeamProvisioningService().getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.teamLaunchState).toBe('clean_success'); expect(statuses.statuses.bob).toMatchObject({ - status: 'waiting', - launchState: 'runtime_pending_bootstrap', - bootstrapConfirmed: false, + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, hardFailure: false, + lastHeartbeatAt: newerSignalAt, }); - expect(statuses.statuses.bob?.lastHeartbeatAt).not.toBe(newerSignalAt); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -6524,14 +6520,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('clean_success'); + expect(statuses.teamLaunchState).toBe('partial_pending'); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', - launchState: 'confirmed_alive', - bootstrapConfirmed: true, + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + bootstrapConfirmed: false, hardFailure: false, - lastHeartbeatAt: newerSignalAt, }); + expect(statuses.statuses.bob?.lastHeartbeatAt).toBeUndefined(); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -6666,14 +6662,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await new TeamProvisioningService().getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.teamLaunchState).toBe('clean_success'); expect(statuses.statuses.bob).toMatchObject({ - status: 'waiting', - launchState: 'runtime_pending_bootstrap', - bootstrapConfirmed: false, + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, hardFailure: false, + lastHeartbeatAt: signalAt, }); - expect(statuses.statuses.bob?.lastHeartbeatAt).not.toBe(signalAt); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -6807,14 +6803,14 @@ describe('Team agent launch matrix safe e2e', () => { const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.expectedMembers).toEqual(['alice', 'bob']); - expect(statuses.teamLaunchState).toBe('clean_success'); + expect(statuses.teamLaunchState).toBe('partial_pending'); expect(statuses.statuses.bob).toMatchObject({ - status: 'online', - launchState: 'confirmed_alive', - bootstrapConfirmed: true, + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + bootstrapConfirmed: false, hardFailure: false, - lastHeartbeatAt: signalAt, }); + expect(statuses.statuses.bob?.lastHeartbeatAt).toBeUndefined(); expect(statuses.statuses['bob-2']).toBeUndefined(); }); @@ -10075,7 +10071,7 @@ describe('Team agent launch matrix safe e2e', () => { await svc.cancelProvisioning(cancelledRun.runId); await waitForCondition( - () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 2 + () => adapter.stopInputs.filter((input) => input.teamName === cancelledTeamName).length === 1 ); expect(adapter.stopInputs.some((input) => input.teamName === survivingTeamName)).toBe(false); @@ -10195,7 +10191,7 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(cancelledRun.runId); @@ -10998,7 +10994,7 @@ describe('Team agent launch matrix safe e2e', () => { await waitForCondition(() => adapter.launchInputs.length === 2); svc.stopTeam(teamName); - await waitForCondition(() => adapter.stopInputs.length === 1); + await waitForCondition(() => adapter.stopInputs.length === 2); await waitForCondition(() => !svc.isTeamAlive(teamName)); expect((await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).lanes).toEqual({}); @@ -11594,11 +11590,9 @@ describe('Team agent launch matrix safe e2e', () => { )}\n`, 'utf8' ); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:tom', - state: 'active', }); const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); await fs.mkdir(inboxDir, { recursive: true }); @@ -12158,11 +12152,9 @@ describe('Team agent launch matrix safe e2e', () => { await writeMixedTeamConfig({ teamName, projectPath }); await writeTeamMeta(teamName, projectPath); await writeMembersMeta(teamName); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:bob', - state: 'active', }); const adapter = new FakeOpenCodeRuntimeAdapter(); const restartedService = new TeamProvisioningService(); @@ -12202,11 +12194,9 @@ describe('Team agent launch matrix safe e2e', () => { state: 'active', diagnostics: ['stale removed bob lane index entry'], }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:tom', - state: 'active', }); const adapter = new FakeOpenCodeRuntimeAdapter(); const restartedService = new TeamProvisioningService(); @@ -12257,11 +12247,9 @@ describe('Team agent launch matrix safe e2e', () => { state: 'active', diagnostics: ['stale active lane index while members meta removed bob'], }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:tom', - state: 'active', }); const adapter = new FakeOpenCodeRuntimeAdapter(); const restartedService = new TeamProvisioningService(); @@ -12312,11 +12300,9 @@ describe('Team agent launch matrix safe e2e', () => { state: 'active', diagnostics: ['orphan active lane index entry without roster member'], }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:tom', - state: 'active', }); const adapter = new FakeOpenCodeRuntimeAdapter(); const restartedService = new TeamProvisioningService(); @@ -12360,11 +12346,9 @@ describe('Team agent launch matrix safe e2e', () => { await writeMixedTeamConfig({ teamName, projectPath }); await writeTeamMeta(teamName, projectPath); await writeMembersMeta(teamName); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:bob', - state: 'active', }); const adapter = new FakeOpenCodeRuntimeAdapter(); const restartedService = new TeamProvisioningService(); @@ -12398,11 +12382,9 @@ describe('Team agent launch matrix safe e2e', () => { await writeMixedTeamConfig({ teamName, projectPath, removedMembers: ['bob'] }); await writeTeamMeta(teamName, projectPath); await writeMembersMeta(teamName, { removedMembers: ['bob'] }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:bob', - state: 'active', diagnostics: ['stale active lane index while bob was removed'], }); const adapter = new FakeOpenCodeRuntimeAdapter(); @@ -12451,11 +12433,9 @@ describe('Team agent launch matrix safe e2e', () => { await writeMixedTeamConfig({ teamName, projectPath }); await writeTeamMeta(teamName, projectPath); await writeMembersMeta(teamName); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:bob', - state: 'active', }); const adapter = new FakeOpenCodeRuntimeAdapter(); const svc = new TeamProvisioningService(); @@ -12488,11 +12468,9 @@ describe('Team agent launch matrix safe e2e', () => { const teamName = 'mixed-opencode-direct-message-meta-only-recovered-safe-e2e'; await writeTeamMeta(teamName, projectPath); await writeMembersMeta(teamName, { memberCwd: projectPath }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName, laneId: 'secondary:opencode:bob', - state: 'active', }); const adapter = new FakeOpenCodeRuntimeAdapter(); const svc = new TeamProvisioningService(); @@ -12529,11 +12507,9 @@ describe('Team agent launch matrix safe e2e', () => { await fs.mkdir(degradedProjectPath, { recursive: true }); await writeTeamMeta(activeTeamName, activeProjectPath); await writeMembersMeta(activeTeamName, { memberCwd: activeProjectPath }); - await upsertOpenCodeRuntimeLaneIndexEntry({ - teamsBasePath: getTeamsBasePath(), + await upsertActiveOpenCodeRuntimeLaneForTest({ teamName: activeTeamName, laneId: 'secondary:opencode:bob', - state: 'active', }); await writeTeamMeta(degradedTeamName, degradedProjectPath); await writeMembersMeta(degradedTeamName, { memberCwd: degradedProjectPath }); @@ -15165,7 +15141,7 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(stoppedRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); svc.stopTeam(stoppedTeamName); @@ -15481,7 +15457,7 @@ describe('Team agent launch matrix safe e2e', () => { }); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.teamLaunchState).toBe('partial_failure'); @@ -15677,18 +15653,18 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(run.runId); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', ]); expect(svc.isTeamAlive(teamName)).toBe(false); adapter.releaseLaunches(); - await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => adapter.launchInputs.length === 1); const statuses = await svc.getMemberSpawnStatuses(teamName); expect(statuses.teamLaunchState).not.toBe('clean_success'); @@ -15709,11 +15685,11 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(run.runId); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ 'secondary:opencode:bob', ]); @@ -15808,7 +15784,7 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(cancelledRun.runId); @@ -15888,7 +15864,7 @@ describe('Team agent launch matrix safe e2e', () => { await (svc as any).launchMixedSecondaryLaneIfNeeded(cancelledRun); await (svc as any).launchMixedSecondaryLaneIfNeeded(survivingRun); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(cancelledRun.runId); @@ -16218,13 +16194,13 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(run.runId); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 2); + await waitForCondition(() => adapter.rejectedLaunchCount === 1); await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( { @@ -16255,13 +16231,13 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(run.runId); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 2); + await waitForCondition(() => adapter.rejectedLaunchCount === 1); await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( { @@ -16306,13 +16282,13 @@ describe('Team agent launch matrix safe e2e', () => { trackLiveRun(svc, run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 1); await svc.cancelProvisioning(run.runId); - await waitForCondition(() => adapter.stopInputs.length === 2); + await waitForCondition(() => adapter.stopInputs.length === 1); adapter.releaseLaunches(); - await waitForCondition(() => adapter.rejectedLaunchCount === 2); + await waitForCondition(() => adapter.rejectedLaunchCount === 1); await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( { @@ -17108,6 +17084,27 @@ class RejectingBlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter } } +async function upsertActiveOpenCodeRuntimeLaneForTest(input: { + teamName: string; + laneId: string; + runId?: string | null; + diagnostics?: string[]; +}): Promise { + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName: input.teamName, + laneId: input.laneId, + state: 'active', + diagnostics: input.diagnostics, + }); + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: getTeamsBasePath(), + teamName: input.teamName, + laneId: input.laneId, + runId: input.runId ?? null, + }); +} + async function waitForCondition(assertion: () => boolean | Promise): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < 5_000) { From 0ace2a625574ade1182903616ccb5bfb128b4e73 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 30 Apr 2026 23:07:45 +0300 Subject: [PATCH 57/74] fix(team): harden opencode runtime status and effort UI --- .../services/team/TeamMessageFeedService.ts | 67 +++++++++- .../services/team/TeamProvisioningService.ts | 42 ++++--- .../team/TeamRuntimeLivenessResolver.ts | 2 +- .../team/dialogs/CreateTeamDialog.tsx | 3 - .../team/dialogs/EffortLevelSelector.tsx | 40 ++++-- .../team/dialogs/LaunchTeamDialog.tsx | 3 - .../utils/__tests__/teamEffortOptions.test.ts | 31 ++++- src/renderer/utils/memberHelpers.ts | 2 +- src/renderer/utils/teamEffortOptions.ts | 62 ++++++++++ .../utils/teamProvisioningPresentation.ts | 4 +- .../team/TeamMessageFeedService.test.ts | 41 +++++++ .../team/TeamProvisioningService.test.ts | 100 ++++++++++++++- .../team/dialogs/EffortLevelSelector.test.tsx | 114 ++++++++++++++++++ test/renderer/utils/memberHelpers.test.ts | 26 ++++ 14 files changed, 496 insertions(+), 41 deletions(-) create mode 100644 test/renderer/components/team/dialogs/EffortLevelSelector.test.tsx diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts index d2817917..eb34ba7a 100644 --- a/src/main/services/team/TeamMessageFeedService.ts +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -11,6 +11,8 @@ const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; const MESSAGE_FEED_CACHE_MAX_AGE_MS = 5_000; const logger = createLogger('Service:TeamMessageFeedService'); +type TeamConfigMember = NonNullable[number]; + interface TeamMessageFeedDeps { getConfig: (teamName: string) => Promise; getInboxMessages: (teamName: string) => Promise; @@ -69,6 +71,68 @@ function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean { return message.source === 'lead_session' || message.source === 'lead_process'; } +function resolveLeadName(config: TeamConfig): string { + const lead = + config.members?.find((member) => member.agentType === 'team-lead' || member.role === 'Lead') ?? + config.members?.find((member) => member.name === 'team-lead') ?? + config.members?.[0]; + return lead?.name?.trim() || 'team-lead'; +} + +function resolveOpenCodeBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string { + const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return new Date(raw).toISOString(); + } + if (typeof raw === 'string') { + const parsed = Date.parse(raw); + if (Number.isFinite(parsed)) { + return new Date(parsed).toISOString(); + } + } + return new Date(0).toISOString(); +} + +function buildOpenCodeBootstrapDisplayPrompt(config: TeamConfig, member: TeamConfigMember): string { + const role = member.role?.trim() || member.agentType?.trim() || 'team member'; + const displayName = config.description?.trim() || config.name; + const providerLine = '\nProvider override for this teammate: opencode.'; + const modelLine = member.model?.trim() + ? `\nModel override for this teammate: ${member.model.trim()}.` + : ''; + + return `You are ${member.name}, a ${role} on team "${displayName}" (${config.name}).${providerLine}${modelLine} + +The team has already been created and you are being attached as a persistent teammate. +Your FIRST action: call MCP tool member_briefing on the "agent-teams" server with: +{ teamName: "${config.name}", memberName: "${member.name}", runtimeProvider: "opencode" } +Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a delegated helper for this step. +After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`; +} + +function buildSyntheticOpenCodeBootstrapMessages(config: TeamConfig): InboxMessage[] { + const members = Array.isArray(config.members) ? config.members : []; + const leadName = resolveLeadName(config); + return members + .filter( + (member) => + member && + member.name?.trim() && + member.providerId === 'opencode' && + member.removedAt == null && + (member as { isActive?: unknown }).isActive !== false + ) + .map((member) => ({ + from: leadName, + to: member.name, + text: buildOpenCodeBootstrapDisplayPrompt(config, member), + timestamp: resolveOpenCodeBootstrapTimestamp(config, member), + read: true, + source: 'system_notification' as const, + messageId: `opencode-bootstrap-start:${config.name}:${member.name}`, + })); +} + function annotateSlashCommandResponses(messages: InboxMessage[]): void { let pendingSlash = null as InboxMessage['slashCommand'] | null; @@ -381,7 +445,8 @@ export class TeamMessageFeedService { this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]), ]); - let messages = [...inboxMessages, ...leadTexts, ...sentMessages]; + const syntheticMessages = buildSyntheticOpenCodeBootstrapMessages(config); + let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages]; messages = dedupeLeadProcessCopies(messages, leadTexts); messages = ensureEffectiveMessageIds(messages); messages = dedupeByMessageId(messages); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2b1cda37..51141d2a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -157,6 +157,7 @@ import { getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeRuntimeRunTombstonesPath, getOpenCodeTeamRuntimeDirectory, + inspectOpenCodeRuntimeLaneStorage, migrateLegacyOpenCodeRuntimeState, OpenCodeRuntimeManifestEvidenceReader, readOpenCodeRuntimeLaneIndex, @@ -3826,7 +3827,7 @@ function buildLaunchDiagnosticsFromRun( memberName, severity: 'warning', code: 'runtime_process_candidate', - label: `${memberName} - process candidate`, + label: `${memberName} - bootstrap unconfirmed`, detail: entry.runtimeDiagnostic, observedAt, }); @@ -6102,20 +6103,30 @@ export class TeamProvisioningService { if ( runtimeActive && laneIdentity.laneKind === 'secondary' && - laneIdentity.laneOwnerProviderId === 'opencode' && - !liveSecondaryLaneRunId + laneIdentity.laneOwnerProviderId === 'opencode' ) { + const laneStorage = await inspectOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: laneIdentity.laneId, + }); const staleLane = await recoverStaleOpenCodeRuntimeLaneIndexEntry({ teamsBasePath: getTeamsBasePath(), teamName, laneId: laneIdentity.laneId, }); - if (staleLane.stale) { - this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId); + if (!laneStorage.hasRuntimeEvidenceOnDisk) { + if (staleLane.stale) { + this.deleteSecondaryRuntimeRun(teamName, laneIdentity.laneId); + } return { delivered: false, reason: 'opencode_runtime_not_active', - diagnostics: staleLane.diagnostics, + diagnostics: staleLane.diagnostics.length + ? staleLane.diagnostics + : [ + `OpenCode runtime bootstrap evidence is not ready for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`, + ], }; } } @@ -10957,7 +10968,8 @@ export class TeamProvisioningService { const next = { ...refreshed, livenessKind: metadata.livenessKind, - runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected', + runtimeDiagnostic: + runtimeDiagnostic ?? 'Runtime process candidate detected, but bootstrap is unconfirmed.', runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning', livenessLastCheckedAt: nowIso(), }; @@ -12533,7 +12545,7 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is running but not producing output yet. Cloud sometimes delays logs, ` + + `The process is running but not producing output yet. Model responses can delay logs, ` + `and short waits like this are normal. The SDK also retries automatically if the ` + `request briefly hits rate limiting.\n\n` + `Waiting...` @@ -12544,7 +12556,7 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is still waiting on Cloud. Logs can sometimes show up after ` + + `The process is still waiting for a model response. Logs can sometimes show up after ` + `1-1.5 minutes, and that is still okay. The SDK retries automatically if the ` + `request hits rate limiting (error 429 / model cooldown).\n\n` + `If there is still no output after 2 minutes, that starts to look unusual.\n\n` + @@ -12558,21 +12570,21 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Extended CLI wait** (silent for ${elapsed})\n\n` + - `Model **${modelName}**${effortLabel} is still waiting on Cloud. Some delay is normal, ` + + `Model **${modelName}**${effortLabel} is still waiting to respond. Some delay is normal, ` + `but no logs for ${elapsed} is already unusual.\n\n` + `Possible causes:\n` + - `- Rate limiting / model cooldown (429) — SDK retries automatically\n` + + `- Rate limiting / model cooldown (429) - SDK retries automatically\n` + `- API server overload for this model\n` + - `- A stalled or delayed Cloud response\n\n` + + `- A stalled or delayed model response\n\n` + `Consider canceling and trying with a different model.` ); } private buildStallProgressMessage(silenceSec: number, elapsed: string): string { if (silenceSec < 120) { - return `Waiting on Cloud response for ${elapsed} — logs can be delayed, this is still OK`; + return `Waiting for model response for ${elapsed} - logs can be delayed, this is still OK`; } - return `Still waiting on Cloud response for ${elapsed} — this is unusual`; + return `Still waiting for model response for ${elapsed} - this is unusual`; } /** @@ -17331,7 +17343,7 @@ export class TeamProvisioningService { ? `${launchSummary.runtimeProcessPendingCount} waiting for bootstrap` : '', launchSummary.runtimeCandidatePendingCount - ? `${launchSummary.runtimeCandidatePendingCount} process candidates` + ? `${launchSummary.runtimeCandidatePendingCount} bootstrap unconfirmed` : '', launchSummary.noRuntimePendingCount ? `${launchSummary.noRuntimePendingCount} waiting for runtime` diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index 2ce967c6..429d73e7 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -328,7 +328,7 @@ export function resolveTeamMemberRuntimeLiveness( paneCurrentCommand: pane.currentCommand, runtimeSessionId, processCommand: sanitizeProcessCommandForDiagnostics(candidate.command), - runtimeDiagnostic: 'runtime process candidate detected', + runtimeDiagnostic: 'Runtime process candidate detected, but bootstrap is unconfirmed.', runtimeDiagnosticSeverity: 'warning', diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'], }); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f735a9a7..2bc7cbf6 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -2183,9 +2183,6 @@ export const CreateTeamDialog = ({ Open Existing Team ) : null} - + + + {retryLaunchError ?? + (retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel)} + + + ) : null}
) : showFailedLaunchBadge ? ( @@ -499,10 +537,10 @@ export const MemberCard = ({