From 544f4576d4e473c63e182c22fdff339034fdfeb6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 1 Jun 2026 21:51:08 +0300 Subject: [PATCH] fix(member-work-sync): recover sleeping teammates --- .../core/domain/taskGraphSemantics.ts | 13 +- .../MemberWorkSyncNudgeActivationPolicy.ts | 48 ++++-- .../MemberWorkSyncNudgeDispatcher.ts | 3 +- .../core/domain/ActionableWorkAgenda.ts | 8 +- .../input/MemberWorkSyncTaskImpactResolver.ts | 8 +- .../createMemberWorkSyncFeature.ts | 76 ++++++++- .../team/stallMonitor/TeamTaskStallPolicy.ts | 4 +- src/main/services/team/teamTaskActiveState.ts | 1 + src/shared/utils/teamTaskState.ts | 20 +++ .../core/ActionableWorkAgenda.test.ts | 5 +- .../core/MemberWorkSyncUseCases.test.ts | 90 ++++++++++- ...emberWorkSyncNudgeActivationPolicy.test.ts | 71 ++++++++ .../MemberWorkSyncTaskImpactResolver.test.ts | 5 +- .../main/createMemberWorkSyncFeature.test.ts | 151 ++++++++++++++++++ .../stallMonitor/TeamTaskStallPolicy.test.ts | 132 +++++++++++++++ .../services/team/teamTaskActiveState.test.ts | 35 ++++ .../agent-graph/taskGraphSemantics.test.ts | 4 +- 17 files changed, 631 insertions(+), 43 deletions(-) diff --git a/src/features/agent-graph/core/domain/taskGraphSemantics.ts b/src/features/agent-graph/core/domain/taskGraphSemantics.ts index 53ed30ed..caab2677 100644 --- a/src/features/agent-graph/core/domain/taskGraphSemantics.ts +++ b/src/features/agent-graph/core/domain/taskGraphSemantics.ts @@ -1,7 +1,6 @@ import { getTeamTaskWorkflowColumn, - isTeamTaskDeleted, - isTeamTaskFinishedForDependency, + isTeamTaskBlockedByUnfinishedDependency, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; @@ -43,13 +42,5 @@ export function isTaskBlocked( task: TaskBlockInput, taskStateById: ReadonlyMap ): boolean { - const blockedBy = task.blockedBy?.filter((taskId) => taskId.length > 0) ?? []; - if (blockedBy.length === 0) { - return false; - } - - return blockedBy.some((taskId) => { - const blocker = taskStateById.get(taskId); - return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker)); - }); + return isTeamTaskBlockedByUnfinishedDependency(task, taskStateById); } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index a8dd435b..eed8ae5c 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -59,6 +59,29 @@ function parseTime(value: string | undefined): number | null { return Number.isFinite(time) ? time : null; } +function hasActiveAcceptedWorkLease(status: MemberWorkSyncStatus): boolean { + const report = status.report; + if ( + report?.accepted !== true || + report.agendaFingerprint !== status.agenda.fingerprint || + (report.state !== 'still_working' && report.state !== 'blocked') + ) { + return false; + } + + const evaluatedAtMs = parseTime(status.evaluatedAt); + const expiresAtMs = parseTime(report.expiresAt); + return evaluatedAtMs != null && expiresAtMs != null && expiresAtMs > evaluatedAtMs; +} + +function hasNoCurrentAcceptedWorkProof(status: MemberWorkSyncStatus): boolean { + return ( + status.diagnostics.includes('no_current_report') || + status.diagnostics.includes('report_lease_expired') || + status.diagnostics.includes('report_fingerprint_stale') + ); +} + function eventsForMember( status: MemberWorkSyncStatus, metrics: MemberWorkSyncTeamMetrics @@ -69,16 +92,6 @@ function eventsForMember( .sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)); } -function hasAcceptedReportForCurrentFingerprint( - status: MemberWorkSyncStatus, - metrics: MemberWorkSyncTeamMetrics -): boolean { - return eventsForMember(status, metrics).some( - (event) => - event.kind === 'report_accepted' && event.agendaFingerprint === status.agenda.fingerprint - ); -} - function isDifferentFingerprintBoundary( event: MemberWorkSyncMetricEvent, currentFingerprint: string @@ -104,11 +117,19 @@ function getCurrentFingerprintStableSinceMs( return recordedAt != null && recordedAt <= nowMs; }); let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY; + let latestAcceptedReportMs = Number.NEGATIVE_INFINITY; for (const event of memberEvents) { const recordedAt = parseTime(event.recordedAt); if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) { latestDifferentFingerprintMs = Math.max(latestDifferentFingerprintMs, recordedAt); } + if ( + recordedAt != null && + event.kind === 'report_accepted' && + event.agendaFingerprint === currentFingerprint + ) { + latestAcceptedReportMs = Math.max(latestAcceptedReportMs, recordedAt); + } } const currentNeedsSyncEventTimes = memberEvents.flatMap((event) => { @@ -117,7 +138,8 @@ function getCurrentFingerprintStableSinceMs( event.state === 'needs_sync' && event.agendaFingerprint === currentFingerprint && recordedAt != null && - recordedAt >= latestDifferentFingerprintMs + recordedAt >= latestDifferentFingerprintMs && + recordedAt > latestAcceptedReportMs ? [recordedAt] : []; }); @@ -133,12 +155,12 @@ function isNativeStaleInProgressCandidate(input: { if ( status.state !== 'needs_sync' || status.shadow?.wouldNudge !== true || - !status.diagnostics.includes('no_current_report') || + !hasNoCurrentAcceptedWorkProof(status) || !status.providerId || !NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) || isLeadLikeMemberName(status.memberName) || status.agenda.items.length !== 1 || - hasAcceptedReportForCurrentFingerprint(status, metrics) + hasActiveAcceptedWorkLease(status) ) { return false; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index f7cc6ffd..42b3d606 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -419,8 +419,9 @@ export class MemberWorkSyncNudgeDispatcher { inactive: source.inactive || !teamActive, }); const providerId = source.providerId ?? previous.providerId; + const { report: _previousReport, ...previousWithoutReport } = previous; const revalidatedStatus: MemberWorkSyncStatus = { - ...previous, + ...previousWithoutReport, state: decision.state, agenda, ...(decision.acceptedReport ? { report: decision.acceptedReport } : {}), diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index f40d80ff..dd457aef 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -126,8 +126,12 @@ export function buildActionableWorkAgenda( 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 blockedBy = [ + ...new Set((task.blockedBy ?? []).map((id) => id.trim()).filter(Boolean)), + ].sort(); + const blocks = [ + ...new Set((task.blocks ?? []).map((id) => id.trim()).filter(Boolean)), + ].sort(); const brokenDependencyIds: string[] = []; const waitingDependencyIds: string[] = []; for (const dependencyId of blockedBy) { diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts index 32af1164..b6f0fae7 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts @@ -50,6 +50,10 @@ function taskReferenceKeys(task: Pick): string[] { return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))]; } +function normalizedTaskReferences(values: readonly string[] | undefined): string[] { + return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))]; +} + function findLeadMemberName(activeMembers: string[]): string | null { return activeMembers.find((memberName) => isLeadMember({ name: memberName })) ?? null; } @@ -179,7 +183,7 @@ export class MemberWorkSyncTaskImpactResolver { taskReferenceKeys(candidate).map((key) => [key, candidate] as const) ) ); - const brokenDependencies = (task.blockedBy ?? []).filter((dependencyId) => { + const brokenDependencies = normalizedTaskReferences(task.blockedBy).filter((dependencyId) => { const dependency = tasksByReference.get(dependencyId); return !dependency || isDeletedTask(dependency); }); @@ -200,7 +204,7 @@ export class MemberWorkSyncTaskImpactResolver { continue; } if ( - (candidate.blockedBy ?? []).some( + normalizedTaskReferences(candidate.blockedBy).some( (dependencyId) => tasksByReference.get(dependencyId) === task ) ) { diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index b20bd774..d08919a9 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -65,6 +65,29 @@ import type { TeamChangeEvent } from '@shared/types'; const STALE_STATUS_MAX_AGE_MS = 2 * 60_000; const PROOF_MISSING_RECOVERY_RECENT_WINDOW_MS = 10 * 60_000; +function statusNeedsBackgroundRefresh(status: MemberWorkSyncStatus, nowMs: number): boolean { + if (status.agenda.items.length === 0) { + return false; + } + + const evaluatedAtMs = Date.parse(status.evaluatedAt); + if (!Number.isFinite(evaluatedAtMs)) { + return true; + } + + if (status.state === 'needs_sync' && nowMs - evaluatedAtMs > STALE_STATUS_MAX_AGE_MS) { + return true; + } + + const reportExpiresAtMs = Date.parse(status.report?.expiresAt ?? ''); + return ( + status.report?.accepted === true && + Number.isFinite(reportExpiresAtMs) && + reportExpiresAtMs <= nowMs && + (status.state === 'still_working' || status.state === 'blocked') + ); +} + function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] { const diagnostics: string[] = []; const evaluatedAtMs = Date.parse(status.evaluatedAt); @@ -290,14 +313,61 @@ export function createMemberWorkSyncFeature(deps: { ); return readiness.filter((item) => item.ready).map((item) => item.teamName); }; + const refreshBackgroundStaleStatuses = async (teamNames: string[]): Promise => { + const nowMs = clock.now().getTime(); + let refreshed = 0; + for (const teamName of teamNames) { + let memberNames: string[]; + try { + memberNames = await agendaSource.loadActiveMemberNames(teamName); + } catch (error) { + deps.logger?.warn('member work sync background refresh member scan failed', { + teamName, + error: String(error), + }); + continue; + } + + for (const memberName of memberNames) { + try { + const status = await store.read({ teamName, memberName }); + if (status && !statusNeedsBackgroundRefresh(status, nowMs)) { + continue; + } + await reconciler.execute( + { teamName, memberName }, + { + reconciledBy: 'queue', + triggerReasons: [status ? 'manual_refresh' : 'startup_scan'], + } + ); + refreshed += 1; + } catch (error) { + deps.logger?.warn('member work sync background refresh failed', { + teamName, + memberName, + error: String(error), + }); + } + } + } + + if (refreshed > 0) { + deps.logger?.debug('member work sync background stale refresh completed', { refreshed }); + } + }; const dispatchNudgesForReadyTeams = async ( teamNames: string[], - claimedBy: string + claimedBy: string, + options: { refreshBackgroundStaleStatuses?: boolean } = {} ): Promise => { const readyTeamNames = await filterNudgeDispatchReadyTeamNames(teamNames); if (readyTeamNames.length === 0) { return emptyNudgeDispatchSummary(); } + if (options.refreshBackgroundStaleStatuses !== false) { + await refreshBackgroundStaleStatuses(readyTeamNames); + } return nudgeDispatcher.dispatchDue({ teamNames: readyTeamNames, claimedBy, @@ -306,7 +376,9 @@ export function createMemberWorkSyncFeature(deps: { const queue = new MemberWorkSyncEventQueue({ reconcile: async (request, context: MemberWorkSyncReconcileContext) => { await reconciler.execute(request, context); - await dispatchNudgesForReadyTeams([request.teamName], `member-work-sync:${process.pid}`); + await dispatchNudgesForReadyTeams([request.teamName], `member-work-sync:${process.pid}`, { + refreshBackgroundStaleStatuses: false, + }); }, isTeamActive: deps.isTeamActive ?? (() => true), ...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}), diff --git a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts index 06caec45..8120f87b 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts @@ -1,3 +1,5 @@ +import { isTeamTaskBlockedByUnfinishedDependency } from '@shared/utils/teamTaskState'; + import { getOpenCodeWeakStartStallThresholdMs } from './featureGates'; import { classifyTaskProgressTouch, type TaskProgressSignal } from './TaskProgressSignalClassifier'; @@ -374,7 +376,7 @@ export class TeamTaskStallPolicy { if (task.reviewState === 'review') { return skip(task.id, 'Task is currently under review', 'review_active'); } - if (task.blockedBy?.length) { + if (isTeamTaskBlockedByUnfinishedDependency(task, snapshot.allTasksById)) { return skip(task.id, 'Task is blocked', 'task_blocked'); } if (task.needsClarification) { diff --git a/src/main/services/team/teamTaskActiveState.ts b/src/main/services/team/teamTaskActiveState.ts index 57fb67f4..d307dc05 100644 --- a/src/main/services/team/teamTaskActiveState.ts +++ b/src/main/services/team/teamTaskActiveState.ts @@ -5,6 +5,7 @@ import type { TeamTaskWithKanban } from '@shared/types'; export { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked, + isTeamTaskBlockedByUnfinishedDependency, isTeamTaskFinalForCompletionNotification, isTeamTaskFinishedForDependency, isTeamTaskNeedsFixActionable, diff --git a/src/shared/utils/teamTaskState.ts b/src/shared/utils/teamTaskState.ts index c5afb481..aa47133f 100644 --- a/src/shared/utils/teamTaskState.ts +++ b/src/shared/utils/teamTaskState.ts @@ -5,6 +5,10 @@ export interface TeamTaskStateLike { deletedAt?: string | null; } +export interface TeamTaskBlockerLike { + blockedBy?: string[] | null; +} + export type TeamTaskWorkflowColumn = 'review' | 'approved'; interface CachedTeamTaskState { @@ -122,6 +126,22 @@ export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolea return getCachedTeamTaskState(task).finishedForDependency; } +export function isTeamTaskBlockedByUnfinishedDependency( + task: TeamTaskBlockerLike, + taskStateById: ReadonlyMap +): boolean { + const blockedBy = + task.blockedBy?.map((taskId) => taskId.trim()).filter((taskId) => taskId.length > 0) ?? []; + if (blockedBy.length === 0) { + return false; + } + + return blockedBy.some((taskId) => { + const blocker = taskStateById.get(taskId); + return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker)); + }); +} + export function isTeamTaskTerminalForActionableWork(task: TeamTaskStateLike): boolean { return getCachedTeamTaskState(task).terminalForActionableWork; } diff --git a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts index be8fba02..1423f424 100644 --- a/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts +++ b/test/features/member-work-sync/core/ActionableWorkAgenda.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it } from 'vitest'; - import { buildActionableWorkAgenda } from '@features/member-work-sync/core/domain'; +import { describe, expect, it } from 'vitest'; const hash = (value: string) => `h${value.length}`; @@ -151,7 +150,7 @@ describe('buildActionableWorkAgenda', () => { subject: 'Depends on approved task', status: 'in_progress', owner: 'jack', - blockedBy: ['task-approved'], + blockedBy: [' task-approved '], }, ], hash, diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 66041ad4..4232ddde 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -17,11 +17,13 @@ import { describe, expect, it } from 'vitest'; import type { MemberWorkSyncActionableWorkItem, + MemberWorkSyncMetricEvent, MemberWorkSyncOutboxEnsureInput, MemberWorkSyncOutboxItem, MemberWorkSyncOutboxMarkDeliveredInput, MemberWorkSyncOutboxMarkFailedInput, MemberWorkSyncOutboxMarkSupersededInput, + MemberWorkSyncPhase2ReadinessReason, MemberWorkSyncPhase2ReadinessState, MemberWorkSyncReportIntent, MemberWorkSyncReportRequest, @@ -94,6 +96,9 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = []; readonly pendingIntents = new Map(); phase2ReadinessState: MemberWorkSyncPhase2ReadinessState = 'collecting_shadow_data'; + phase2ReadinessReasons: MemberWorkSyncPhase2ReadinessReason[] = []; + metricsGeneratedAt = '2026-04-29T00:00:00.000Z'; + recentEvents: MemberWorkSyncMetricEvent[] = []; async read(): Promise { return this.writes.at(-1) ?? null; @@ -129,7 +134,7 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { async readTeamMetrics(teamName: string): Promise { return { teamName, - generatedAt: '2026-04-29T00:00:00.000Z', + generatedAt: this.metricsGeneratedAt, memberCount: 1, stateCounts: { caught_up: 0, @@ -144,10 +149,10 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort { fingerprintChangeCount: 0, reportAcceptedCount: 0, reportRejectedCount: 0, - recentEvents: [], + recentEvents: this.recentEvents, phase2Readiness: { state: this.phase2ReadinessState, - reasons: [], + reasons: this.phase2ReadinessReasons, thresholds: { minObservedMembers: 1, minStatusEvents: 20, @@ -1286,6 +1291,85 @@ describe('MemberWorkSync use cases', () => { expect(revived).not.toHaveProperty('lastError'); }); + it('dispatches native stale recovery after an attached still_working report expires', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const inProgressItem: MemberWorkSyncActionableWorkItem = { + ...workItem, + reason: 'owned_in_progress_task', + evidence: { + status: 'in_progress', + owner: 'bob', + }, + }; + const { clock, deps, store } = createDeps({ + items: [inProgressItem], + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const reconciler = new MemberWorkSyncReconciler(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reconciler.execute( + { teamName: 'team-a', memberName: 'bob' }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, + taskIds: ['task-1'], + leaseTtlMs: 120_000, + source: 'test', + }); + + clock.set('2026-04-29T00:10:00.000Z'); + store.phase2ReadinessState = 'blocked'; + store.phase2ReadinessReasons = ['would_nudge_rate_high']; + store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z'; + store.recentEvents = [ + { + id: 'old-report-accepted', + teamName: 'team-a', + memberName: 'bob', + kind: 'report_accepted', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + recordedAt: '2026-04-29T00:01:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'needs-sync-after-lease-expired', + teamName: 'team-a', + memberName: 'bob', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: current.agenda.fingerprint, + recordedAt: '2026-04-29T00:04:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ]; + + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(summary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(1); + expect( + outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`) + ).toMatchObject({ + status: 'delivered', + }); + }); + it('rate-limits delivered nudges per member per hour', async () => { const outbox = new InMemoryOutboxStore(); const inbox = new InMemoryInboxNudge(); diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts index 7cb74f96..b992541a 100644 --- a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -458,6 +458,77 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => { ).toEqual({ active: false, reason: 'blocking_metrics' }); }); + it('activates stale native in-progress recovery when an old accepted report is followed by a new needs_sync window', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus(), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:10:01.000Z', + recentEvents: [ + { + id: 'old-report-accepted', + teamName: 'team-a', + memberName: 'alice', + kind: 'report_accepted', + state: 'still_working', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:01:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + { + id: 'needs-sync-after-old-report', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:04:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: true, reason: 'native_stale_in_progress' }); + }); + + it('activates stale native in-progress recovery when an attached accepted report is expired', () => { + expect( + decideMemberWorkSyncNudgeActivation({ + status: nativeStaleInProgressStatus({ + evaluatedAt: '2026-05-06T00:10:01.000Z', + diagnostics: ['report_lease_expired'], + report: { + accepted: true, + state: 'still_working', + agendaFingerprint: 'agenda:v1:native-stale', + memberName: 'alice', + teamName: 'team-a', + reportedAt: '2026-05-06T00:01:00.000Z', + expiresAt: '2026-05-06T00:02:00.000Z', + }, + }), + metrics: staleMetrics({ + generatedAt: '2026-05-06T00:10:01.000Z', + recentEvents: [ + { + id: 'needs-sync-after-expired-report', + teamName: 'team-a', + memberName: 'alice', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:native-stale', + recordedAt: '2026-05-06T00:04:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ], + }), + }) + ).toEqual({ active: true, reason: 'native_stale_in_progress' }); + }); + it('does not activate stale native in-progress recovery when the accepted report state is still current', () => { expect( decideMemberWorkSyncNudgeActivation({ diff --git a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts index 104faa6e..31b7319b 100644 --- a/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts +++ b/test/features/member-work-sync/main/MemberWorkSyncTaskImpactResolver.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from 'vitest'; - import { MemberWorkSyncTaskImpactResolver } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver'; +import { describe, expect, it, vi } from 'vitest'; import type { TeamTask } from '@shared/types'; @@ -112,7 +111,7 @@ describe('MemberWorkSyncTaskImpactResolver', () => { subject: 'Depends on display id', status: 'pending', owner: 'tom', - blockedBy: ['11111111'], + blockedBy: [' 11111111 '], }, ]; const resolver = new MemberWorkSyncTaskImpactResolver({ diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index d3a4df81..a9ae9b09 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -3153,6 +3153,157 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('refreshes an expired still_working lease during nudge dispatch without a status read', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Wake after lease expiry', + status: 'in_progress', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + canDispatchNudges: vi.fn(async () => true), + }); + + try { + await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + const current = await feature.refreshStatus({ teamName, memberName }); + expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); + + await expect( + feature.report({ + teamName, + memberName, + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, + taskIds: ['task-1'], + source: 'test', + }) + ).resolves.toMatchObject({ + accepted: true, + status: { state: 'still_working', report: { accepted: true } }, + }); + + const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)); + const acceptedStatus = await store.read({ teamName, memberName }); + expect(acceptedStatus?.report?.accepted).toBe(true); + const expiredReportedAt = new Date(Date.now() - 7 * 60_000).toISOString(); + const expiredAt = new Date(Date.now() - 6 * 60_000).toISOString(); + await store.write({ + ...acceptedStatus!, + evaluatedAt: expiredReportedAt, + report: { + ...acceptedStatus!.report!, + reportedAt: expiredReportedAt, + expiresAt: expiredAt, + }, + }); + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 1, + superseded: 0, + retryable: 0, + terminal: 0, + }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1); + } finally { + await feature.dispose(); + } + }); + + it('materializes a missing active-member status during nudge dispatch', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-a'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Wake after app restart', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + canDispatchNudges: vi.fn(async () => true), + }); + + try { + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 1, + superseded: 0, + retryable: 0, + terminal: 0, + }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1); + await expect( + new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)).read({ + teamName, + memberName, + }) + ).resolves.toMatchObject({ + state: 'needs_sync', + shadow: { triggerReasons: ['startup_scan'] }, + }); + } finally { + await feature.dispose(); + } + }); + it('uses snapshot config reads for startup roster materialization', async () => { const getConfig = vi.fn(async () => ({ members: [] })); const getConfigSnapshot = vi.fn(async () => ({ diff --git a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts index bde8a0b4..32a356e6 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts @@ -186,6 +186,138 @@ describe('TeamTaskStallPolicy', () => { }); }); + it.each([ + ['completed', { status: 'completed' }], + ['approved', { status: 'in_progress', reviewState: 'approved' }], + ['soft-deleted', { status: 'in_progress', deletedAt: '2026-04-19T12:05:00.000Z' }], + ] as const)('does not treat %s blockers as active stall blockers', (_label, blockerState) => { + const blocker: TeamTask = { + id: 'task-blocker', + displayId: 'block123', + subject: 'Finished dependency', + ...blockerState, + }; + const task: TeamTask = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + status: 'in_progress', + blockedBy: [` ${blocker.id} `], + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + }; + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([ + [task.id, task], + [blocker.id, blocker], + ]), + inProgressTasks: [task], + recordsByTaskId: new Map([[task.id, [createRecord()]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/session.jsonl', + [ + createExactRow({ + messageUuid: 'msg-touch', + toolUseIds: ['tool-1'], + }), + createExactRow({ + sourceOrder: 2, + messageUuid: 'msg-turn-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-turn-end', + type: 'system', + }), + }), + ], + ], + ]), + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:30:00.000Z'), + task, + snapshot, + }); + + expect(evaluation).toMatchObject({ + status: 'alert', + taskId: 'task-a', + branch: 'work', + signal: 'turn_ended_after_touch', + }); + }); + + it.each([ + ['in-progress', { status: 'in_progress' }], + ['completed in review', { status: 'completed', reviewState: 'review' }], + ] as const)('still skips work tasks with %s blockers', (_label, blockerState) => { + const blocker: TeamTask = { + id: 'task-blocker', + displayId: 'block123', + subject: 'Unfinished dependency', + ...blockerState, + }; + const task: TeamTask = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + status: 'in_progress', + blockedBy: [blocker.id], + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + }; + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:30:00.000Z'), + task, + snapshot: createSnapshot({ + activeTasks: [task, blocker], + allTasksById: new Map([ + [task.id, task], + [blocker.id, blocker], + ]), + inProgressTasks: [task, blocker], + }), + }); + + expect(evaluation).toMatchObject({ + status: 'skip', + taskId: 'task-a', + skipReason: 'task_blocked', + }); + }); + + it('keeps work tasks blocked when a blocker id cannot be resolved', () => { + const task: TeamTask = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + status: 'in_progress', + blockedBy: ['missing-blocker'], + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + }; + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:30:00.000Z'), + task, + snapshot: createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + }), + }); + + expect(evaluation).toMatchObject({ + status: 'skip', + taskId: 'task-a', + skipReason: 'task_blocked', + }); + }); + it.each([ ['turn_ended_after_touch', 4], ['touch_then_other_turns', 5], diff --git a/test/main/services/team/teamTaskActiveState.test.ts b/test/main/services/team/teamTaskActiveState.test.ts index 5f679705..e9752045 100644 --- a/test/main/services/team/teamTaskActiveState.test.ts +++ b/test/main/services/team/teamTaskActiveState.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { getTeamTaskWorkflowColumn, isTeamTaskActivelyWorked, + isTeamTaskBlockedByUnfinishedDependency, isTeamTaskFinalForCompletionNotification, isTeamTaskFinishedForDependency, isTeamTaskNeedsFixActionable, @@ -109,6 +110,40 @@ describe('isTeamTaskActivelyWorked', () => { }); }); +describe('isTeamTaskBlockedByUnfinishedDependency', () => { + it('uses dependency-finished semantics and trims persisted blocker ids', () => { + const taskStateById = new Map([ + ['completed', { status: 'completed' }], + ['approved', { status: 'in_progress', reviewState: 'approved' }], + ['soft-deleted', { status: 'in_progress', deletedAt: '2026-05-06T00:00:00.000Z' }], + ]); + + expect( + isTeamTaskBlockedByUnfinishedDependency( + { blockedBy: [' completed ', 'approved', 'soft-deleted'] }, + taskStateById + ) + ).toBe(false); + }); + + it('fails closed for missing or unfinished blockers', () => { + const taskStateById = new Map([ + ['in-progress', { status: 'in_progress' }], + ['completed-review', { status: 'completed', reviewState: 'review' }], + ]); + + expect( + isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['in-progress'] }, taskStateById) + ).toBe(true); + expect( + isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['completed-review'] }, taskStateById) + ).toBe(true); + expect( + isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['missing'] }, taskStateById) + ).toBe(true); + }); +}); + describe('getTeamTaskWorkflowColumn', () => { it('keeps stale in-progress approved overlay visible as approved', () => { expect( diff --git a/test/renderer/features/agent-graph/taskGraphSemantics.test.ts b/test/renderer/features/agent-graph/taskGraphSemantics.test.ts index 0e266a44..1a485b98 100644 --- a/test/renderer/features/agent-graph/taskGraphSemantics.test.ts +++ b/test/renderer/features/agent-graph/taskGraphSemantics.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it } from 'vitest'; - import { isTaskBlocked, resolveTaskGraphColumn, } from '@features/agent-graph/core/domain/taskGraphSemantics'; +import { describe, expect, it } from 'vitest'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -50,6 +49,7 @@ describe('taskGraphSemantics', () => { ]); expect(isTaskBlocked({ blockedBy: ['completed'] }, taskStateById)).toBe(false); + expect(isTaskBlocked({ blockedBy: [' completed '] }, taskStateById)).toBe(false); expect(isTaskBlocked({ blockedBy: ['soft-deleted'] }, taskStateById)).toBe(false); expect(isTaskBlocked({ blockedBy: ['review-approved'] }, taskStateById)).toBe(false); expect(isTaskBlocked({ blockedBy: ['kanban-approved'] }, taskStateById)).toBe(false);