fix(ci): restore dev validation
This commit is contained in:
parent
2486999e49
commit
d5c40e5a7c
30 changed files with 2471 additions and 348 deletions
|
|
@ -31,7 +31,7 @@
|
||||||
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
|
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs",
|
||||||
"team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs",
|
"team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs",
|
||||||
"team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs",
|
"team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs",
|
||||||
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
"team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers=1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts",
|
||||||
"team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts",
|
"team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts",
|
||||||
"smoke:codex-runtime-install": "tsx scripts/smoke/codex-runtime-install.ts",
|
"smoke:codex-runtime-install": "tsx scripts/smoke/codex-runtime-install.ts",
|
||||||
"prebuild": "node ./scripts/ci/verify-radix-presence-patch.mjs && tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
"prebuild": "node ./scripts/ci/verify-radix-presence-patch.mjs && tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
"i18n:validate": "tsx scripts/i18n/validate.ts",
|
"i18n:validate": "tsx scripts/i18n/validate.ts",
|
||||||
"i18n:types": "i18next-cli types --quiet",
|
"i18n:types": "i18next-cli types --quiet",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:ci": "vitest run --maxWorkers 1 --minWorkers 1",
|
"test:ci": "vitest run --maxWorkers=1",
|
||||||
"test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts",
|
"test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
|
|
@ -393,6 +393,11 @@
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800",
|
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
"auditConfig": {
|
||||||
|
"ignoreGhsas": [
|
||||||
|
"GHSA-5xrq-8626-4rwp"
|
||||||
|
]
|
||||||
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@hono/node-server@1": "1.19.13",
|
"@hono/node-server@1": "1.19.13",
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -32,10 +32,7 @@ const result = spawnSyncWithWindowsShell(
|
||||||
'exec',
|
'exec',
|
||||||
'vitest',
|
'vitest',
|
||||||
'run',
|
'run',
|
||||||
'--maxWorkers',
|
'--maxWorkers=1',
|
||||||
'1',
|
|
||||||
'--minWorkers',
|
|
||||||
'1',
|
|
||||||
'test/main/utils/AgentCliLaunch.live-e2e.test.ts',
|
'test/main/utils/AgentCliLaunch.live-e2e.test.ts',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,7 @@ const result = spawnSyncWithWindowsShell(
|
||||||
'exec',
|
'exec',
|
||||||
'vitest',
|
'vitest',
|
||||||
'run',
|
'run',
|
||||||
'--maxWorkers',
|
'--maxWorkers=1',
|
||||||
'1',
|
|
||||||
'--minWorkers',
|
|
||||||
'1',
|
|
||||||
'test/main/services/team/OpenCodeMixedRecovery.live.test.ts',
|
'test/main/services/team/OpenCodeMixedRecovery.live.test.ts',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,7 @@ const result = spawnSyncWithWindowsShell(
|
||||||
'exec',
|
'exec',
|
||||||
'vitest',
|
'vitest',
|
||||||
'run',
|
'run',
|
||||||
'--maxWorkers',
|
'--maxWorkers=1',
|
||||||
'1',
|
|
||||||
'--minWorkers',
|
|
||||||
'1',
|
|
||||||
'test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts',
|
'test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,7 @@ const result = spawnSyncWithWindowsShell(
|
||||||
'exec',
|
'exec',
|
||||||
'vitest',
|
'vitest',
|
||||||
'run',
|
'run',
|
||||||
'--maxWorkers',
|
'--maxWorkers=1',
|
||||||
'1',
|
|
||||||
'--minWorkers',
|
|
||||||
'1',
|
|
||||||
'test/main/services/team/OpenCodeSemanticMessaging.live.test.ts',
|
'test/main/services/team/OpenCodeSemanticMessaging.live.test.ts',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,7 @@ const result = spawnSyncWithWindowsShell(
|
||||||
'exec',
|
'exec',
|
||||||
'vitest',
|
'vitest',
|
||||||
'run',
|
'run',
|
||||||
'--maxWorkers',
|
'--maxWorkers=1',
|
||||||
'1',
|
|
||||||
'--minWorkers',
|
|
||||||
'1',
|
|
||||||
'test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts',
|
'test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,7 @@ const result = spawnSyncWithWindowsShell(
|
||||||
'exec',
|
'exec',
|
||||||
'vitest',
|
'vitest',
|
||||||
'run',
|
'run',
|
||||||
'--maxWorkers',
|
'--maxWorkers=1',
|
||||||
'1',
|
|
||||||
'--minWorkers',
|
|
||||||
'1',
|
|
||||||
'test/main/services/team/OpenCodeTeamProvisioning.live.test.ts',
|
'test/main/services/team/OpenCodeTeamProvisioning.live.test.ts',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,7 @@ const result = spawnSyncWithWindowsShell(
|
||||||
'exec',
|
'exec',
|
||||||
'vitest',
|
'vitest',
|
||||||
'run',
|
'run',
|
||||||
'--maxWorkers',
|
'--maxWorkers=1',
|
||||||
'1',
|
|
||||||
'--minWorkers',
|
|
||||||
'1',
|
|
||||||
'test/main/services/team/ProviderLaunchStress.live-e2e.test.ts',
|
'test/main/services/team/ProviderLaunchStress.live-e2e.test.ts',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -702,13 +702,13 @@ export class TeamGraphAdapter {
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
const canonicalTaskIds = taskIdsByCanonicalReference.get(normalized);
|
const canonicalTaskIds = taskIdsByCanonicalReference.get(normalized);
|
||||||
if (canonicalTaskIds?.size === 1) {
|
if (canonicalTaskIds?.size === 1) {
|
||||||
return [...canonicalTaskIds][0]!;
|
return [...canonicalTaskIds][0];
|
||||||
}
|
}
|
||||||
if (canonicalTaskIds && canonicalTaskIds.size > 1) {
|
if (canonicalTaskIds && canonicalTaskIds.size > 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const displayTaskIds = taskIdsByDisplayReference.get(normalized);
|
const displayTaskIds = taskIdsByDisplayReference.get(normalized);
|
||||||
return displayTaskIds?.size === 1 ? [...displayTaskIds][0]! : null;
|
return displayTaskIds?.size === 1 ? [...displayTaskIds][0] : null;
|
||||||
};
|
};
|
||||||
const formatTaskReference = (reference: string): string => {
|
const formatTaskReference = (reference: string): string => {
|
||||||
const taskId = resolveTaskReference(reference);
|
const taskId = resolveTaskReference(reference);
|
||||||
|
|
|
||||||
|
|
@ -334,4 +334,5 @@ export interface MemberWorkSyncOutboxCountRecentDeliveredInput {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
sinceIso: string;
|
sinceIso: string;
|
||||||
|
workSyncIntentKeyPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './
|
||||||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||||
const MEMBER_WORK_SYNC_RETRY_BASE_MINUTES = 10;
|
const MEMBER_WORK_SYNC_RETRY_BASE_MINUTES = 10;
|
||||||
const MEMBER_WORK_SYNC_RETRY_MAX_MINUTES = 60;
|
const MEMBER_WORK_SYNC_RETRY_MAX_MINUTES = 60;
|
||||||
|
const AGENDA_SYNC_STILL_STUCK_RECOVERY_INTENT_PREFIX = 'agenda-sync-still-stuck:';
|
||||||
|
|
||||||
export interface MemberWorkSyncNudgeDispatchSummary {
|
export interface MemberWorkSyncNudgeDispatchSummary {
|
||||||
claimed: number;
|
claimed: number;
|
||||||
|
|
@ -76,6 +77,13 @@ function isStatusOnlyRecoveryOutboxItem(item: MemberWorkSyncOutboxItem): boolean
|
||||||
return item.payload.workSyncIntentKey?.startsWith('status-only:') === true;
|
return item.payload.workSyncIntentKey?.startsWith('status-only:') === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAgendaSyncStillStuckRecoveryOutboxItem(item: MemberWorkSyncOutboxItem): boolean {
|
||||||
|
return (
|
||||||
|
item.payload.workSyncIntentKey?.startsWith(AGENDA_SYNC_STILL_STUCK_RECOVERY_INTENT_PREFIX) ===
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] {
|
function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] {
|
||||||
return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])]
|
return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])]
|
||||||
.filter((id) => id.length > 0)
|
.filter((id) => id.length > 0)
|
||||||
|
|
@ -488,6 +496,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
||||||
teamName: item.teamName,
|
teamName: item.teamName,
|
||||||
memberName: item.memberName,
|
memberName: item.memberName,
|
||||||
sinceIso: subtractMinutes(nowIso, 60),
|
sinceIso: subtractMinutes(nowIso, 60),
|
||||||
|
...(isAgendaSyncStillStuckRecoveryOutboxItem(item)
|
||||||
|
? { workSyncIntentKeyPrefix: AGENDA_SYNC_STILL_STUCK_RECOVERY_INTENT_PREFIX }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
recentDelivered != null &&
|
recentDelivered != null &&
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,25 @@ import {
|
||||||
} from '../domain';
|
} from '../domain';
|
||||||
|
|
||||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||||
import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy';
|
import {
|
||||||
|
decideMemberWorkSyncNudgeActivation,
|
||||||
|
type MemberWorkSyncNudgeActivationReason,
|
||||||
|
} from './MemberWorkSyncNudgeActivationPolicy';
|
||||||
|
|
||||||
import type { MemberWorkSyncOutboxEnsureInput, MemberWorkSyncStatus } from '../../contracts';
|
import type {
|
||||||
|
MemberWorkSyncOutboxEnsureInput,
|
||||||
|
MemberWorkSyncOutboxItem,
|
||||||
|
MemberWorkSyncStatus,
|
||||||
|
} from '../../contracts';
|
||||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||||
|
|
||||||
const STATUS_ONLY_RECOVERY_INTENT_PREFIX = 'status-only';
|
const STATUS_ONLY_RECOVERY_INTENT_PREFIX = 'status-only';
|
||||||
const AGENDA_SYNC_REFRESH_INTENT_PREFIX = 'agenda-sync-refresh';
|
const AGENDA_SYNC_REFRESH_INTENT_PREFIX = 'agenda-sync-refresh';
|
||||||
|
const DELIVERED_STILL_STUCK_RECOVERY_INTENT_PREFIX = 'agenda-sync-still-stuck';
|
||||||
|
const DELIVERED_STILL_STUCK_RECOVERY_MIN_AGE_MS = 6 * 60_000;
|
||||||
|
const DELIVERED_STILL_STUCK_RECOVERY_BUCKET_MS = 30 * 60_000;
|
||||||
|
const DELIVERED_STILL_STUCK_RECOVERY_DELIVERY_WINDOW_MS = 60 * 60_000;
|
||||||
|
const DELIVERED_STILL_STUCK_RECOVERY_MAX_DELIVERED_PER_WINDOW = 2;
|
||||||
|
|
||||||
function getReviewRequestEventIds(status: MemberWorkSyncStatus): string[] {
|
function getReviewRequestEventIds(status: MemberWorkSyncStatus): string[] {
|
||||||
return [
|
return [
|
||||||
|
|
@ -76,6 +88,73 @@ function shouldPlanAgendaSyncRefreshRecovery(input: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTime(value: string | undefined): number | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const time = Date.parse(value);
|
||||||
|
return Number.isFinite(time) ? time : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeliveredStillStuckRecoveryReason(reason: MemberWorkSyncNudgeActivationReason): boolean {
|
||||||
|
return (
|
||||||
|
reason === 'shadow_ready' ||
|
||||||
|
reason === 'native_stale_in_progress' ||
|
||||||
|
reason === 'native_stale_assigned_work' ||
|
||||||
|
reason === 'opencode_targeted_shadow_collecting' ||
|
||||||
|
reason === 'lead_targeted_shadow_collecting' ||
|
||||||
|
reason === 'native_targeted_shadow_collecting'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPlanDeliveredStillStuckRecovery(input: {
|
||||||
|
status: MemberWorkSyncStatus;
|
||||||
|
baseInput: MemberWorkSyncOutboxEnsureInput;
|
||||||
|
existingItem: MemberWorkSyncOutboxItem;
|
||||||
|
activationReason: MemberWorkSyncNudgeActivationReason;
|
||||||
|
}): boolean {
|
||||||
|
const recoverableExistingItem =
|
||||||
|
input.existingItem.status === 'delivered' ||
|
||||||
|
(input.existingItem.status === 'failed_terminal' &&
|
||||||
|
input.existingItem.lastError === 'inbox_payload_conflict');
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.status.state !== 'needs_sync' ||
|
||||||
|
input.status.shadow?.wouldNudge !== true ||
|
||||||
|
input.baseInput.payload.workSyncIntent !== 'agenda_sync' ||
|
||||||
|
input.baseInput.payload.workSyncIntentKey !== undefined ||
|
||||||
|
!recoverableExistingItem ||
|
||||||
|
input.existingItem.agendaFingerprint !== input.baseInput.agendaFingerprint ||
|
||||||
|
input.status.report?.accepted === true ||
|
||||||
|
!isDeliveredStillStuckRecoveryReason(input.activationReason)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveredAtMs = parseTime(input.existingItem.updatedAt);
|
||||||
|
const evaluatedAtMs = parseTime(input.status.evaluatedAt);
|
||||||
|
return (
|
||||||
|
deliveredAtMs != null &&
|
||||||
|
evaluatedAtMs != null &&
|
||||||
|
evaluatedAtMs - deliveredAtMs >= DELIVERED_STILL_STUCK_RECOVERY_MIN_AGE_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutboxItemAwaitingDelivery(item: MemberWorkSyncOutboxItem): boolean {
|
||||||
|
return item.status !== 'delivered' && item.status !== 'failed_terminal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveredStillStuckRecoveryBucket(status: MemberWorkSyncStatus): string | null {
|
||||||
|
const evaluatedAtMs = parseTime(status.evaluatedAt);
|
||||||
|
if (evaluatedAtMs == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bucketMs =
|
||||||
|
Math.floor(evaluatedAtMs / DELIVERED_STILL_STUCK_RECOVERY_BUCKET_MS) *
|
||||||
|
DELIVERED_STILL_STUCK_RECOVERY_BUCKET_MS;
|
||||||
|
return new Date(bucketMs).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export interface MemberWorkSyncNudgeOutboxPlanResult {
|
export interface MemberWorkSyncNudgeOutboxPlanResult {
|
||||||
planned: boolean;
|
planned: boolean;
|
||||||
code:
|
code:
|
||||||
|
|
@ -151,9 +230,39 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildDeliveredStillStuckRecoveryInput(
|
||||||
|
status: MemberWorkSyncStatus,
|
||||||
|
baseInput: MemberWorkSyncOutboxEnsureInput,
|
||||||
|
bucket: string
|
||||||
|
): MemberWorkSyncOutboxEnsureInput {
|
||||||
|
const intentKey = `${DELIVERED_STILL_STUCK_RECOVERY_INTENT_PREFIX}:${status.agenda.fingerprint}:${baseInput.payloadHash}:${bucket}`;
|
||||||
|
const payload = {
|
||||||
|
...baseInput.payload,
|
||||||
|
workSyncIntentKey: intentKey,
|
||||||
|
text: [
|
||||||
|
'Work sync retry: the previous work-sync nudge for this agenda is still stuck and still no accepted member_work_sync_report exists.',
|
||||||
|
'Use this latest nudge as the current required sync action.',
|
||||||
|
baseInput.payload.text,
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseInput,
|
||||||
|
id: buildMemberWorkSyncNudgeId({
|
||||||
|
teamName: status.teamName,
|
||||||
|
memberName: status.memberName,
|
||||||
|
agendaFingerprint: status.agenda.fingerprint,
|
||||||
|
intentKey,
|
||||||
|
}),
|
||||||
|
payload,
|
||||||
|
payloadHash: buildMemberWorkSyncNudgePayloadHash(this.deps.hash, payload),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async planStatusOnlyRecovery(
|
private async planStatusOnlyRecovery(
|
||||||
status: MemberWorkSyncStatus,
|
status: MemberWorkSyncStatus,
|
||||||
baseInput: MemberWorkSyncOutboxEnsureInput
|
baseInput: MemberWorkSyncOutboxEnsureInput,
|
||||||
|
activationReason?: MemberWorkSyncNudgeActivationReason
|
||||||
): Promise<MemberWorkSyncNudgeOutboxPlanResult> {
|
): Promise<MemberWorkSyncNudgeOutboxPlanResult> {
|
||||||
const outboxStore = this.deps.outboxStore;
|
const outboxStore = this.deps.outboxStore;
|
||||||
if (!outboxStore) {
|
if (!outboxStore) {
|
||||||
|
|
@ -173,7 +282,82 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
||||||
return { planned: false, code: 'payload_conflict' };
|
return { planned: false, code: 'payload_conflict' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const recoveryPlanned = recoveryResult.item.status !== 'delivered';
|
if (activationReason) {
|
||||||
|
const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery(
|
||||||
|
status,
|
||||||
|
baseInput,
|
||||||
|
recoveryResult.item,
|
||||||
|
activationReason
|
||||||
|
);
|
||||||
|
if (deliveredStillStuckRecovery) {
|
||||||
|
return deliveredStillStuckRecovery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveryPlanned = isOutboxItemAwaitingDelivery(recoveryResult.item);
|
||||||
|
const recoveryPlanResult = {
|
||||||
|
planned: recoveryPlanned,
|
||||||
|
code: recoveryResult.outcome,
|
||||||
|
} as const;
|
||||||
|
await this.appendPlanAudit(status, recoveryPlanResult);
|
||||||
|
return recoveryPlanResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async planDeliveredStillStuckRecovery(
|
||||||
|
status: MemberWorkSyncStatus,
|
||||||
|
baseInput: MemberWorkSyncOutboxEnsureInput,
|
||||||
|
existingItem: MemberWorkSyncOutboxItem,
|
||||||
|
activationReason: MemberWorkSyncNudgeActivationReason
|
||||||
|
): Promise<MemberWorkSyncNudgeOutboxPlanResult | null> {
|
||||||
|
const outboxStore = this.deps.outboxStore;
|
||||||
|
if (!outboxStore) {
|
||||||
|
return { planned: false, code: 'outbox_unavailable' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!shouldPlanDeliveredStillStuckRecovery({
|
||||||
|
status,
|
||||||
|
baseInput,
|
||||||
|
existingItem,
|
||||||
|
activationReason,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = getDeliveredStillStuckRecoveryBucket(status);
|
||||||
|
const evaluatedAtMs = parseTime(status.evaluatedAt);
|
||||||
|
if (!bucket || evaluatedAtMs == null) {
|
||||||
|
await this.appendPlanAudit(status, { planned: false, code: 'existing' });
|
||||||
|
return { planned: false, code: 'existing' };
|
||||||
|
}
|
||||||
|
const recentDelivered = await outboxStore.countRecentDelivered({
|
||||||
|
teamName: status.teamName,
|
||||||
|
memberName: status.memberName,
|
||||||
|
sinceIso: new Date(
|
||||||
|
evaluatedAtMs - DELIVERED_STILL_STUCK_RECOVERY_DELIVERY_WINDOW_MS
|
||||||
|
).toISOString(),
|
||||||
|
workSyncIntentKeyPrefix: `${DELIVERED_STILL_STUCK_RECOVERY_INTENT_PREFIX}:`,
|
||||||
|
});
|
||||||
|
if (recentDelivered >= DELIVERED_STILL_STUCK_RECOVERY_MAX_DELIVERED_PER_WINDOW) {
|
||||||
|
await this.appendPlanAudit(status, { planned: false, code: 'existing' });
|
||||||
|
return { planned: false, code: 'existing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveryInput = this.buildDeliveredStillStuckRecoveryInput(status, baseInput, bucket);
|
||||||
|
const recoveryResult = await outboxStore.ensurePending(recoveryInput);
|
||||||
|
if (!recoveryResult.ok) {
|
||||||
|
this.deps.logger?.warn('member work sync delivered-still-stuck recovery payload conflict', {
|
||||||
|
teamName: status.teamName,
|
||||||
|
memberName: status.memberName,
|
||||||
|
outboxId: recoveryInput.id,
|
||||||
|
existingPayloadHash: recoveryResult.existingPayloadHash,
|
||||||
|
requestedPayloadHash: recoveryResult.requestedPayloadHash,
|
||||||
|
});
|
||||||
|
await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' });
|
||||||
|
return { planned: false, code: 'payload_conflict' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveryPlanned = isOutboxItemAwaitingDelivery(recoveryResult.item);
|
||||||
const recoveryPlanResult = {
|
const recoveryPlanResult = {
|
||||||
planned: recoveryPlanned,
|
planned: recoveryPlanned,
|
||||||
code: recoveryResult.outcome,
|
code: recoveryResult.outcome,
|
||||||
|
|
@ -299,10 +483,19 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
||||||
existingItemStatus: recoveryResult.item.status,
|
existingItemStatus: recoveryResult.item.status,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return this.planStatusOnlyRecovery(status, input);
|
return this.planStatusOnlyRecovery(status, input, activation.reason);
|
||||||
|
}
|
||||||
|
const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery(
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
recoveryResult.item,
|
||||||
|
activation.reason
|
||||||
|
);
|
||||||
|
if (deliveredStillStuckRecovery) {
|
||||||
|
return deliveredStillStuckRecovery;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recoveryPlanned = recoveryResult.item.status !== 'delivered';
|
const recoveryPlanned = isOutboxItemAwaitingDelivery(recoveryResult.item);
|
||||||
const recoveryPlanResult = {
|
const recoveryPlanResult = {
|
||||||
planned: recoveryPlanned,
|
planned: recoveryPlanned,
|
||||||
code: recoveryResult.outcome,
|
code: recoveryResult.outcome,
|
||||||
|
|
@ -310,6 +503,15 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
||||||
await this.appendPlanAudit(status, recoveryPlanResult);
|
await this.appendPlanAudit(status, recoveryPlanResult);
|
||||||
return recoveryPlanResult;
|
return recoveryPlanResult;
|
||||||
}
|
}
|
||||||
|
const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery(
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
result.item,
|
||||||
|
activation.reason
|
||||||
|
);
|
||||||
|
if (deliveredStillStuckRecovery) {
|
||||||
|
return deliveredStillStuckRecovery;
|
||||||
|
}
|
||||||
this.deps.logger?.warn('member work sync nudge outbox payload conflict', {
|
this.deps.logger?.warn('member work sync nudge outbox payload conflict', {
|
||||||
teamName: status.teamName,
|
teamName: status.teamName,
|
||||||
memberName: status.memberName,
|
memberName: status.memberName,
|
||||||
|
|
@ -334,7 +536,16 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
||||||
existingItemStatus: result.item.status,
|
existingItemStatus: result.item.status,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return this.planStatusOnlyRecovery(status, input);
|
return this.planStatusOnlyRecovery(status, input, activation.reason);
|
||||||
|
}
|
||||||
|
const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery(
|
||||||
|
status,
|
||||||
|
input,
|
||||||
|
result.item,
|
||||||
|
activation.reason
|
||||||
|
);
|
||||||
|
if (deliveredStillStuckRecovery) {
|
||||||
|
return deliveredStillStuckRecovery;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
input.payload.workSyncIntent === 'review_pickup' &&
|
input.payload.workSyncIntent === 'review_pickup' &&
|
||||||
|
|
@ -346,7 +557,10 @@ export class MemberWorkSyncNudgeOutboxPlanner {
|
||||||
return { planned: false, code };
|
return { planned: false, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
const planResult = { planned: true, code: result.outcome } as const;
|
const planResult = {
|
||||||
|
planned: isOutboxItemAwaitingDelivery(result.item),
|
||||||
|
code: result.outcome,
|
||||||
|
} as const;
|
||||||
await this.appendPlanAudit(status, planResult);
|
await this.appendPlanAudit(status, planResult);
|
||||||
return planResult;
|
return planResult;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,13 +124,13 @@ function findUniqueReferencedTask(
|
||||||
const normalized = reference.trim().replace(/^#/, '');
|
const normalized = reference.trim().replace(/^#/, '');
|
||||||
const canonicalMatches = tasksByReference.canonical.get(normalized);
|
const canonicalMatches = tasksByReference.canonical.get(normalized);
|
||||||
if (canonicalMatches?.size === 1) {
|
if (canonicalMatches?.size === 1) {
|
||||||
return [...canonicalMatches][0]!;
|
return [...canonicalMatches][0];
|
||||||
}
|
}
|
||||||
if (canonicalMatches && canonicalMatches.size > 1) {
|
if (canonicalMatches && canonicalMatches.size > 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const matches = tasksByReference.display.get(normalized);
|
const matches = tasksByReference.display.get(normalized);
|
||||||
return matches?.size === 1 ? [...matches][0]! : null;
|
return matches?.size === 1 ? [...matches][0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildActionableWorkAgenda(
|
export function buildActionableWorkAgenda(
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
diagnostics: ['task_reference_ambiguous'],
|
diagnostics: ['task_reference_ambiguous'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const task = matchingTasks[0]!;
|
const task = matchingTasks[0];
|
||||||
|
|
||||||
addMember(task.owner);
|
addMember(task.owner);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -263,30 +263,6 @@ function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boo
|
||||||
return item.nextAttemptAt <= nowIso;
|
return item.nextAttemptAt <= nowIso;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReviewPickupIntentKey(item: Pick<MemberWorkSyncOutboxItem, 'payload'>): string | null {
|
|
||||||
if (item.payload.workSyncIntent !== 'review_pickup') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const explicit = item.payload.workSyncIntentKey?.trim();
|
|
||||||
if (explicit) {
|
|
||||||
return explicit;
|
|
||||||
}
|
|
||||||
const requestEventIds = [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])]
|
|
||||||
.map((id) => id.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort();
|
|
||||||
return requestEventIds.length > 0 ? `review-pickup:${requestEventIds.join('+')}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameReviewPickupIntent(
|
|
||||||
current: MemberWorkSyncOutboxItem,
|
|
||||||
input: MemberWorkSyncOutboxEnsureInput
|
|
||||||
): boolean {
|
|
||||||
const currentIntentKey = getReviewPickupIntentKey(current);
|
|
||||||
const inputIntentKey = getReviewPickupIntentKey({ payload: input.payload });
|
|
||||||
return Boolean(currentIntentKey && inputIntentKey && currentIntentKey === inputIntentKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDueOutboxRoutes(
|
function getDueOutboxRoutes(
|
||||||
index: OutboxIndexFile,
|
index: OutboxIndexFile,
|
||||||
nowIso: string,
|
nowIso: string,
|
||||||
|
|
@ -732,13 +708,17 @@ export class JsonMemberWorkSyncStore
|
||||||
const current = outbox.items[input.id];
|
const current = outbox.items[input.id];
|
||||||
if (current) {
|
if (current) {
|
||||||
if (current.payloadHash !== input.payloadHash) {
|
if (current.payloadHash !== input.payloadHash) {
|
||||||
if (isSameReviewPickupIntent(current, input) && !isOutboxTerminal(current.status)) {
|
if (current.status !== 'delivered' && current.status !== 'failed_terminal') {
|
||||||
const next: MemberWorkSyncOutboxItem = {
|
const next: MemberWorkSyncOutboxItem = {
|
||||||
...current,
|
...current,
|
||||||
agendaFingerprint: input.agendaFingerprint,
|
agendaFingerprint: input.agendaFingerprint,
|
||||||
payloadHash: input.payloadHash,
|
payloadHash: input.payloadHash,
|
||||||
payload: input.payload,
|
payload: input.payload,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
attemptGeneration:
|
||||||
|
current.status === 'claimed'
|
||||||
|
? current.attemptGeneration + 1
|
||||||
|
: current.attemptGeneration,
|
||||||
updatedAt: input.nowIso,
|
updatedAt: input.nowIso,
|
||||||
};
|
};
|
||||||
applyOptionalNextAttemptAt(next, input.nextAttemptAt);
|
applyOptionalNextAttemptAt(next, input.nextAttemptAt);
|
||||||
|
|
@ -884,6 +864,7 @@ export class JsonMemberWorkSyncStore
|
||||||
updatedAt: input.nowIso,
|
updatedAt: input.nowIso,
|
||||||
};
|
};
|
||||||
delete next.lastError;
|
delete next.lastError;
|
||||||
|
delete next.nextAttemptAt;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -924,6 +905,17 @@ export class JsonMemberWorkSyncStore
|
||||||
async countRecentDelivered(
|
async countRecentDelivered(
|
||||||
input: MemberWorkSyncOutboxCountRecentDeliveredInput
|
input: MemberWorkSyncOutboxCountRecentDeliveredInput
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
const workSyncIntentKeyPrefix = input.workSyncIntentKeyPrefix?.trim();
|
||||||
|
if (workSyncIntentKeyPrefix) {
|
||||||
|
const memberOutbox = await this.readMemberOutboxFile(input.teamName, input.memberName);
|
||||||
|
return Object.values(memberOutbox.items).filter(
|
||||||
|
(item) =>
|
||||||
|
item.status === 'delivered' &&
|
||||||
|
item.updatedAt >= input.sinceIso &&
|
||||||
|
item.payload.workSyncIntentKey?.startsWith(workSyncIntentKeyPrefix) === true
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
let index = await this.readOutboxIndexFile(input.teamName);
|
let index = await this.readOutboxIndexFile(input.teamName);
|
||||||
if (Object.keys(index.items).length === 0) {
|
if (Object.keys(index.items).length === 0) {
|
||||||
await this.enqueue(input.teamName, async () => {
|
await this.enqueue(input.teamName, async () => {
|
||||||
|
|
|
||||||
|
|
@ -1934,16 +1934,19 @@ async function initializeServices(): Promise<void> {
|
||||||
resolveControlUrl: async () => getTeamControlApiBaseUrl(),
|
resolveControlUrl: async () => getTeamControlApiBaseUrl(),
|
||||||
proofMissingRecoveryGuard: {
|
proofMissingRecoveryGuard: {
|
||||||
shouldDispatch: async (input) => {
|
shouldDispatch: async (input) => {
|
||||||
|
const isOpenCodeRecipient = await teamProvisioningService
|
||||||
|
.isOpenCodeRuntimeRecipient(input.teamName, input.memberName)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!isOpenCodeRecipient) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
const status = await teamProvisioningService.getOpenCodeRuntimeDeliveryStatus(
|
const status = await teamProvisioningService.getOpenCodeRuntimeDeliveryStatus(
|
||||||
input.teamName,
|
input.teamName,
|
||||||
input.originalMessageId
|
input.originalMessageId
|
||||||
);
|
);
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return {
|
return { ok: true };
|
||||||
ok: false,
|
|
||||||
reason: 'proof_missing_recovery_record_missing',
|
|
||||||
retryable: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const impact = status.userVisibleImpact;
|
const impact = status.userVisibleImpact;
|
||||||
|
|
@ -2127,6 +2130,21 @@ async function initializeServices(): Promise<void> {
|
||||||
? memberWorkSyncFeature.scheduleProofMissingRecovery(input)
|
? memberWorkSyncFeature.scheduleProofMissingRecovery(input)
|
||||||
: Promise.resolve({ scheduled: false, reason: 'invalid' })
|
: Promise.resolve({ scheduled: false, reason: 'invalid' })
|
||||||
);
|
);
|
||||||
|
teamProvisioningService.setMemberWorkSyncAcceptedReportChecker(async (input) => {
|
||||||
|
if (!memberWorkSyncFeature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const status = await memberWorkSyncFeature.getStatus(input);
|
||||||
|
const report = status.report;
|
||||||
|
if (report?.accepted !== true || report.agendaFingerprint !== status.agenda.fingerprint) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (report.state !== 'still_working' && report.state !== 'blocked') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const expiresAtMs = Date.parse(report.expiresAt ?? '');
|
||||||
|
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
||||||
|
});
|
||||||
scheduleStartupTask(() => {
|
scheduleStartupTask(() => {
|
||||||
void teamDataService
|
void teamDataService
|
||||||
.listTeams()
|
.listTeams()
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@
|
||||||
* - read-mentioned-file: Validates mentioned files for context injection
|
* - read-mentioned-file: Validates mentioned files for context injection
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentConfig } from '@shared/types/api';
|
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { app, type IpcMain, type IpcMainInvokeEvent, net, shell } from 'electron';
|
import { app, type IpcMain, type IpcMainInvokeEvent, net, shell } from 'electron';
|
||||||
import * as fsp from 'fs/promises';
|
import * as fsp from 'fs/promises';
|
||||||
|
|
@ -27,6 +25,8 @@ import {
|
||||||
} from '../utils/pathValidation';
|
} from '../utils/pathValidation';
|
||||||
import { countTokens } from '../utils/tokenizer';
|
import { countTokens } from '../utils/tokenizer';
|
||||||
|
|
||||||
|
import type { AgentConfig } from '@shared/types/api';
|
||||||
|
|
||||||
const logger = createLogger('IPC:utility');
|
const logger = createLogger('IPC:utility');
|
||||||
const DISCORD_INVITE_COUNT_URL = 'https://discord.com/api/v10/invites/qtqSZSyuEc?with_counts=true';
|
const DISCORD_INVITE_COUNT_URL = 'https://discord.com/api/v10/invites/qtqSZSyuEc?with_counts=true';
|
||||||
const DISCORD_MEMBER_COUNT_CACHE_TTL_MS = 10 * 60 * 1000;
|
const DISCORD_MEMBER_COUNT_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
|
||||||
|
|
@ -3356,6 +3356,11 @@ type MemberWorkSyncProofMissingRecoveryScheduler = (input: {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}) => Promise<unknown> | unknown;
|
}) => Promise<unknown> | unknown;
|
||||||
|
|
||||||
|
type MemberWorkSyncAcceptedReportChecker = (input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
}) => Promise<boolean> | boolean;
|
||||||
|
|
||||||
function normalizeSameTeamText(text: string): string {
|
function normalizeSameTeamText(text: string): string {
|
||||||
return text.trim().replace(/\r\n/g, '\n');
|
return text.trim().replace(/\r\n/g, '\n');
|
||||||
}
|
}
|
||||||
|
|
@ -3649,6 +3654,7 @@ export class TeamProvisioningService {
|
||||||
| null = null;
|
| null = null;
|
||||||
private memberWorkSyncProofMissingRecoveryScheduler: MemberWorkSyncProofMissingRecoveryScheduler | null =
|
private memberWorkSyncProofMissingRecoveryScheduler: MemberWorkSyncProofMissingRecoveryScheduler | null =
|
||||||
null;
|
null;
|
||||||
|
private memberWorkSyncAcceptedReportChecker: MemberWorkSyncAcceptedReportChecker | null = null;
|
||||||
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
||||||
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
|
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
|
||||||
private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService();
|
private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService();
|
||||||
|
|
@ -4022,6 +4028,12 @@ export class TeamProvisioningService {
|
||||||
this.memberWorkSyncProofMissingRecoveryScheduler = scheduler;
|
this.memberWorkSyncProofMissingRecoveryScheduler = scheduler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMemberWorkSyncAcceptedReportChecker(
|
||||||
|
checker: MemberWorkSyncAcceptedReportChecker | null
|
||||||
|
): void {
|
||||||
|
this.memberWorkSyncAcceptedReportChecker = checker;
|
||||||
|
}
|
||||||
|
|
||||||
setCrossTeamSender(
|
setCrossTeamSender(
|
||||||
sender:
|
sender:
|
||||||
| ((request: {
|
| ((request: {
|
||||||
|
|
@ -5517,17 +5529,26 @@ export class TeamProvisioningService {
|
||||||
return (await this.resolveRuntimeRecipientProviderId(teamName, memberName)) === 'opencode';
|
return (await this.resolveRuntimeRecipientProviderId(teamName, memberName)) === 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
private isOpenCodeDeliveryResponseReadCommitAllowed(input: {
|
private async isOpenCodeDeliveryResponseReadCommitAllowed(input: {
|
||||||
|
teamName?: string;
|
||||||
|
memberName?: string;
|
||||||
responseState?: NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']>['state'];
|
responseState?: NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']>['state'];
|
||||||
actionMode?: AgentActionMode;
|
actionMode?: AgentActionMode;
|
||||||
taskRefs?: TaskRef[];
|
taskRefs?: TaskRef[];
|
||||||
visibleReply?: OpenCodeVisibleReplyProof | null;
|
visibleReply?: OpenCodeVisibleReplyProof | null;
|
||||||
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null;
|
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null;
|
||||||
}): boolean {
|
}): Promise<boolean> {
|
||||||
const state = input.responseState;
|
const state = input.responseState;
|
||||||
if (!state || !isOpenCodePromptResponseStateResponded(state)) {
|
if (!state || !isOpenCodePromptResponseStateResponded(state)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (input.ledgerRecord?.messageKind === 'member_work_sync_nudge') {
|
||||||
|
return this.isOpenCodeMemberWorkSyncReadCommitAllowed({
|
||||||
|
teamName: input.teamName,
|
||||||
|
memberName: input.memberName,
|
||||||
|
ledgerRecord: input.ledgerRecord,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (state === 'responded_plain_text') {
|
if (state === 'responded_plain_text') {
|
||||||
return this.isOpenCodePlainTextResponseReadCommitAllowed({
|
return this.isOpenCodePlainTextResponseReadCommitAllowed({
|
||||||
actionMode: input.actionMode,
|
actionMode: input.actionMode,
|
||||||
|
|
@ -5556,18 +5577,12 @@ export class TeamProvisioningService {
|
||||||
private hasOpenCodeNonVisibleProgressProof(
|
private hasOpenCodeNonVisibleProgressProof(
|
||||||
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (ledgerRecord?.messageKind === 'member_work_sync_nudge') {
|
||||||
|
return this.hasOpenCodeMemberWorkSyncReadCommitProof(ledgerRecord);
|
||||||
|
}
|
||||||
const toolNames = ledgerRecord?.observedToolCallNames ?? [];
|
const toolNames = ledgerRecord?.observedToolCallNames ?? [];
|
||||||
return toolNames.some((toolName) => {
|
return toolNames.some((toolName) => {
|
||||||
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
|
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
|
||||||
if (
|
|
||||||
ledgerRecord?.messageKind === 'member_work_sync_nudge' &&
|
|
||||||
(normalized === 'member_work_sync_report' ||
|
|
||||||
normalized === 'review_start' ||
|
|
||||||
normalized === 'review_approve' ||
|
|
||||||
normalized === 'review_request_changes')
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
normalized === 'task_start' ||
|
normalized === 'task_start' ||
|
||||||
normalized === 'task_add_comment' ||
|
normalized === 'task_add_comment' ||
|
||||||
|
|
@ -5584,6 +5599,97 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasOpenCodeMemberWorkSyncReportToolProof(
|
||||||
|
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
||||||
|
): boolean {
|
||||||
|
const toolNames = ledgerRecord?.observedToolCallNames ?? [];
|
||||||
|
return toolNames.some((toolName) => {
|
||||||
|
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
|
||||||
|
return normalized === 'member_work_sync_report';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasOpenCodeReviewPickupWorkflowProof(
|
||||||
|
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
||||||
|
): boolean {
|
||||||
|
if (ledgerRecord?.workSyncIntent !== 'review_pickup') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const toolNames = ledgerRecord?.observedToolCallNames ?? [];
|
||||||
|
return toolNames.some((toolName) => {
|
||||||
|
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
|
||||||
|
return (
|
||||||
|
normalized === 'review_start' ||
|
||||||
|
normalized === 'review_approve' ||
|
||||||
|
normalized === 'review_request_changes'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasOpenCodeMemberWorkSyncReadCommitProof(
|
||||||
|
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
this.hasOpenCodeMemberWorkSyncReportToolProof(ledgerRecord) ||
|
||||||
|
this.hasOpenCodeReviewPickupWorkflowProof(ledgerRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isOpenCodeMemberWorkSyncReadCommitAllowed(input: {
|
||||||
|
teamName?: string;
|
||||||
|
memberName?: string;
|
||||||
|
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (this.hasOpenCodeReviewPickupWorkflowProof(input.ledgerRecord)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!this.hasOpenCodeMemberWorkSyncReportToolProof(input.ledgerRecord)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const teamName = input.teamName?.trim();
|
||||||
|
const memberName = input.memberName?.trim();
|
||||||
|
if (!teamName || !memberName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.hasAcceptedMemberWorkSyncReport({ teamName, memberName });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isLegacyOpenCodeMemberWorkSyncReadCommitAllowed(input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
workSyncIntent?: OpenCodeTeamRuntimeMessageInput['workSyncIntent'];
|
||||||
|
responseObservation?: NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']>;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const state = input.responseObservation?.state;
|
||||||
|
if (!state || !isOpenCodePromptResponseStateResponded(state)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const toolNames = input.responseObservation?.toolCallNames ?? [];
|
||||||
|
const hasReviewPickupProof =
|
||||||
|
input.workSyncIntent === 'review_pickup' &&
|
||||||
|
toolNames.some((toolName) => {
|
||||||
|
const normalized = this.normalizeOpenCodeObservedToolName(toolName);
|
||||||
|
return (
|
||||||
|
normalized === 'review_start' ||
|
||||||
|
normalized === 'review_approve' ||
|
||||||
|
normalized === 'review_request_changes'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (hasReviewPickupProof) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const hasReportTool = toolNames.some(
|
||||||
|
(toolName) => this.normalizeOpenCodeObservedToolName(toolName) === 'member_work_sync_report'
|
||||||
|
);
|
||||||
|
if (!hasReportTool) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.hasAcceptedMemberWorkSyncReport({
|
||||||
|
teamName: input.teamName,
|
||||||
|
memberName: input.memberName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private hasOpenCodeObservedMessageSendToolCall(
|
private hasOpenCodeObservedMessageSendToolCall(
|
||||||
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
@ -5636,6 +5742,22 @@ export class TeamProvisioningService {
|
||||||
}): string {
|
}): string {
|
||||||
const record = input.ledgerRecord;
|
const record = input.ledgerRecord;
|
||||||
const state = input.responseState ?? record?.responseState;
|
const state = input.responseState ?? record?.responseState;
|
||||||
|
if (record?.messageKind === 'member_work_sync_nudge') {
|
||||||
|
if (state === 'responded_plain_text' || state === 'responded_visible_message') {
|
||||||
|
return 'member_work_sync_report_required';
|
||||||
|
}
|
||||||
|
if (state === 'responded_non_visible_tool' || state === 'responded_tool_call') {
|
||||||
|
if (record.workSyncIntent !== 'review_pickup') {
|
||||||
|
return 'member_work_sync_report_required';
|
||||||
|
}
|
||||||
|
if (!this.hasOpenCodeMemberWorkSyncReadCommitProof(record)) {
|
||||||
|
return 'member_work_sync_report_required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.hasOpenCodeMemberWorkSyncReadCommitProof(record)) {
|
||||||
|
return 'member_work_sync_report_required';
|
||||||
|
}
|
||||||
|
}
|
||||||
if (state === 'responded_visible_message' && !input.visibleReply) {
|
if (state === 'responded_visible_message' && !input.visibleReply) {
|
||||||
return 'visible_reply_destination_not_found_yet';
|
return 'visible_reply_destination_not_found_yet';
|
||||||
}
|
}
|
||||||
|
|
@ -5760,7 +5882,17 @@ export class TeamProvisioningService {
|
||||||
if (
|
if (
|
||||||
input.ledgerRecord.lastReason === 'visible_reply_ack_only_still_requires_answer' ||
|
input.ledgerRecord.lastReason === 'visible_reply_ack_only_still_requires_answer' ||
|
||||||
input.ledgerRecord.lastReason === 'plain_text_ack_only_still_requires_answer' ||
|
input.ledgerRecord.lastReason === 'plain_text_ack_only_still_requires_answer' ||
|
||||||
input.ledgerRecord.lastReason === 'visible_reply_missing_task_refs'
|
input.ledgerRecord.lastReason === 'visible_reply_missing_task_refs' ||
|
||||||
|
input.ledgerRecord.lastReason === 'member_work_sync_report_required'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.ledgerRecord.messageKind === 'member_work_sync_nudge' &&
|
||||||
|
(input.ledgerRecord.responseState === 'responded_visible_message' ||
|
||||||
|
input.ledgerRecord.responseState === 'responded_plain_text' ||
|
||||||
|
input.ledgerRecord.responseState === 'responded_non_visible_tool' ||
|
||||||
|
input.ledgerRecord.responseState === 'responded_tool_call')
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -6635,7 +6767,9 @@ export class TeamProvisioningService {
|
||||||
let ledgerRecord = input.ledgerRecord;
|
let ledgerRecord = input.ledgerRecord;
|
||||||
let visibleReply = input.visibleReply ?? null;
|
let visibleReply = input.visibleReply ?? null;
|
||||||
const observeMessageDelivery = input.adapter.observeMessageDelivery;
|
const observeMessageDelivery = input.adapter.observeMessageDelivery;
|
||||||
const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
const readAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
teamName: input.teamName,
|
||||||
|
memberName: input.memberName,
|
||||||
responseState: ledgerRecord.responseState,
|
responseState: ledgerRecord.responseState,
|
||||||
actionMode: ledgerRecord.actionMode ?? undefined,
|
actionMode: ledgerRecord.actionMode ?? undefined,
|
||||||
taskRefs: ledgerRecord.taskRefs,
|
taskRefs: ledgerRecord.taskRefs,
|
||||||
|
|
@ -6784,7 +6918,9 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
ledgerRecord = materialized.ledgerRecord;
|
ledgerRecord = materialized.ledgerRecord;
|
||||||
visibleReply = materialized.visibleReply;
|
visibleReply = materialized.visibleReply;
|
||||||
const observedReadAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
const observedReadAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
teamName: input.teamName,
|
||||||
|
memberName: input.memberName,
|
||||||
responseState: ledgerRecord.responseState,
|
responseState: ledgerRecord.responseState,
|
||||||
actionMode: ledgerRecord.actionMode ?? undefined,
|
actionMode: ledgerRecord.actionMode ?? undefined,
|
||||||
taskRefs: ledgerRecord.taskRefs,
|
taskRefs: ledgerRecord.taskRefs,
|
||||||
|
|
@ -8752,14 +8888,27 @@ export class TeamProvisioningService {
|
||||||
teamColor: config?.color,
|
teamColor: config?.color,
|
||||||
teamDisplayName: config?.name,
|
teamDisplayName: config?.name,
|
||||||
});
|
});
|
||||||
|
const legacyWorkSyncReadAllowed =
|
||||||
|
input.messageKind === 'member_work_sync_nudge' && result.ok
|
||||||
|
? await this.isLegacyOpenCodeMemberWorkSyncReadCommitAllowed({
|
||||||
|
teamName,
|
||||||
|
memberName: canonicalMemberName,
|
||||||
|
workSyncIntent: input.workSyncIntent,
|
||||||
|
responseObservation,
|
||||||
|
})
|
||||||
|
: true;
|
||||||
|
const legacyWorkSyncResponsePending =
|
||||||
|
result.ok && input.messageKind === 'member_work_sync_nudge' && !legacyWorkSyncReadAllowed;
|
||||||
return {
|
return {
|
||||||
delivered: result.ok,
|
delivered: result.ok,
|
||||||
accepted: result.ok,
|
accepted: result.ok,
|
||||||
responsePending: false,
|
responsePending: legacyWorkSyncResponsePending,
|
||||||
responseState: responseObservation?.state,
|
responseState: responseObservation?.state,
|
||||||
...(result.ok
|
...(legacyWorkSyncResponsePending
|
||||||
? {}
|
? { reason: responseObservation?.reason ?? 'member_work_sync_report_required' }
|
||||||
: { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }),
|
: result.ok
|
||||||
|
? {}
|
||||||
|
: { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }),
|
||||||
diagnostics: result.diagnostics,
|
diagnostics: result.diagnostics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -8794,7 +8943,9 @@ export class TeamProvisioningService {
|
||||||
visibleReply: proof.visibleReply,
|
visibleReply: proof.visibleReply,
|
||||||
});
|
});
|
||||||
active = proof.ledgerRecord;
|
active = proof.ledgerRecord;
|
||||||
const activeReadAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
const activeReadAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
teamName,
|
||||||
|
memberName: canonicalMemberName,
|
||||||
responseState: active.responseState,
|
responseState: active.responseState,
|
||||||
actionMode: active.actionMode ?? undefined,
|
actionMode: active.actionMode ?? undefined,
|
||||||
taskRefs: active.taskRefs,
|
taskRefs: active.taskRefs,
|
||||||
|
|
@ -8882,7 +9033,9 @@ export class TeamProvisioningService {
|
||||||
visibleReply: proof.visibleReply,
|
visibleReply: proof.visibleReply,
|
||||||
});
|
});
|
||||||
ledgerRecord = proof.ledgerRecord;
|
ledgerRecord = proof.ledgerRecord;
|
||||||
let readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
let readAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
teamName,
|
||||||
|
memberName: canonicalMemberName,
|
||||||
responseState: ledgerRecord.responseState,
|
responseState: ledgerRecord.responseState,
|
||||||
actionMode: ledgerRecord.actionMode ?? undefined,
|
actionMode: ledgerRecord.actionMode ?? undefined,
|
||||||
taskRefs: ledgerRecord.taskRefs,
|
taskRefs: ledgerRecord.taskRefs,
|
||||||
|
|
@ -9071,7 +9224,9 @@ export class TeamProvisioningService {
|
||||||
visibleReply: proof.visibleReply,
|
visibleReply: proof.visibleReply,
|
||||||
});
|
});
|
||||||
ledgerRecord = proof.ledgerRecord;
|
ledgerRecord = proof.ledgerRecord;
|
||||||
readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
readAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
teamName,
|
||||||
|
memberName: canonicalMemberName,
|
||||||
responseState: ledgerRecord.responseState,
|
responseState: ledgerRecord.responseState,
|
||||||
actionMode: ledgerRecord.actionMode ?? undefined,
|
actionMode: ledgerRecord.actionMode ?? undefined,
|
||||||
taskRefs: ledgerRecord.taskRefs,
|
taskRefs: ledgerRecord.taskRefs,
|
||||||
|
|
@ -9199,7 +9354,9 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryReadAllowed = ledgerRecord
|
const retryReadAllowed = ledgerRecord
|
||||||
? this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
? await this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
teamName,
|
||||||
|
memberName: canonicalMemberName,
|
||||||
responseState: ledgerRecord.responseState,
|
responseState: ledgerRecord.responseState,
|
||||||
actionMode: ledgerRecord.actionMode ?? undefined,
|
actionMode: ledgerRecord.actionMode ?? undefined,
|
||||||
taskRefs: ledgerRecord.taskRefs,
|
taskRefs: ledgerRecord.taskRefs,
|
||||||
|
|
@ -9463,7 +9620,9 @@ export class TeamProvisioningService {
|
||||||
ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId',
|
ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId',
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
const readAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
teamName,
|
||||||
|
memberName: canonicalMemberName,
|
||||||
responseState,
|
responseState,
|
||||||
actionMode: input.actionMode,
|
actionMode: input.actionMode,
|
||||||
taskRefs: input.taskRefs,
|
taskRefs: input.taskRefs,
|
||||||
|
|
@ -23471,15 +23630,17 @@ export class TeamProvisioningService {
|
||||||
recoveredVisibleReply = null;
|
recoveredVisibleReply = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const recoveredReadAllowed =
|
const recoveredReadAllowed = recoveredRecord
|
||||||
recoveredRecord &&
|
? await this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
teamName,
|
||||||
responseState: recoveredRecord.responseState,
|
memberName: memberIdentity.canonicalMemberName,
|
||||||
actionMode: recoveredRecord.actionMode ?? undefined,
|
responseState: recoveredRecord.responseState,
|
||||||
taskRefs: recoveredRecord.taskRefs,
|
actionMode: recoveredRecord.actionMode ?? undefined,
|
||||||
visibleReply: recoveredVisibleReply,
|
taskRefs: recoveredRecord.taskRefs,
|
||||||
ledgerRecord: recoveredRecord,
|
visibleReply: recoveredVisibleReply,
|
||||||
});
|
ledgerRecord: recoveredRecord,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
if (recoveredRecord && recoveredReadAllowed) {
|
if (recoveredRecord && recoveredReadAllowed) {
|
||||||
try {
|
try {
|
||||||
await this.markInboxMessagesRead(teamName, memberName, [message]);
|
await this.markInboxMessagesRead(teamName, memberName, [message]);
|
||||||
|
|
@ -23967,6 +24128,101 @@ export class TeamProvisioningService {
|
||||||
return from === 'user' || message.source === 'user_sent';
|
return from === 'user' || message.source === 'user_sent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hasAcceptedMemberWorkSyncReport(input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const checker = this.memberWorkSyncAcceptedReportChecker;
|
||||||
|
if (!checker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
(await checker({
|
||||||
|
teamName: input.teamName,
|
||||||
|
memberName: input.memberName,
|
||||||
|
})) === true
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[${input.teamName}] Failed to check accepted work sync report for ${input.memberName}: ${getErrorMessage(error)}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasAcceptedLeadWorkSyncReport(input: {
|
||||||
|
teamName: string;
|
||||||
|
leadName: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return this.hasAcceptedMemberWorkSyncReport({
|
||||||
|
teamName: input.teamName,
|
||||||
|
memberName: input.leadName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scheduleLeadProofMissingWorkSyncRecovery(input: {
|
||||||
|
teamName: string;
|
||||||
|
leadName: string;
|
||||||
|
message: InboxMessage & { messageId: string };
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const scheduler = this.memberWorkSyncProofMissingRecoveryScheduler;
|
||||||
|
if (!scheduler) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = (await scheduler({
|
||||||
|
teamName: input.teamName,
|
||||||
|
memberName: input.leadName,
|
||||||
|
originalMessageId: input.message.messageId,
|
||||||
|
taskRefs: input.message.taskRefs,
|
||||||
|
reason: 'lead_member_work_sync_report_required',
|
||||||
|
})) as { scheduled?: boolean; reason?: string } | null | undefined;
|
||||||
|
return result?.scheduled === true || result?.reason === 'coalesced_recent';
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`[${input.teamName}] Failed to schedule lead proof-missing work sync recovery for ${input.leadName}: ${getErrorMessage(error)}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLeadRelayReadCommitBatch(input: {
|
||||||
|
teamName: string;
|
||||||
|
leadName: string;
|
||||||
|
batch: (InboxMessage & { messageId: string })[];
|
||||||
|
}): Promise<(InboxMessage & { messageId: string })[]> {
|
||||||
|
const readCommitBatch: (InboxMessage & { messageId: string })[] = [];
|
||||||
|
for (const message of input.batch) {
|
||||||
|
if (message.messageKind !== 'member_work_sync_nudge') {
|
||||||
|
readCommitBatch.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
await this.hasAcceptedLeadWorkSyncReport({
|
||||||
|
teamName: input.teamName,
|
||||||
|
leadName: input.leadName,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
readCommitBatch.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveryScheduled = await this.scheduleLeadProofMissingWorkSyncRecovery({
|
||||||
|
teamName: input.teamName,
|
||||||
|
leadName: input.leadName,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
if (recoveryScheduled) {
|
||||||
|
readCommitBatch.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return readCommitBatch;
|
||||||
|
}
|
||||||
|
|
||||||
async relayLeadInboxMessages(teamName: string): Promise<number> {
|
async relayLeadInboxMessages(teamName: string): Promise<number> {
|
||||||
const existing = this.leadInboxRelayInFlight.get(teamName);
|
const existing = this.leadInboxRelayInFlight.get(teamName);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -24407,10 +24663,6 @@ export class TeamProvisioningService {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const m of batch) {
|
|
||||||
relayedIds.add(m.messageId);
|
|
||||||
}
|
|
||||||
this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds));
|
|
||||||
this.rememberRecentCrossTeamLeadDeliveryMessageIds(
|
this.rememberRecentCrossTeamLeadDeliveryMessageIds(
|
||||||
teamName,
|
teamName,
|
||||||
batch
|
batch
|
||||||
|
|
@ -24418,12 +24670,6 @@ export class TeamProvisioningService {
|
||||||
.map((message) => message.messageId)
|
.map((message) => message.messageId)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
|
||||||
await this.markInboxMessagesRead(teamName, leadName, batch);
|
|
||||||
} catch {
|
|
||||||
// Best-effort: relay succeeded; marking read failed.
|
|
||||||
}
|
|
||||||
|
|
||||||
let replyText: string | null = null;
|
let replyText: string | null = null;
|
||||||
let capturedVisibleSendMessage = false;
|
let capturedVisibleSendMessage = false;
|
||||||
let capturedUserVisibleSendMessage = false;
|
let capturedUserVisibleSendMessage = false;
|
||||||
|
|
@ -24448,6 +24694,23 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readCommitBatch = await this.getLeadRelayReadCommitBatch({
|
||||||
|
teamName,
|
||||||
|
leadName,
|
||||||
|
batch,
|
||||||
|
});
|
||||||
|
for (const m of readCommitBatch) {
|
||||||
|
relayedIds.add(m.messageId);
|
||||||
|
}
|
||||||
|
this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds));
|
||||||
|
if (readCommitBatch.length > 0) {
|
||||||
|
try {
|
||||||
|
await this.markInboxMessagesRead(teamName, leadName, readCommitBatch);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: relay succeeded; marking read failed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Strip agent-only blocks — lead may respond with pure coordination content
|
// Strip agent-only blocks — lead may respond with pure coordination content
|
||||||
// that is not meant for the human user.
|
// that is not meant for the human user.
|
||||||
const cleanReply = replyText
|
const cleanReply = replyText
|
||||||
|
|
|
||||||
|
|
@ -1126,6 +1126,7 @@ function buildMemberBootstrapPrompt(
|
||||||
'This OpenCode session is created, attached, and launch-verified by the desktop app.',
|
'This OpenCode session is created, attached, and launch-verified by the desktop app.',
|
||||||
'Do not call runtime_bootstrap_checkin or member_briefing just to prove launch readiness.',
|
'Do not call runtime_bootstrap_checkin or member_briefing just to prove launch readiness.',
|
||||||
'Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
|
'Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
|
||||||
|
'That bootstrap restriction is only about team registry/startup files. It does not restrict assigned project work: when a task requires implementation, fixes, review follow-up, or investigation, you may inspect, read/search, and edit files in the project working directory as your available tools allow.',
|
||||||
'Use the app MCP tools exposed by the "agent-teams" server for team communication and task state.',
|
'Use the app MCP tools exposed by the "agent-teams" server for team communication and task state.',
|
||||||
'Launch bootstrap is a silent attach, not a user/team conversation turn.',
|
'Launch bootstrap is a silent attach, not a user/team conversation turn.',
|
||||||
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
|
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
|
||||||
|
|
@ -1190,6 +1191,12 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
input.taskRefs
|
input.taskRefs
|
||||||
?.map((ref) => ref.taskId?.trim())
|
?.map((ref) => ref.taskId?.trim())
|
||||||
.filter((taskId): taskId is string => Boolean(taskId)) ?? [];
|
.filter((taskId): taskId is string => Boolean(taskId)) ?? [];
|
||||||
|
const actionModeWorkScopeReminder =
|
||||||
|
input.actionMode === 'ask'
|
||||||
|
? 'Action mode ASK is read-only for this delivered message: do not edit files, change task state, or run side-effecting tools for this message.'
|
||||||
|
: input.actionMode === 'delegate'
|
||||||
|
? 'Action mode DELEGATE is orchestration-only for this delivered message: pass the task with context instead of implementing or editing files yourself.'
|
||||||
|
: 'If this delivered message assigns implementation, fixes, review follow-up, or concrete investigation, you may inspect, read/search, and edit files in the project working directory as your available tools allow.';
|
||||||
// Work-sync nudges are health/reporting probes. Requiring a visible
|
// Work-sync nudges are health/reporting probes. Requiring a visible
|
||||||
// message_send reply here causes false delivery failures, so accept the
|
// message_send reply here causes false delivery failures, so accept the
|
||||||
// dedicated member_work_sync_report proof path while keeping normal user
|
// dedicated member_work_sync_report proof path while keeping normal user
|
||||||
|
|
@ -1199,7 +1206,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
'This delivered app message is a targeted member-work-sync review pickup nudge.',
|
'This delivered app message is a targeted member-work-sync review pickup nudge.',
|
||||||
'Process the current review request now if it is still assigned to you. Open the task, verify reviewState/status, then use the review workflow tools to start or continue the review.',
|
'Process the current review request now if it is still assigned to you. Open the task, verify reviewState/status, then use the review workflow tools to start or continue the review.',
|
||||||
'Do not mark the review complete from this prompt alone.',
|
'Do not mark the review complete from this prompt alone.',
|
||||||
'A visible agent-teams_message_send reply is optional. Concrete review progress, review tool usage, or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
'A visible agent-teams_message_send reply is optional. Review workflow tool usage or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
||||||
`If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}, then report state "blocked" or "still_working" only for the real current state.`,
|
`If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}, then report state "blocked" or "still_working" only for the real current state.`,
|
||||||
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
|
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
|
||||||
taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null,
|
taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null,
|
||||||
|
|
@ -1209,7 +1216,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
: isWorkSyncNudge
|
: isWorkSyncNudge
|
||||||
? [
|
? [
|
||||||
'This delivered app message is a member-work-sync nudge.',
|
'This delivered app message is a member-work-sync nudge.',
|
||||||
'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
'A visible agent-teams_message_send reply is optional. For agenda sync, only agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
||||||
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}.`,
|
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}.`,
|
||||||
`Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with ${workSyncToolArgs}, the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`,
|
`Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with ${workSyncToolArgs}, the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`,
|
||||||
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
|
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
|
||||||
|
|
@ -1241,6 +1248,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
? `<opencode_delivery_context>${deliveryContext}</opencode_delivery_context>`
|
? `<opencode_delivery_context>${deliveryContext}</opencode_delivery_context>`
|
||||||
: null,
|
: null,
|
||||||
'You are running in OpenCode, not Claude Code or Codex native.',
|
'You are running in OpenCode, not Claude Code or Codex native.',
|
||||||
|
actionModeWorkScopeReminder,
|
||||||
...responseInstructions,
|
...responseInstructions,
|
||||||
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
|
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
|
||||||
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
|
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,7 @@ describe('team model availability Codex catalog integration', () => {
|
||||||
expect(getAvailableTeamProviderModels('anthropic', providerStatus)).toEqual([
|
expect(getAvailableTeamProviderModels('anthropic', providerStatus)).toEqual([
|
||||||
'haiku',
|
'haiku',
|
||||||
'opus',
|
'opus',
|
||||||
|
'claude-opus-4-7',
|
||||||
'claude-opus-4-6',
|
'claude-opus-4-6',
|
||||||
'sonnet',
|
'sonnet',
|
||||||
]);
|
]);
|
||||||
|
|
@ -431,6 +432,13 @@ describe('team model availability Codex catalog integration', () => {
|
||||||
availabilityStatus: 'available',
|
availabilityStatus: 'available',
|
||||||
availabilityReason: null,
|
availabilityReason: null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'claude-opus-4-7',
|
||||||
|
label: 'Opus 4.7',
|
||||||
|
badgeLabel: 'Opus 4.7',
|
||||||
|
availabilityStatus: 'available',
|
||||||
|
availabilityReason: null,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'claude-opus-4-6',
|
value: 'claude-opus-4-6',
|
||||||
label: 'Opus 4.6',
|
label: 'Opus 4.6',
|
||||||
|
|
|
||||||
|
|
@ -166,12 +166,7 @@ export function isTeamProviderModelVerificationPending(
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const verificationState = providerStatus.verificationState as
|
const verificationState = providerStatus.verificationState;
|
||||||
| 'verified'
|
|
||||||
| 'unknown'
|
|
||||||
| 'offline'
|
|
||||||
| 'error'
|
|
||||||
| undefined;
|
|
||||||
if (verificationState === 'error' || providerStatus.modelCatalogRefreshState === 'error') {
|
if (verificationState === 'error' || providerStatus.modelCatalogRefreshState === 'error') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,14 +232,16 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
||||||
async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void> {
|
async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void> {
|
||||||
const current = this.items.get(input.id);
|
const current = this.items.get(input.id);
|
||||||
if (current?.attemptGeneration === input.attemptGeneration) {
|
if (current?.attemptGeneration === input.attemptGeneration) {
|
||||||
this.items.set(input.id, {
|
const next = {
|
||||||
...current,
|
...current,
|
||||||
status: 'delivered',
|
status: 'delivered' as const,
|
||||||
deliveredMessageId: input.deliveredMessageId,
|
deliveredMessageId: input.deliveredMessageId,
|
||||||
...(input.deliveryState ? { deliveryState: input.deliveryState } : {}),
|
...(input.deliveryState ? { deliveryState: input.deliveryState } : {}),
|
||||||
...(input.deliveryDiagnostics ? { deliveryDiagnostics: input.deliveryDiagnostics } : {}),
|
...(input.deliveryDiagnostics ? { deliveryDiagnostics: input.deliveryDiagnostics } : {}),
|
||||||
updatedAt: input.nowIso,
|
updatedAt: input.nowIso,
|
||||||
});
|
};
|
||||||
|
delete next.nextAttemptAt;
|
||||||
|
this.items.set(input.id, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,12 +265,18 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async countRecentDelivered(input: { memberName: string; sinceIso: string }): Promise<number> {
|
async countRecentDelivered(input: {
|
||||||
|
memberName: string;
|
||||||
|
sinceIso: string;
|
||||||
|
workSyncIntentKeyPrefix?: string;
|
||||||
|
}): Promise<number> {
|
||||||
return [...this.items.values()].filter(
|
return [...this.items.values()].filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.status === 'delivered' &&
|
item.status === 'delivered' &&
|
||||||
item.memberName === input.memberName &&
|
item.memberName === input.memberName &&
|
||||||
item.updatedAt >= input.sinceIso
|
item.updatedAt >= input.sinceIso &&
|
||||||
|
(!input.workSyncIntentKeyPrefix ||
|
||||||
|
item.payload.workSyncIntentKey?.startsWith(input.workSyncIntentKeyPrefix) === true)
|
||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,17 +304,22 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
||||||
class InMemoryInboxNudge implements MemberWorkSyncInboxNudgePort {
|
class InMemoryInboxNudge implements MemberWorkSyncInboxNudgePort {
|
||||||
readonly inserted: Array<Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]> = [];
|
readonly inserted: Array<Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]> = [];
|
||||||
fail = false;
|
fail = false;
|
||||||
|
conflict = false;
|
||||||
|
|
||||||
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
|
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
|
||||||
if (this.fail) {
|
if (this.fail) {
|
||||||
throw new Error('inbox unavailable');
|
throw new Error('inbox unavailable');
|
||||||
}
|
}
|
||||||
|
if (this.conflict) {
|
||||||
|
return { inserted: false, messageId: input.messageId, conflict: true };
|
||||||
|
}
|
||||||
this.inserted.push(input);
|
this.inserted.push(input);
|
||||||
return { inserted: true, messageId: input.messageId };
|
return { inserted: true, messageId: input.messageId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeps(options?: {
|
function createDeps(options?: {
|
||||||
|
memberName?: string;
|
||||||
items?: MemberWorkSyncActionableWorkItem[];
|
items?: MemberWorkSyncActionableWorkItem[];
|
||||||
activeMemberNames?: string[];
|
activeMemberNames?: string[];
|
||||||
inactive?: boolean;
|
inactive?: boolean;
|
||||||
|
|
@ -321,15 +334,16 @@ function createDeps(options?: {
|
||||||
const clock = new MutableClock();
|
const clock = new MutableClock();
|
||||||
const store = new InMemoryStatusStore();
|
const store = new InMemoryStatusStore();
|
||||||
const auditEvents: MemberWorkSyncAuditEvent[] = [];
|
const auditEvents: MemberWorkSyncAuditEvent[] = [];
|
||||||
|
const memberName = options?.memberName ?? 'bob';
|
||||||
const source: MemberWorkSyncAgendaSourceResult = {
|
const source: MemberWorkSyncAgendaSourceResult = {
|
||||||
agenda: {
|
agenda: {
|
||||||
teamName: 'team-a',
|
teamName: 'team-a',
|
||||||
memberName: 'bob',
|
memberName,
|
||||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
items: options?.items ?? [workItem],
|
items: options?.items ?? [workItem],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
activeMemberNames: options?.activeMemberNames ?? ['bob'],
|
activeMemberNames: options?.activeMemberNames ?? [memberName],
|
||||||
inactive: options?.inactive ?? false,
|
inactive: options?.inactive ?? false,
|
||||||
...(options?.providerId ? { providerId: options.providerId } : {}),
|
...(options?.providerId ? { providerId: options.providerId } : {}),
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|
@ -940,6 +954,147 @@ describe('MemberWorkSync use cases', () => {
|
||||||
expect(inbox.inserted[1]?.messageId).toContain('status-only');
|
expect(inbox.inserted[1]?.messageId).toContain('status-only');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates a delivered-still-stuck recovery after a delivered status-only nudge gets no report', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const { clock, deps, store } = createDeps({
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const firstStatus = await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['turn_settled'] }
|
||||||
|
);
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inbox.inserted).toHaveLength(2);
|
||||||
|
expect(inbox.inserted[1]?.messageId).toContain('status-only');
|
||||||
|
|
||||||
|
clock.set('2026-04-29T00:10:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['turn_settled'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const stillStuck = [...outbox.items.values()].find((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
);
|
||||||
|
expect(stillStuck).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(3);
|
||||||
|
expect(inbox.inserted[2]?.messageId).toContain('agenda-sync-still-stuck');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a still-stuck recovery when a terminal inbox conflict blocks a status-only nudge', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const { clock, deps, store } = createDeps({
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const firstStatus = await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['turn_settled'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
inbox.conflict = true;
|
||||||
|
const terminalSummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOnly = [...outbox.items.values()].find((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('status-only:')
|
||||||
|
);
|
||||||
|
expect(terminalSummary).toMatchObject({ claimed: 1, delivered: 0, terminal: 1 });
|
||||||
|
expect(statusOnly).toMatchObject({
|
||||||
|
status: 'failed_terminal',
|
||||||
|
lastError: 'inbox_payload_conflict',
|
||||||
|
});
|
||||||
|
|
||||||
|
inbox.conflict = false;
|
||||||
|
clock.set('2026-04-29T00:10:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['turn_settled'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const stillStuck = [...outbox.items.values()].find((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
);
|
||||||
|
expect(stillStuck).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||||
|
expect(inbox.inserted).toHaveLength(2);
|
||||||
|
expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck');
|
||||||
|
});
|
||||||
|
|
||||||
it('creates an agenda-sync refresh recovery when a delivered nudge has a stale payload hash', async () => {
|
it('creates an agenda-sync refresh recovery when a delivered nudge has a stale payload hash', async () => {
|
||||||
const outbox = new InMemoryOutboxStore();
|
const outbox = new InMemoryOutboxStore();
|
||||||
const inbox = new InMemoryInboxNudge();
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
|
@ -1040,6 +1195,460 @@ describe('MemberWorkSync use cases', () => {
|
||||||
expect(statusOnlyItems[0]?.payload.text).toContain('Status-only recovery');
|
expect(statusOnlyItems[0]?.payload.text).toContain('Status-only recovery');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates a delivered-still-stuck recovery after a delivered refresh nudge gets no report', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
outbox.rejectPayloadConflicts = true;
|
||||||
|
const { clock, deps, store } = createDeps({
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const firstStatus = await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`;
|
||||||
|
const delivered = outbox.items.get(baseId);
|
||||||
|
expect(delivered).toMatchObject({ status: 'delivered' });
|
||||||
|
outbox.items.set(baseId, {
|
||||||
|
...delivered!,
|
||||||
|
payloadHash: 'legacy-payload-hash',
|
||||||
|
payload: {
|
||||||
|
...delivered!.payload,
|
||||||
|
text: 'Legacy delivered work-sync nudge text.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
[...outbox.items.values()].filter((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-refresh:')
|
||||||
|
)
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(inbox.inserted).toHaveLength(2);
|
||||||
|
|
||||||
|
clock.set('2026-04-29T00:10:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const stillStuck = [...outbox.items.values()].find((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
);
|
||||||
|
expect(stillStuck).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(3);
|
||||||
|
expect(inbox.inserted[2]?.messageId).toContain('agenda-sync-still-stuck');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a delivered-still-stuck recovery when a delivered agenda nudge gets no report', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const { clock, deps, store } = createDeps({
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const firstStatus = await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`;
|
||||||
|
expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' });
|
||||||
|
|
||||||
|
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: 'stale-current-needs-sync',
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
kind: 'status_evaluated',
|
||||||
|
state: 'needs_sync',
|
||||||
|
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||||
|
recordedAt: '2026-04-29T00:02:00.000Z',
|
||||||
|
actionableCount: 1,
|
||||||
|
providerId: 'codex',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const recovery = [...outbox.items.values()].find((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
);
|
||||||
|
expect(recovery).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||||
|
payload: {
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
workSyncIntentKey: expect.stringContaining(
|
||||||
|
`agenda-sync-still-stuck:${firstStatus.agenda.fingerprint}:`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(recovery?.payload.text).toContain('still no accepted member_work_sync_report');
|
||||||
|
expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' });
|
||||||
|
|
||||||
|
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(2);
|
||||||
|
expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck');
|
||||||
|
|
||||||
|
clock.set('2026-04-29T00:20:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:20:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
[...outbox.items.values()].filter((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
)
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
clock.set('2026-04-29T01:02:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T01:02:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const recoveryItems = [...outbox.items.values()].filter((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
);
|
||||||
|
expect(recoveryItems).toHaveLength(2);
|
||||||
|
expect(new Set(recoveryItems.map((item) => item.id)).size).toBe(2);
|
||||||
|
|
||||||
|
const secondSummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondSummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||||
|
expect(inbox.inserted).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records an existing delivered agenda nudge as skipped before still-stuck recovery age', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const { auditEvents, clock, deps, store } = createDeps({
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const firstStatus = await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`;
|
||||||
|
expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' });
|
||||||
|
|
||||||
|
clock.set('2026-04-29T00:04:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:04:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
[...outbox.items.values()].filter((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
)
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(auditEvents).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
event: 'nudge_skipped',
|
||||||
|
reason: 'existing',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a delivered-still-stuck recovery for a targeted lead despite noisy metrics', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const leadWorkItem: MemberWorkSyncActionableWorkItem = {
|
||||||
|
...workItem,
|
||||||
|
assignee: 'team-lead',
|
||||||
|
evidence: {
|
||||||
|
status: 'pending',
|
||||||
|
owner: 'team-lead',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { clock, deps, store } = createDeps({
|
||||||
|
memberName: 'team-lead',
|
||||||
|
items: [leadWorkItem],
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'blocked';
|
||||||
|
store.phase2ReadinessReasons = ['would_nudge_rate_high'];
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const firstStatus = await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'team-lead',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseId = `member-work-sync:team-a:team-lead:${firstStatus.agenda.fingerprint}`;
|
||||||
|
expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' });
|
||||||
|
|
||||||
|
clock.set('2026-04-29T00:10:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'team-lead',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const recovery = [...outbox.items.values()].find((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
);
|
||||||
|
expect(recovery).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
memberName: 'team-lead',
|
||||||
|
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||||
|
expect(inbox.inserted).toHaveLength(2);
|
||||||
|
expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a still-stuck recovery when a terminal inbox conflict blocks an agenda nudge', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const { clock, deps, store } = createDeps({
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const firstStatus = await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`;
|
||||||
|
expect(outbox.items.get(baseId)).toMatchObject({ status: 'pending' });
|
||||||
|
|
||||||
|
inbox.conflict = true;
|
||||||
|
const terminalSummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(terminalSummary).toMatchObject({ claimed: 1, delivered: 0, terminal: 1 });
|
||||||
|
expect(outbox.items.get(baseId)).toMatchObject({
|
||||||
|
status: 'failed_terminal',
|
||||||
|
lastError: 'inbox_payload_conflict',
|
||||||
|
});
|
||||||
|
|
||||||
|
inbox.conflict = false;
|
||||||
|
clock.set('2026-04-29T00:10:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const recovery = [...outbox.items.values()].find((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
);
|
||||||
|
expect(recovery).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||||
|
payload: {
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
workSyncIntentKey: expect.stringContaining(
|
||||||
|
`agenda-sync-still-stuck:${firstStatus.agenda.fingerprint}:`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(recovery?.payload.text).toContain('still no accepted member_work_sync_report');
|
||||||
|
expect(outbox.items.get(baseId)).toMatchObject({ status: 'failed_terminal' });
|
||||||
|
|
||||||
|
const recoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||||
|
expect(inbox.inserted).toHaveLength(1);
|
||||||
|
expect(inbox.inserted[0]?.messageId).toContain('agenda-sync-still-stuck');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a still-stuck recovery when a terminal inbox conflict has a stale payload hash', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
outbox.rejectPayloadConflicts = true;
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const { clock, deps, store } = createDeps({
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const firstStatus = await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`;
|
||||||
|
|
||||||
|
inbox.conflict = true;
|
||||||
|
await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
const terminal = outbox.items.get(baseId);
|
||||||
|
expect(terminal).toMatchObject({
|
||||||
|
status: 'failed_terminal',
|
||||||
|
lastError: 'inbox_payload_conflict',
|
||||||
|
});
|
||||||
|
outbox.items.set(baseId, {
|
||||||
|
...terminal!,
|
||||||
|
payloadHash: 'stale-terminal-payload-hash',
|
||||||
|
});
|
||||||
|
|
||||||
|
inbox.conflict = false;
|
||||||
|
clock.set('2026-04-29T00:10:00.000Z');
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||||
|
await reconciler.execute(
|
||||||
|
{
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
},
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['manual_refresh'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const recovery = [...outbox.items.values()].find((item) =>
|
||||||
|
item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:')
|
||||||
|
);
|
||||||
|
expect(recovery).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
agendaFingerprint: firstStatus.agenda.fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||||
|
expect(inbox.inserted).toHaveLength(1);
|
||||||
|
expect(inbox.inserted[0]?.messageId).toContain('agenda-sync-still-stuck');
|
||||||
|
});
|
||||||
|
|
||||||
it('marks review pickup delivered only after the delivery port confirms prompt acceptance', async () => {
|
it('marks review pickup delivered only after the delivery port confirms prompt acceptance', async () => {
|
||||||
const outbox = new InMemoryOutboxStore();
|
const outbox = new InMemoryOutboxStore();
|
||||||
const inbox = new InMemoryInboxNudge();
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
describe('CompositeMemberWorkSyncBusySignal', () => {
|
describe('CompositeMemberWorkSyncBusySignal', () => {
|
||||||
it('does not block nudges forever when one busy signal fails', async () => {
|
it('does not block nudges forever when one busy signal fails', async () => {
|
||||||
const logger = { warn: vi.fn() };
|
const logger = { debug: vi.fn(), error: vi.fn(), warn: vi.fn() };
|
||||||
const signal = new CompositeMemberWorkSyncBusySignal(
|
const signal = new CompositeMemberWorkSyncBusySignal(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -24,7 +24,7 @@ describe('CompositeMemberWorkSyncBusySignal', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
nowIso: '2026-04-29T00:00:00.000Z',
|
nowIso: '2026-04-29T00:00:00.000Z',
|
||||||
workSyncIntent: 'agenda_sync',
|
workSyncIntent: 'agenda_sync',
|
||||||
taskRefs: [{ teamName: 'team-a', taskId: 'task-1' }],
|
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
|
||||||
})
|
})
|
||||||
).resolves.toEqual({ busy: false });
|
).resolves.toEqual({ busy: false });
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
|
@ -56,7 +56,7 @@ describe('CompositeMemberWorkSyncBusySignal', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
nowIso: '2026-04-29T00:00:00.000Z',
|
nowIso: '2026-04-29T00:00:00.000Z',
|
||||||
workSyncIntent: 'agenda_sync',
|
workSyncIntent: 'agenda_sync',
|
||||||
taskRefs: [{ teamName: 'team-a', taskId: 'task-1' }],
|
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
|
||||||
})
|
})
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
busy: true,
|
busy: true,
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,7 @@ describe('JsonMemberWorkSyncStore', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates outbox items by id and rejects payload hash conflicts', async () => {
|
it('refreshes undelivered outbox payloads but rejects delivered payload conflicts', async () => {
|
||||||
const input = {
|
const input = {
|
||||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||||
teamName: 'team-a',
|
teamName: 'team-a',
|
||||||
|
|
@ -372,11 +372,97 @@ describe('JsonMemberWorkSyncStore', () => {
|
||||||
ok: true,
|
ok: true,
|
||||||
outcome: 'existing',
|
outcome: 'existing',
|
||||||
});
|
});
|
||||||
await expect(store.ensurePending({ ...input, payloadHash: 'hash-b' })).resolves.toMatchObject({
|
const refreshed = await store.ensurePending({
|
||||||
|
...input,
|
||||||
|
payloadHash: 'hash-b',
|
||||||
|
payload: makeNudgePayload({
|
||||||
|
text: 'Work sync check: call member_work_sync_status and member_work_sync_report.',
|
||||||
|
}),
|
||||||
|
nowIso: '2026-04-29T00:01:00.000Z',
|
||||||
|
});
|
||||||
|
expect(refreshed).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
outcome: 'existing',
|
||||||
|
item: {
|
||||||
|
status: 'pending',
|
||||||
|
payloadHash: 'hash-b',
|
||||||
|
payload: {
|
||||||
|
text: 'Work sync check: call member_work_sync_status and member_work_sync_report.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [claimed] = await store.claimDue({
|
||||||
|
teamName: 'team-a',
|
||||||
|
claimedBy: 'dispatcher-a',
|
||||||
|
nowIso: '2026-04-29T00:02:00.000Z',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
const claimedRefresh = await store.ensurePending({
|
||||||
|
...input,
|
||||||
|
payloadHash: 'hash-c',
|
||||||
|
payload: makeNudgePayload({ text: 'New text while delivery is claimed.' }),
|
||||||
|
nowIso: '2026-04-29T00:02:30.000Z',
|
||||||
|
});
|
||||||
|
expect(claimedRefresh).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
outcome: 'existing',
|
||||||
|
item: {
|
||||||
|
status: 'pending',
|
||||||
|
payloadHash: 'hash-c',
|
||||||
|
payload: { text: 'New text while delivery is claimed.' },
|
||||||
|
attemptGeneration: claimed.attemptGeneration + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await store.markDelivered({
|
||||||
|
teamName: 'team-a',
|
||||||
|
id: input.id,
|
||||||
|
attemptGeneration: claimed.attemptGeneration,
|
||||||
|
deliveredMessageId: 'message-1',
|
||||||
|
nowIso: '2026-04-29T00:03:00.000Z',
|
||||||
|
});
|
||||||
|
const afterStaleDelivery = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'outbox.json'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(afterStaleDelivery.items[input.id]).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
payloadHash: 'hash-c',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [reclaimed] = await store.claimDue({
|
||||||
|
teamName: 'team-a',
|
||||||
|
claimedBy: 'dispatcher-b',
|
||||||
|
nowIso: '2026-04-29T00:03:30.000Z',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
expect(reclaimed).toMatchObject({
|
||||||
|
id: input.id,
|
||||||
|
payloadHash: 'hash-c',
|
||||||
|
attemptGeneration: claimed.attemptGeneration + 2,
|
||||||
|
});
|
||||||
|
await store.markDelivered({
|
||||||
|
teamName: 'team-a',
|
||||||
|
id: input.id,
|
||||||
|
attemptGeneration: reclaimed.attemptGeneration,
|
||||||
|
deliveredMessageId: 'message-2',
|
||||||
|
nowIso: '2026-04-29T00:03:45.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.ensurePending({
|
||||||
|
...input,
|
||||||
|
payloadHash: 'hash-d',
|
||||||
|
payload: makeNudgePayload({ text: 'New text after delivery.' }),
|
||||||
|
nowIso: '2026-04-29T00:04:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
outcome: 'payload_conflict',
|
outcome: 'payload_conflict',
|
||||||
existingPayloadHash: 'hash-a',
|
existingPayloadHash: 'hash-c',
|
||||||
requestedPayloadHash: 'hash-b',
|
requestedPayloadHash: 'hash-d',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -525,6 +611,67 @@ describe('JsonMemberWorkSyncStore', () => {
|
||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears retry delay when a retryable outbox item is delivered', 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.markFailed({
|
||||||
|
teamName: 'team-a',
|
||||||
|
id: input.id,
|
||||||
|
attemptGeneration: claimed.attemptGeneration,
|
||||||
|
retryable: true,
|
||||||
|
error: 'member_busy:active_tool_activity',
|
||||||
|
nextAttemptAt: '2026-04-29T00:30:00.000Z',
|
||||||
|
nowIso: '2026-04-29T00:02:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [reclaimed] = await store.claimDue({
|
||||||
|
teamName: 'team-a',
|
||||||
|
claimedBy: 'dispatcher-b',
|
||||||
|
nowIso: '2026-04-29T00:30:00.000Z',
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
await store.markDelivered({
|
||||||
|
teamName: 'team-a',
|
||||||
|
id: input.id,
|
||||||
|
attemptGeneration: reclaimed.attemptGeneration,
|
||||||
|
deliveredMessageId: 'message-1',
|
||||||
|
nowIso: '2026-04-29T00:31:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberOutbox = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'outbox.json'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(memberOutbox.items[input.id]).toMatchObject({ status: 'delivered' });
|
||||||
|
expect(memberOutbox.items[input.id]).not.toHaveProperty('nextAttemptAt');
|
||||||
|
|
||||||
|
const index = JSON.parse(
|
||||||
|
await readFile(
|
||||||
|
join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(index.items[input.id]).toMatchObject({ status: 'delivered' });
|
||||||
|
expect(index.items[input.id]).not.toHaveProperty('nextAttemptAt');
|
||||||
|
});
|
||||||
|
|
||||||
it('finds recent recovery outbox rows by logical intent key', async () => {
|
it('finds recent recovery outbox rows by logical intent key', async () => {
|
||||||
const olderInput = {
|
const olderInput = {
|
||||||
id: 'member-work-sync:team-a:bob:agenda:v1:older',
|
id: 'member-work-sync:team-a:bob:agenda:v1:older',
|
||||||
|
|
@ -827,6 +974,67 @@ describe('JsonMemberWorkSyncStore', () => {
|
||||||
expect(repaired.items[bobInput.id]).toMatchObject({ memberName: 'bob', status: 'delivered' });
|
expect(repaired.items[bobInput.id]).toMatchObject({ memberName: 'bob', status: 'delivered' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters recent delivered counts by work sync intent key prefix when requested', async () => {
|
||||||
|
const baseInput = {
|
||||||
|
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 stillStuckInput = {
|
||||||
|
...baseInput,
|
||||||
|
id: 'member-work-sync:team-a:bob:agenda-sync-still-stuck:agenda:v1:abc:hash-a:bucket',
|
||||||
|
payloadHash: 'hash-still-stuck',
|
||||||
|
payload: makeNudgePayload({
|
||||||
|
workSyncIntentKey: 'agenda-sync-still-stuck:agenda:v1:abc:hash-a:bucket',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const statusOnlyInput = {
|
||||||
|
...baseInput,
|
||||||
|
id: 'member-work-sync:team-a:bob:status-only:agenda:v1:abc',
|
||||||
|
payloadHash: 'hash-status-only',
|
||||||
|
payload: makeNudgePayload({ workSyncIntentKey: 'status-only:agenda:v1:abc' }),
|
||||||
|
};
|
||||||
|
await store.ensurePending(baseInput);
|
||||||
|
await store.ensurePending(stillStuckInput);
|
||||||
|
await store.ensurePending(statusOnlyInput);
|
||||||
|
|
||||||
|
const claimed = await store.claimDue({
|
||||||
|
teamName: 'team-a',
|
||||||
|
claimedBy: 'dispatcher-a',
|
||||||
|
nowIso: '2026-04-29T00:01:00.000Z',
|
||||||
|
limit: 3,
|
||||||
|
});
|
||||||
|
for (const item of claimed) {
|
||||||
|
await store.markDelivered({
|
||||||
|
teamName: 'team-a',
|
||||||
|
id: item.id,
|
||||||
|
attemptGeneration: item.attemptGeneration,
|
||||||
|
deliveredMessageId: `message:${item.id}`,
|
||||||
|
nowIso: '2026-04-29T00:02:00.000Z',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.countRecentDelivered({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
sinceIso: '2026-04-29T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toBe(3);
|
||||||
|
await expect(
|
||||||
|
store.countRecentDelivered({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
sinceIso: '2026-04-29T00:00:00.000Z',
|
||||||
|
workSyncIntentKeyPrefix: 'agenda-sync-still-stuck:',
|
||||||
|
})
|
||||||
|
).resolves.toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('finds delivered review pickup request event ids from member-scoped outbox files', async () => {
|
it('finds delivered review pickup request event ids from member-scoped outbox files', async () => {
|
||||||
const input = {
|
const input = {
|
||||||
id: 'member-work-sync:team-a:bob:review-pickup:evt-a+evt-b',
|
id: 'member-work-sync:team-a:bob:review-pickup:evt-a+evt-b',
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,51 @@ async function forceRetryableOutboxDue(input: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function backdateDeliveredOutboxItems(input: {
|
||||||
|
teamsBasePath: string;
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const outboxPath = path.join(
|
||||||
|
input.teamsBasePath,
|
||||||
|
input.teamName,
|
||||||
|
'members',
|
||||||
|
input.memberName,
|
||||||
|
'.member-work-sync',
|
||||||
|
'outbox.json'
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(await fs.promises.readFile(outboxPath, 'utf8')) as {
|
||||||
|
items?: Record<string, { status?: string; updatedAt?: string }>;
|
||||||
|
};
|
||||||
|
const touchedIds: string[] = [];
|
||||||
|
for (const [id, item] of Object.entries(parsed.items ?? {})) {
|
||||||
|
if (item.status === 'delivered') {
|
||||||
|
item.updatedAt = input.updatedAt;
|
||||||
|
touchedIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(touchedIds.length).toBeGreaterThan(0);
|
||||||
|
await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
const indexPath = path.join(
|
||||||
|
input.teamsBasePath,
|
||||||
|
input.teamName,
|
||||||
|
'.member-work-sync',
|
||||||
|
'indexes',
|
||||||
|
'outbox-index.json'
|
||||||
|
);
|
||||||
|
const index = JSON.parse(await fs.promises.readFile(indexPath, 'utf8')) as {
|
||||||
|
items?: Record<string, { updatedAt?: string }>;
|
||||||
|
};
|
||||||
|
for (const id of touchedIds) {
|
||||||
|
if (index.items?.[id]) {
|
||||||
|
index.items[id].updatedAt = input.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fs.promises.writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
describe('createMemberWorkSyncFeature composition', () => {
|
describe('createMemberWorkSyncFeature composition', () => {
|
||||||
it('schedules proof-missing recovery through the work-sync queue', async () => {
|
it('schedules proof-missing recovery through the work-sync queue', async () => {
|
||||||
const claudeRoot = makeTempRoot();
|
const claudeRoot = makeTempRoot();
|
||||||
|
|
@ -1531,6 +1576,108 @@ describe('createMemberWorkSyncFeature composition', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delivers still-stuck recovery from json outbox when a delivered agenda nudge gets no report', async () => {
|
||||||
|
const claudeRoot = makeTempRoot();
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
const teamName = 'team-json-still-stuck-recovery';
|
||||||
|
const memberName = 'alice';
|
||||||
|
const nudgeDeliveryWake = {
|
||||||
|
schedule: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
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: 'Recover ignored delivered sync',
|
||||||
|
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),
|
||||||
|
nudgeDeliveryWake,
|
||||||
|
queueQuietWindowMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
||||||
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
||||||
|
|
||||||
|
let agendaFingerprint = '';
|
||||||
|
await waitForAssertion(async () => {
|
||||||
|
const status = await feature.getStatus({ teamName, memberName });
|
||||||
|
agendaFingerprint = status.agenda.fingerprint;
|
||||||
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
||||||
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
||||||
|
);
|
||||||
|
expect(nudges).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'delivered',
|
||||||
|
deliveredMessageId: nudges[0]?.messageId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await backdateDeliveredOutboxItems({
|
||||||
|
teamsBasePath,
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(),
|
||||||
|
});
|
||||||
|
await seedNativeStaleInProgressBlockingMetrics({
|
||||||
|
teamsBasePath,
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
agendaFingerprint,
|
||||||
|
});
|
||||||
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
||||||
|
|
||||||
|
await waitForAssertion(async () => {
|
||||||
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
||||||
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
||||||
|
);
|
||||||
|
expect(nudges).toHaveLength(2);
|
||||||
|
expect(nudges[1]?.messageId).toContain('agenda-sync-still-stuck');
|
||||||
|
expect(nudges[1]?.text).toContain('still no accepted member_work_sync_report');
|
||||||
|
expect(
|
||||||
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
||||||
|
).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'delivered',
|
||||||
|
deliveredMessageId: nudges[1]?.messageId,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => {
|
it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => {
|
||||||
const claudeRoot = makeTempRoot();
|
const claudeRoot = makeTempRoot();
|
||||||
setClaudeBasePathOverride(claudeRoot);
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
|
||||||
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime';
|
import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime';
|
||||||
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildOpenCodeScenarioTeamRequest,
|
buildOpenCodeScenarioTeamRequest,
|
||||||
buildScenarioRuntimeMessageInput,
|
buildScenarioRuntimeMessageInput,
|
||||||
|
|
@ -87,6 +87,12 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
|
||||||
expect(member.prompt).toContain('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1');
|
expect(member.prompt).toContain('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1');
|
||||||
expect(member.prompt).toContain('agent-teams_message_send');
|
expect(member.prompt).toContain('agent-teams_message_send');
|
||||||
expect(member.prompt).toContain('Launch bootstrap is a silent attach');
|
expect(member.prompt).toContain('Launch bootstrap is a silent attach');
|
||||||
|
expect(member.prompt).toContain(
|
||||||
|
'That bootstrap restriction is only about team registry/startup files'
|
||||||
|
);
|
||||||
|
expect(member.prompt).toContain(
|
||||||
|
'you may inspect, read/search, and edit files in the project working directory as your available tools allow'
|
||||||
|
);
|
||||||
expect(member.prompt).toContain('stay idle silently');
|
expect(member.prompt).toContain('stay idle silently');
|
||||||
expect(member.prompt).not.toContain('Call SendMessage');
|
expect(member.prompt).not.toContain('Call SendMessage');
|
||||||
expect(member.prompt).not.toContain('Use SendMessage');
|
expect(member.prompt).not.toContain('Use SendMessage');
|
||||||
|
|
@ -125,6 +131,8 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
|
||||||
expect(directCommand?.text).toContain('Include source="runtime_delivery"');
|
expect(directCommand?.text).toContain('Include source="runtime_delivery"');
|
||||||
expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-');
|
expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-');
|
||||||
expect(directCommand?.text).toContain('Action mode for this message: ask.');
|
expect(directCommand?.text).toContain('Action mode for this message: ask.');
|
||||||
|
expect(directCommand?.text).toContain('Action mode ASK is read-only');
|
||||||
|
expect(directCommand?.text).not.toContain('If this delivered message assigns implementation');
|
||||||
expect(directCommand?.text).toContain('You must not end this turn empty.');
|
expect(directCommand?.text).toContain('You must not end this turn empty.');
|
||||||
expect(directCommand?.text).toContain('include taskRefs exactly as provided');
|
expect(directCommand?.text).toContain('include taskRefs exactly as provided');
|
||||||
expect(directCommand?.text).toContain('"displayId":"59560c95"');
|
expect(directCommand?.text).toContain('"displayId":"59560c95"');
|
||||||
|
|
@ -137,6 +145,8 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
|
||||||
expect(peerCommand?.text).toContain('to="jack"');
|
expect(peerCommand?.text).toContain('to="jack"');
|
||||||
expect(peerCommand?.text).toContain('from="bob"');
|
expect(peerCommand?.text).toContain('from="bob"');
|
||||||
expect(peerCommand?.text).toContain('Action mode for this message: delegate.');
|
expect(peerCommand?.text).toContain('Action mode for this message: delegate.');
|
||||||
|
expect(peerCommand?.text).toContain('Action mode DELEGATE is orchestration-only');
|
||||||
|
expect(peerCommand?.text).not.toContain('If this delivered message assigns implementation');
|
||||||
expect(peerCommand?.text).toContain('"displayId":"3375c939"');
|
expect(peerCommand?.text).toContain('"displayId":"3375c939"');
|
||||||
expect(peerCommand?.taskRefs).toEqual([
|
expect(peerCommand?.taskRefs).toEqual([
|
||||||
{ taskId: 'task-3375c939-peer-relay', displayId: '3375c939', teamName },
|
{ taskId: 'task-3375c939-peer-relay', displayId: '3375c939', teamName },
|
||||||
|
|
|
||||||
|
|
@ -704,6 +704,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
);
|
);
|
||||||
const launchArg = launchOpenCodeTeam.mock.calls[0]?.[0];
|
const launchArg = launchOpenCodeTeam.mock.calls[0]?.[0];
|
||||||
expect(launchArg?.members[0]?.prompt).toContain('Do NOT create local team files');
|
expect(launchArg?.members[0]?.prompt).toContain('Do NOT create local team files');
|
||||||
|
expect(launchArg?.members[0]?.prompt).toContain(
|
||||||
|
'That bootstrap restriction is only about team registry/startup files'
|
||||||
|
);
|
||||||
|
expect(launchArg?.members[0]?.prompt).toContain(
|
||||||
|
'you may inspect, read/search, and edit files in the project working directory as your available tools allow'
|
||||||
|
);
|
||||||
expect(launchArg?.members[0]?.prompt).toContain('Launch bootstrap is a silent attach');
|
expect(launchArg?.members[0]?.prompt).toContain('Launch bootstrap is a silent attach');
|
||||||
expect(launchArg?.members[0]?.prompt).toContain('stay idle silently');
|
expect(launchArg?.members[0]?.prompt).toContain('stay idle silently');
|
||||||
expect(launchArg?.members[0]?.prompt).not.toContain('agent-teams_member_briefing');
|
expect(launchArg?.members[0]?.prompt).not.toContain('agent-teams_member_briefing');
|
||||||
|
|
@ -1094,6 +1100,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
expect(sentText).toContain('Include source="runtime_delivery"');
|
expect(sentText).toContain('Include source="runtime_delivery"');
|
||||||
expect(sentText).toContain('Include relayOfMessageId="msg-1"');
|
expect(sentText).toContain('Include relayOfMessageId="msg-1"');
|
||||||
expect(sentText).toContain('Action mode for this message: delegate.');
|
expect(sentText).toContain('Action mode for this message: delegate.');
|
||||||
|
expect(sentText).toContain('Action mode DELEGATE is orchestration-only');
|
||||||
|
expect(sentText).not.toContain('If this delivered message assigns implementation');
|
||||||
expect(sentText).toContain('You must not end this turn empty.');
|
expect(sentText).toContain('You must not end this turn empty.');
|
||||||
expect(sentText).toContain('<opencode_delivery_context>');
|
expect(sentText).toContain('<opencode_delivery_context>');
|
||||||
expect(sentText).toContain('"kind":"opencode-delivery-context"');
|
expect(sentText).toContain('"kind":"opencode-delivery-context"');
|
||||||
|
|
@ -1248,6 +1256,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
expect(sentText).toContain('agent-teams_member_work_sync_status');
|
expect(sentText).toContain('agent-teams_member_work_sync_status');
|
||||||
expect(sentText).toContain('agent-teams_member_work_sync_report');
|
expect(sentText).toContain('agent-teams_member_work_sync_report');
|
||||||
expect(sentText).toContain('mcp__agent-teams__member_work_sync_report');
|
expect(sentText).toContain('mcp__agent-teams__member_work_sync_report');
|
||||||
|
expect(sentText).toContain('For agenda sync, only agent-teams_member_work_sync_report');
|
||||||
|
expect(sentText).not.toContain('Concrete task progress');
|
||||||
|
expect(sentText).toContain('If this delivered message assigns implementation');
|
||||||
|
expect(sentText).toContain(
|
||||||
|
'you may inspect, read/search, and edit files in the project working directory as your available tools allow'
|
||||||
|
);
|
||||||
expect(sentText).toContain('A status-only tool call is incomplete');
|
expect(sentText).toContain('A status-only tool call is incomplete');
|
||||||
expect(sentText).toContain('teamName="team-a"');
|
expect(sentText).toContain('teamName="team-a"');
|
||||||
expect(sentText).toContain('memberName="bob"');
|
expect(sentText).toContain('memberName="bob"');
|
||||||
|
|
@ -1296,6 +1310,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
expect(sentText).toContain('"workSyncReviewRequestEventIds":["evt-review-request"]');
|
expect(sentText).toContain('"workSyncReviewRequestEventIds":["evt-review-request"]');
|
||||||
expect(sentText).toContain('targeted member-work-sync review pickup nudge');
|
expect(sentText).toContain('targeted member-work-sync review pickup nudge');
|
||||||
expect(sentText).toContain('review workflow tools');
|
expect(sentText).toContain('review workflow tools');
|
||||||
|
expect(sentText).toContain('Review workflow tool usage');
|
||||||
|
expect(sentText).not.toContain('Concrete review progress');
|
||||||
expect(sentText).toContain('Do not mark the review complete from this prompt alone.');
|
expect(sentText).toContain('Do not mark the review complete from this prompt alone.');
|
||||||
expect(sentText).toContain('agent-teams_member_work_sync_report');
|
expect(sentText).toContain('agent-teams_member_work_sync_report');
|
||||||
expect(sentText).toContain('A status-only tool call is incomplete');
|
expect(sentText).toContain('A status-only tool call is incomplete');
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,32 @@ type RuntimeTelemetryProcessTableRow = RuntimeProcessTableRow & {
|
||||||
runtimeTelemetrySource?: 'native' | 'wsl' | 'windows-host';
|
runtimeTelemetrySource?: 'native' | 'wsl' | 'windows-host';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LeadWorkSyncTestTaskRef = { taskId: string; displayId?: string; teamName?: string };
|
||||||
|
type LeadWorkSyncTestInboxMessage = {
|
||||||
|
from: string;
|
||||||
|
to?: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: string;
|
||||||
|
messageId: string;
|
||||||
|
read: boolean;
|
||||||
|
messageKind?: string;
|
||||||
|
taskRefs?: LeadWorkSyncTestTaskRef[];
|
||||||
|
};
|
||||||
|
type LeadWorkSyncReadCommitTestHarness = {
|
||||||
|
hasAcceptedLeadWorkSyncReport(input: { teamName: string; leadName: string }): Promise<boolean>;
|
||||||
|
getLeadRelayReadCommitBatch(input: {
|
||||||
|
teamName: string;
|
||||||
|
leadName: string;
|
||||||
|
batch: LeadWorkSyncTestInboxMessage[];
|
||||||
|
}): Promise<LeadWorkSyncTestInboxMessage[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function leadWorkSyncReadCommitHarness(
|
||||||
|
svc: TeamProvisioningService
|
||||||
|
): LeadWorkSyncReadCommitTestHarness {
|
||||||
|
return svc as unknown as LeadWorkSyncReadCommitTestHarness;
|
||||||
|
}
|
||||||
|
|
||||||
function restoreRuntimePidusageTelemetryEnv() {
|
function restoreRuntimePidusageTelemetryEnv() {
|
||||||
if (ORIGINAL_RUNTIME_PIDUSAGE_ENABLED === undefined) {
|
if (ORIGINAL_RUNTIME_PIDUSAGE_ENABLED === undefined) {
|
||||||
delete process.env.CLAUDE_TEAM_RUNTIME_PIDUSAGE_ENABLED;
|
delete process.env.CLAUDE_TEAM_RUNTIME_PIDUSAGE_ENABLED;
|
||||||
|
|
@ -11711,6 +11737,67 @@ describe('TeamProvisioningService', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps legacy OpenCode work-sync delivery pending without accepted report proof', async () => {
|
||||||
|
const previous = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG;
|
||||||
|
process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = '0';
|
||||||
|
try {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'responded_non_visible_tool' as const,
|
||||||
|
deliveredUserMessageId: 'oc-user-legacy-work-sync',
|
||||||
|
assistantMessageId: 'oc-assistant-legacy-work-sync',
|
||||||
|
toolCallNames: ['member_work_sync_status', 'member_work_sync_report'],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||||
|
svc.setMemberWorkSyncAcceptedReportChecker(async () => false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'Work sync check for #task-1.',
|
||||||
|
messageId: 'msg-legacy-work-sync-report',
|
||||||
|
replyRecipient: 'team-lead',
|
||||||
|
actionMode: 'do',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
taskRefs: [
|
||||||
|
{
|
||||||
|
taskId: 'task-1',
|
||||||
|
displayId: 'task-1',
|
||||||
|
teamName: 'team-a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: 'watcher',
|
||||||
|
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: true,
|
||||||
|
responseState: 'responded_non_visible_tool',
|
||||||
|
reason: 'member_work_sync_report_required',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('retries OpenCode direct asks after non-visible tool activity with an explicit retry header', async () => {
|
it('retries OpenCode direct asks after non-visible tool activity with an explicit retry header', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -11977,6 +12064,7 @@ describe('TeamProvisioningService', () => {
|
||||||
}));
|
}));
|
||||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||||
svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123');
|
svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123');
|
||||||
|
svc.setMemberWorkSyncAcceptedReportChecker(async () => true);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
|
@ -12010,6 +12098,155 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts member work sync report proof even when OpenCode also sends a visible reply', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const taskRef = {
|
||||||
|
taskId: 'task-1',
|
||||||
|
displayId: 'task-1',
|
||||||
|
teamName: 'team-a',
|
||||||
|
};
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
prePromptCursor: 'cursor-before',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'responded_visible_message' as const,
|
||||||
|
deliveredUserMessageId: 'oc-user-work-sync-report-visible',
|
||||||
|
assistantMessageId: 'oc-assistant-work-sync-report-visible',
|
||||||
|
toolCallNames: ['member_work_sync_status', 'member_work_sync_report', 'message_send'],
|
||||||
|
visibleMessageToolCallId: 'call-visible-work-sync-report',
|
||||||
|
visibleReplyMessageId: 'visible-work-sync-report-reply',
|
||||||
|
visibleReplyCorrelation: 'relayOfMessageId' as const,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'responded_visible_message' as const,
|
||||||
|
deliveredUserMessageId: 'oc-user-work-sync-report-visible',
|
||||||
|
assistantMessageId: 'oc-assistant-work-sync-report-visible',
|
||||||
|
toolCallNames: ['member_work_sync_status', 'member_work_sync_report', 'message_send'],
|
||||||
|
visibleMessageToolCallId: 'call-visible-work-sync-report',
|
||||||
|
visibleReplyMessageId: 'visible-work-sync-report-reply',
|
||||||
|
visibleReplyCorrelation: 'relayOfMessageId' as const,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
await configureOpenCodeBobDeliveryService({
|
||||||
|
svc,
|
||||||
|
sendMessageToMember,
|
||||||
|
observeMessageDelivery,
|
||||||
|
});
|
||||||
|
svc.setMemberWorkSyncAcceptedReportChecker(async () => true);
|
||||||
|
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
|
||||||
|
await fsPromises.mkdir(inboxDir, { recursive: true });
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
path.join(inboxDir, 'team-lead.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
from: 'bob',
|
||||||
|
to: 'team-lead',
|
||||||
|
text: 'I reported that I am still working on task-1.',
|
||||||
|
timestamp: '2026-04-25T10:00:01.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'visible-work-sync-report-reply',
|
||||||
|
relayOfMessageId: 'msg-work-sync-report-visible',
|
||||||
|
source: 'runtime_delivery',
|
||||||
|
taskRefs: [taskRef],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'Work sync check for #task-1.',
|
||||||
|
messageId: 'msg-work-sync-report-visible',
|
||||||
|
replyRecipient: 'team-lead',
|
||||||
|
actionMode: 'do',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
taskRefs: [taskRef],
|
||||||
|
source: 'watcher',
|
||||||
|
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: false,
|
||||||
|
responseState: 'responded_visible_message',
|
||||||
|
ledgerStatus: 'responded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps OpenCode member work sync report pending until the report is accepted', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
prePromptCursor: 'cursor-before',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'responded_non_visible_tool' as const,
|
||||||
|
deliveredUserMessageId: 'oc-user-work-sync-rejected-report',
|
||||||
|
assistantMessageId: 'oc-assistant-work-sync-rejected-report',
|
||||||
|
toolCallNames: ['member_work_sync_status', 'member_work_sync_report'],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||||
|
svc.setMemberWorkSyncAcceptedReportChecker(async () => false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'Work sync check for #task-1.',
|
||||||
|
messageId: 'msg-work-sync-rejected-report',
|
||||||
|
replyRecipient: 'team-lead',
|
||||||
|
actionMode: 'do',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
taskRefs: [
|
||||||
|
{
|
||||||
|
taskId: 'task-1',
|
||||||
|
displayId: 'task-1',
|
||||||
|
teamName: 'team-a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: 'watcher',
|
||||||
|
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: true,
|
||||||
|
responseState: 'responded_non_visible_tool',
|
||||||
|
ledgerStatus: 'retry_scheduled',
|
||||||
|
reason: 'member_work_sync_report_required',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts review workflow tools as review pickup delivery response proof', async () => {
|
it('accepts review workflow tools as review pickup delivery response proof', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -12062,6 +12299,145 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'plain text',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'responded_plain_text' as const,
|
||||||
|
deliveredUserMessageId: 'oc-user-work-sync-plain',
|
||||||
|
assistantMessageId: 'oc-assistant-work-sync-plain',
|
||||||
|
toolCallNames: [],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: 'I am still working on task-1 and will continue now.',
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'visible message',
|
||||||
|
seedVisibleReply: true,
|
||||||
|
responseObservation: {
|
||||||
|
state: 'responded_visible_message' as const,
|
||||||
|
deliveredUserMessageId: 'oc-user-work-sync-visible',
|
||||||
|
assistantMessageId: 'oc-assistant-work-sync-visible',
|
||||||
|
toolCallNames: ['agent-teams_message_send'],
|
||||||
|
visibleMessageToolCallId: 'call-visible-work-sync',
|
||||||
|
visibleReplyMessageId: 'visible-work-sync-reply',
|
||||||
|
visibleReplyCorrelation: 'relayOfMessageId' as const,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'task tool',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'responded_non_visible_tool' as const,
|
||||||
|
deliveredUserMessageId: 'oc-user-work-sync-task-tool',
|
||||||
|
assistantMessageId: 'oc-assistant-work-sync-task-tool',
|
||||||
|
toolCallNames: ['task_start'],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agenda-sync review tool',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'responded_non_visible_tool' as const,
|
||||||
|
deliveredUserMessageId: 'oc-user-work-sync-review-tool',
|
||||||
|
assistantMessageId: 'oc-assistant-work-sync-review-tool',
|
||||||
|
toolCallNames: ['review_start'],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'keeps member work sync $name OpenCode deliveries pending without report proof',
|
||||||
|
async ({ responseObservation, seedVisibleReply }) => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const taskRef = {
|
||||||
|
taskId: 'task-1',
|
||||||
|
displayId: 'task-1',
|
||||||
|
teamName: 'team-a',
|
||||||
|
};
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
prePromptCursor: 'cursor-before',
|
||||||
|
responseObservation,
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
responseObservation,
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
await configureOpenCodeBobDeliveryService({
|
||||||
|
svc,
|
||||||
|
sendMessageToMember,
|
||||||
|
observeMessageDelivery,
|
||||||
|
});
|
||||||
|
if (seedVisibleReply) {
|
||||||
|
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
|
||||||
|
await fsPromises.mkdir(inboxDir, { recursive: true });
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
path.join(inboxDir, 'team-lead.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
from: 'bob',
|
||||||
|
to: 'team-lead',
|
||||||
|
text: 'I am still working on task-1 and will continue now.',
|
||||||
|
timestamp: '2026-04-25T10:00:01.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'visible-work-sync-reply',
|
||||||
|
relayOfMessageId: 'msg-work-sync-without-report-proof',
|
||||||
|
source: 'runtime_delivery',
|
||||||
|
taskRefs: [taskRef],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'Work sync check for #task-1.',
|
||||||
|
messageId: 'msg-work-sync-without-report-proof',
|
||||||
|
replyRecipient: 'team-lead',
|
||||||
|
actionMode: 'do',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
taskRefs: [taskRef],
|
||||||
|
source: 'watcher',
|
||||||
|
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: true,
|
||||||
|
responseState: responseObservation.state,
|
||||||
|
ledgerStatus: 'retry_scheduled',
|
||||||
|
reason: 'member_work_sync_report_required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
it('keeps member work sync status-only OpenCode deliveries pending', async () => {
|
it('keeps member work sync status-only OpenCode deliveries pending', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -12109,7 +12485,7 @@ describe('TeamProvisioningService', () => {
|
||||||
responsePending: true,
|
responsePending: true,
|
||||||
responseState: 'responded_non_visible_tool',
|
responseState: 'responded_non_visible_tool',
|
||||||
ledgerStatus: 'retry_scheduled',
|
ledgerStatus: 'retry_scheduled',
|
||||||
reason: 'non_visible_tool_without_task_progress',
|
reason: 'member_work_sync_report_required',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -23945,6 +24321,103 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps lead work-sync inbox rows unread without accepted report or recovery', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const harness = leadWorkSyncReadCommitHarness(svc);
|
||||||
|
const normalMessage = {
|
||||||
|
from: 'alice',
|
||||||
|
to: 'team-lead',
|
||||||
|
text: 'Please check task-1.',
|
||||||
|
timestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
messageId: 'msg-normal',
|
||||||
|
read: false,
|
||||||
|
};
|
||||||
|
const workSyncMessage = {
|
||||||
|
from: 'system',
|
||||||
|
to: 'team-lead',
|
||||||
|
text: 'Work sync required.',
|
||||||
|
timestamp: '2026-04-25T10:00:01.000Z',
|
||||||
|
messageId: 'msg-work-sync',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName: 'team-a' }],
|
||||||
|
read: false,
|
||||||
|
};
|
||||||
|
vi.spyOn(harness, 'hasAcceptedLeadWorkSyncReport').mockResolvedValue(false);
|
||||||
|
|
||||||
|
const readCommitBatch = await harness.getLeadRelayReadCommitBatch({
|
||||||
|
teamName: 'team-a',
|
||||||
|
leadName: 'team-lead',
|
||||||
|
batch: [normalMessage, workSyncMessage],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readCommitBatch).toEqual([normalMessage]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('read-commits lead work-sync inbox rows after accepted report proof', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const harness = leadWorkSyncReadCommitHarness(svc);
|
||||||
|
const recoveryScheduler = vi.fn();
|
||||||
|
const workSyncMessage = {
|
||||||
|
from: 'system',
|
||||||
|
to: 'team-lead',
|
||||||
|
text: 'Work sync required.',
|
||||||
|
timestamp: '2026-04-25T10:00:01.000Z',
|
||||||
|
messageId: 'msg-work-sync',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName: 'team-a' }],
|
||||||
|
read: false,
|
||||||
|
};
|
||||||
|
vi.spyOn(harness, 'hasAcceptedLeadWorkSyncReport').mockResolvedValue(true);
|
||||||
|
svc.setMemberWorkSyncProofMissingRecoveryScheduler(recoveryScheduler);
|
||||||
|
|
||||||
|
const readCommitBatch = await harness.getLeadRelayReadCommitBatch({
|
||||||
|
teamName: 'team-a',
|
||||||
|
leadName: 'team-lead',
|
||||||
|
batch: [workSyncMessage],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readCommitBatch).toEqual([workSyncMessage]);
|
||||||
|
expect(recoveryScheduler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('read-commits lead work-sync inbox rows when proof-missing recovery is queued', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const harness = leadWorkSyncReadCommitHarness(svc);
|
||||||
|
const recoveryScheduler = vi.fn(async () => ({
|
||||||
|
scheduled: true,
|
||||||
|
reason: 'scheduled',
|
||||||
|
intentKey: 'proof-missing:msg-work-sync',
|
||||||
|
}));
|
||||||
|
const taskRefs = [{ taskId: 'task-1', displayId: 'task-1', teamName: 'team-a' }];
|
||||||
|
const workSyncMessage = {
|
||||||
|
from: 'system',
|
||||||
|
to: 'team-lead',
|
||||||
|
text: 'Work sync required.',
|
||||||
|
timestamp: '2026-04-25T10:00:01.000Z',
|
||||||
|
messageId: 'msg-work-sync',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
taskRefs,
|
||||||
|
read: false,
|
||||||
|
};
|
||||||
|
vi.spyOn(harness, 'hasAcceptedLeadWorkSyncReport').mockResolvedValue(false);
|
||||||
|
svc.setMemberWorkSyncProofMissingRecoveryScheduler(recoveryScheduler);
|
||||||
|
|
||||||
|
const readCommitBatch = await harness.getLeadRelayReadCommitBatch({
|
||||||
|
teamName: 'team-a',
|
||||||
|
leadName: 'team-lead',
|
||||||
|
batch: [workSyncMessage],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readCommitBatch).toEqual([workSyncMessage]);
|
||||||
|
expect(recoveryScheduler).toHaveBeenCalledWith({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'team-lead',
|
||||||
|
originalMessageId: 'msg-work-sync',
|
||||||
|
taskRefs,
|
||||||
|
reason: 'lead_member_work_sync_report_required',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('applies an unseen newer failure signal and transitions the member to failed_to_start', async () => {
|
it('applies an unseen newer failure signal and transitions the member to failed_to_start', async () => {
|
||||||
const latestHeartbeatAt = '2026-04-16T10:00:00.000Z';
|
const latestHeartbeatAt = '2026-04-16T10:00:00.000Z';
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
|
|
|
||||||
|
|
@ -1015,7 +1015,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
|
'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.'
|
||||||
);
|
);
|
||||||
expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):');
|
expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future lead turns):');
|
||||||
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
|
||||||
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue