4018 lines
125 KiB
TypeScript
4018 lines
125 KiB
TypeScript
import { buildMemberWorkSyncOutboxEnsureInput } from '@features/member-work-sync/core/domain';
|
|
import {
|
|
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
|
createMemberWorkSyncFeature,
|
|
} from '@features/member-work-sync/main';
|
|
import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore';
|
|
import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths';
|
|
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
|
|
import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment';
|
|
import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const tempRoots: string[] = [];
|
|
|
|
function makeTempRoot(): string {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'member-work-sync-feature-'));
|
|
tempRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
afterEach(() => {
|
|
setClaudeBasePathOverride(null);
|
|
for (const root of tempRoots.splice(0)) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function seedShadowReadyMetrics(input: {
|
|
teamsBasePath: string;
|
|
teamName: string;
|
|
memberName: string;
|
|
}): Promise<void> {
|
|
const metricsPath = path.join(
|
|
input.teamsBasePath,
|
|
input.teamName,
|
|
'.member-work-sync',
|
|
'indexes',
|
|
'metrics.json'
|
|
);
|
|
await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true });
|
|
await fs.promises.writeFile(
|
|
metricsPath,
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 2,
|
|
members: {
|
|
[input.memberName]: {
|
|
memberName: input.memberName,
|
|
state: 'caught_up',
|
|
agendaFingerprint: 'agenda:v1:seed',
|
|
actionableCount: 0,
|
|
evaluatedAt: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
},
|
|
recentEvents: Array.from({ length: 20 }, (_, index) => ({
|
|
id: `seed-status-${index}`,
|
|
teamName: input.teamName,
|
|
memberName: input.memberName,
|
|
kind: 'status_evaluated',
|
|
state: 'caught_up',
|
|
agendaFingerprint: `agenda:v1:seed-${index}`,
|
|
recordedAt: new Date(Date.UTC(2026, 0, 1, index)).toISOString(),
|
|
actionableCount: 0,
|
|
})),
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
async function seedNonBlockingShadowCollectingMetrics(input: {
|
|
teamsBasePath: string;
|
|
teamName: string;
|
|
memberName: string;
|
|
}): Promise<void> {
|
|
const metricsPath = path.join(
|
|
input.teamsBasePath,
|
|
input.teamName,
|
|
'.member-work-sync',
|
|
'indexes',
|
|
'metrics.json'
|
|
);
|
|
await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true });
|
|
await fs.promises.writeFile(
|
|
metricsPath,
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 2,
|
|
members: {
|
|
[input.memberName]: {
|
|
memberName: input.memberName,
|
|
state: 'caught_up',
|
|
agendaFingerprint: 'agenda:v1:seed',
|
|
actionableCount: 0,
|
|
evaluatedAt: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
},
|
|
recentEvents: Array.from({ length: 18 }, (_, index) => ({
|
|
id: `seed-status-${index}`,
|
|
teamName: input.teamName,
|
|
memberName: input.memberName,
|
|
kind: 'status_evaluated',
|
|
state: 'caught_up',
|
|
agendaFingerprint: `agenda:v1:seed-${index}`,
|
|
recordedAt: new Date(Date.UTC(2026, 0, 1, index * 6)).toISOString(),
|
|
actionableCount: 0,
|
|
})),
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
async function seedBlockingShadowCollectingMetrics(input: {
|
|
teamsBasePath: string;
|
|
teamName: string;
|
|
memberName: string;
|
|
}): Promise<void> {
|
|
const nowMs = Date.now();
|
|
const firstObservedAt = new Date(nowMs - 1_000).toISOString();
|
|
const secondObservedAt = new Date(nowMs).toISOString();
|
|
const metricsPath = path.join(
|
|
input.teamsBasePath,
|
|
input.teamName,
|
|
'.member-work-sync',
|
|
'indexes',
|
|
'metrics.json'
|
|
);
|
|
await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true });
|
|
await fs.promises.writeFile(
|
|
metricsPath,
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 2,
|
|
members: {
|
|
[input.memberName]: {
|
|
memberName: input.memberName,
|
|
state: 'needs_sync',
|
|
agendaFingerprint: 'agenda:v1:seed',
|
|
actionableCount: 1,
|
|
evaluatedAt: firstObservedAt,
|
|
},
|
|
},
|
|
recentEvents: [
|
|
{
|
|
id: 'seed-status-0',
|
|
teamName: input.teamName,
|
|
memberName: input.memberName,
|
|
kind: 'status_evaluated',
|
|
state: 'needs_sync',
|
|
agendaFingerprint: 'agenda:v1:seed',
|
|
recordedAt: firstObservedAt,
|
|
actionableCount: 1,
|
|
},
|
|
{
|
|
id: 'seed-would-nudge-0',
|
|
teamName: input.teamName,
|
|
memberName: input.memberName,
|
|
kind: 'would_nudge',
|
|
state: 'needs_sync',
|
|
agendaFingerprint: 'agenda:v1:seed',
|
|
recordedAt: secondObservedAt,
|
|
actionableCount: 1,
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
async function seedNativeStaleInProgressBlockingMetrics(input: {
|
|
teamsBasePath: string;
|
|
teamName: string;
|
|
memberName: string;
|
|
agendaFingerprint: string;
|
|
}): Promise<void> {
|
|
const nowMs = Date.now();
|
|
const staleObservedAt = new Date(nowMs - 6 * 60_000 - 1_000).toISOString();
|
|
const metricsPath = path.join(
|
|
input.teamsBasePath,
|
|
input.teamName,
|
|
'.member-work-sync',
|
|
'indexes',
|
|
'metrics.json'
|
|
);
|
|
await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true });
|
|
await fs.promises.writeFile(
|
|
metricsPath,
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 2,
|
|
members: {
|
|
[input.memberName]: {
|
|
memberName: input.memberName,
|
|
state: 'needs_sync',
|
|
agendaFingerprint: input.agendaFingerprint,
|
|
actionableCount: 1,
|
|
evaluatedAt: staleObservedAt,
|
|
providerId: 'codex',
|
|
},
|
|
},
|
|
recentEvents: [
|
|
{
|
|
id: 'native-stale-status',
|
|
teamName: input.teamName,
|
|
memberName: input.memberName,
|
|
kind: 'status_evaluated',
|
|
state: 'needs_sync',
|
|
agendaFingerprint: input.agendaFingerprint,
|
|
recordedAt: staleObservedAt,
|
|
actionableCount: 1,
|
|
providerId: 'codex',
|
|
},
|
|
...Array.from({ length: 12 }, (_, index) => ({
|
|
id: `native-stale-would-nudge-${index}`,
|
|
teamName: input.teamName,
|
|
memberName: input.memberName,
|
|
kind: 'would_nudge',
|
|
state: 'needs_sync',
|
|
agendaFingerprint: input.agendaFingerprint,
|
|
recordedAt: new Date(nowMs - 5 * 60_000 + index * 5_000).toISOString(),
|
|
actionableCount: 1,
|
|
providerId: 'codex',
|
|
})),
|
|
],
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
|
|
const deadline = Date.now() + 5_000;
|
|
let lastError: unknown;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await assertion();
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
}
|
|
if (lastError) {
|
|
throw lastError;
|
|
}
|
|
await assertion();
|
|
}
|
|
|
|
async function waitForQueueIdle(
|
|
feature: ReturnType<typeof createMemberWorkSyncFeature>
|
|
): Promise<void> {
|
|
await waitForAssertion(() => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({
|
|
queued: 0,
|
|
running: 0,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function readInboxMessages(input: {
|
|
teamsBasePath: string;
|
|
teamName: string;
|
|
memberName: string;
|
|
}): Promise<Array<{ messageId?: string; messageKind?: string; text?: string }>> {
|
|
const inboxPath = path.join(
|
|
input.teamsBasePath,
|
|
input.teamName,
|
|
'inboxes',
|
|
`${input.memberName}.json`
|
|
);
|
|
let raw: string;
|
|
try {
|
|
raw = await fs.promises.readFile(inboxPath, 'utf8');
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === 'ENOENT' || code === 'EISDIR') {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
return Array.isArray(parsed)
|
|
? parsed.filter(
|
|
(item): item is { messageId?: string; messageKind?: string; text?: string } =>
|
|
Boolean(item) && typeof item === 'object'
|
|
)
|
|
: [];
|
|
}
|
|
|
|
async function readMemberOutboxItems(input: {
|
|
teamsBasePath: string;
|
|
teamName: string;
|
|
memberName: string;
|
|
}): Promise<
|
|
Record<
|
|
string,
|
|
{ status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string }
|
|
>
|
|
> {
|
|
const outboxPath = path.join(
|
|
input.teamsBasePath,
|
|
input.teamName,
|
|
'members',
|
|
input.memberName,
|
|
'.member-work-sync',
|
|
'outbox.json'
|
|
);
|
|
let raw: string;
|
|
try {
|
|
raw = await fs.promises.readFile(outboxPath, 'utf8');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return {};
|
|
}
|
|
throw error;
|
|
}
|
|
const parsed = JSON.parse(raw) as {
|
|
items?: Record<string, { status?: string; lastError?: string }>;
|
|
};
|
|
return parsed.items ?? {};
|
|
}
|
|
|
|
async function forceRetryableOutboxDue(input: {
|
|
teamsBasePath: string;
|
|
teamName: string;
|
|
memberName: string;
|
|
nextAttemptAt: 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; nextAttemptAt?: string; updatedAt?: string }>;
|
|
};
|
|
let touched = 0;
|
|
for (const item of Object.values(parsed.items ?? {})) {
|
|
if (item.status === 'failed_retryable') {
|
|
item.nextAttemptAt = input.nextAttemptAt;
|
|
item.updatedAt = input.nextAttemptAt;
|
|
touched += 1;
|
|
}
|
|
}
|
|
expect(touched).toBeGreaterThan(0);
|
|
await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
|
|
await fs.promises.rm(
|
|
path.join(
|
|
input.teamsBasePath,
|
|
input.teamName,
|
|
'.member-work-sync',
|
|
'indexes',
|
|
'outbox-index.json'
|
|
),
|
|
{ force: true }
|
|
);
|
|
}
|
|
|
|
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', () => {
|
|
it('schedules proof-missing recovery through the work-sync queue', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName }],
|
|
})),
|
|
} as never,
|
|
taskReader: { getTasks: vi.fn(async () => []) } as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })),
|
|
} as never,
|
|
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
feature.scheduleProofMissingRecovery({
|
|
teamName,
|
|
memberName,
|
|
originalMessageId: 'message-1',
|
|
taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }],
|
|
reason: 'OpenCode proof missing',
|
|
})
|
|
).resolves.toMatchObject({
|
|
scheduled: true,
|
|
reason: 'scheduled',
|
|
intentKey: 'proof-missing:message-1',
|
|
});
|
|
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({
|
|
queued: 1,
|
|
queuedItems: [
|
|
{
|
|
teamName,
|
|
memberName,
|
|
triggerReasons: ['proof_missing_recovery'],
|
|
},
|
|
],
|
|
});
|
|
await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual(
|
|
[]
|
|
);
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('coalesces proof-missing recovery when a recent matching outbox item exists', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName }],
|
|
})),
|
|
} as never,
|
|
taskReader: { getTasks: vi.fn(async () => []) } as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })),
|
|
} as never,
|
|
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
|
|
});
|
|
|
|
try {
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
await store.ensurePending({
|
|
id: 'member-work-sync:team-a:bob:proof-missing:message-1',
|
|
teamName,
|
|
memberName,
|
|
agendaFingerprint: 'agenda:v1:test',
|
|
payloadHash: 'payload-hash',
|
|
payload: {
|
|
from: 'system',
|
|
to: memberName,
|
|
messageKind: 'member_work_sync_nudge',
|
|
source: 'member-work-sync',
|
|
actionMode: 'do',
|
|
workSyncIntent: 'agenda_sync',
|
|
workSyncIntentKey: 'proof-missing:message-1',
|
|
text: 'Recover proof',
|
|
taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }],
|
|
},
|
|
nowIso: new Date().toISOString(),
|
|
});
|
|
|
|
await expect(
|
|
feature.scheduleProofMissingRecovery({
|
|
teamName,
|
|
memberName,
|
|
originalMessageId: 'message-1',
|
|
taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }],
|
|
})
|
|
).resolves.toMatchObject({
|
|
scheduled: false,
|
|
reason: 'coalesced_recent',
|
|
existingOutboxId: 'member-work-sync:team-a:bob:proof-missing:message-1',
|
|
});
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ queued: 0 });
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('does not schedule broad proof-missing recovery without task refs', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName }],
|
|
})),
|
|
} as never,
|
|
taskReader: { getTasks: vi.fn(async () => []) } as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({ teamName, reviewers: [], tasks: {} })),
|
|
} as never,
|
|
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
feature.scheduleProofMissingRecovery({
|
|
teamName,
|
|
memberName,
|
|
originalMessageId: 'message-1',
|
|
})
|
|
).resolves.toMatchObject({
|
|
scheduled: false,
|
|
reason: 'invalid',
|
|
});
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ queued: 0 });
|
|
await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual(
|
|
[]
|
|
);
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('dispatches a due nudge through the real outbox and inbox by default', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
]),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const status = await feature.refreshStatus({ teamName, memberName });
|
|
expect(status).toMatchObject({
|
|
state: 'needs_sync',
|
|
shadow: { wouldNudge: true },
|
|
});
|
|
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
|
phase2Readiness: { state: 'shadow_ready' },
|
|
});
|
|
|
|
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
|
|
status,
|
|
hash: new NodeHashAdapter(),
|
|
nowIso: status.evaluatedAt,
|
|
});
|
|
expect(outboxInput).not.toBeNull();
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
|
|
ok: true,
|
|
outcome: 'existing',
|
|
});
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
await expect(
|
|
fs.promises.readFile(path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`), {
|
|
encoding: 'utf8',
|
|
})
|
|
).resolves.toContain(outboxInput!.id);
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('suppresses queued proof-missing recovery when the original delivery is no longer proof-missing', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const proofMissingRecoveryGuard = {
|
|
shouldDispatch: vi.fn(async () => ({
|
|
ok: false as const,
|
|
reason: 'proof_missing_recovery_suppressed',
|
|
retryable: false,
|
|
})),
|
|
};
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
]),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
proofMissingRecoveryGuard,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const status = await feature.refreshStatus({ teamName, memberName });
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
await expect(
|
|
store.ensurePending({
|
|
id: 'member-work-sync:team-a:bob:proof-missing:message-1',
|
|
teamName,
|
|
memberName,
|
|
agendaFingerprint: status.agenda.fingerprint,
|
|
payloadHash: 'payload-hash',
|
|
payload: {
|
|
from: 'system',
|
|
to: memberName,
|
|
messageKind: 'member_work_sync_nudge',
|
|
source: 'member-work-sync',
|
|
actionMode: 'do',
|
|
workSyncIntent: 'agenda_sync',
|
|
workSyncIntentKey: 'proof-missing:message-1',
|
|
text: 'Recover proof',
|
|
taskRefs: [{ taskId: 'task-1', displayId: '11111111', teamName }],
|
|
},
|
|
nowIso: status.evaluatedAt,
|
|
})
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
outcome: 'created',
|
|
});
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 2,
|
|
delivered: 1,
|
|
superseded: 1,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
expect(proofMissingRecoveryGuard.shouldDispatch).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
teamName,
|
|
memberName,
|
|
intentKey: 'proof-missing:message-1',
|
|
originalMessageId: 'message-1',
|
|
taskIds: ['task-1'],
|
|
})
|
|
);
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('Required sync action');
|
|
expect(nudges[0]?.text).not.toContain('Recover proof');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('does not deliver pending nudges until the team is ready for nudge dispatch', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
let canDispatchNudges = false;
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
]),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
canDispatchNudges: vi.fn(async () => canDispatchNudges),
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const status = await feature.refreshStatus({ teamName, memberName });
|
|
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
|
|
status,
|
|
hash: new NodeHashAdapter(),
|
|
nowIso: status.evaluatedAt,
|
|
});
|
|
expect(outboxInput).not.toBeNull();
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
|
|
ok: true,
|
|
outcome: 'existing',
|
|
});
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 0,
|
|
delivered: 0,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]);
|
|
await expect(
|
|
readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
).resolves.toMatchObject({
|
|
[outboxInput!.id]: { status: 'pending' },
|
|
});
|
|
|
|
canDispatchNudges = true;
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
await expect(
|
|
readInboxMessages({ teamsBasePath, teamName, memberName })
|
|
).resolves.toMatchObject([{ messageId: outboxInput!.id }]);
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('plans and dispatches due nudges after queued reconcile by default', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
]),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({
|
|
type: 'task',
|
|
teamName,
|
|
taskId: 'task-1',
|
|
} as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
const inbox = await fs.promises.readFile(
|
|
path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`),
|
|
'utf8'
|
|
);
|
|
expect(inbox).toContain('member_work_sync_nudge');
|
|
expect(inbox).toContain(`member-work-sync:${teamName}:${memberName}:agenda:v1:`);
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('drains runtime turn-settled files into queued reconcile and nudge delivery', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'opencode' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync after settled turn',
|
|
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),
|
|
queueQuietWindowMs: 1,
|
|
resolveControlUrl: vi.fn(async () => 'http://127.0.0.1:43123'),
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' });
|
|
const spoolRoot = env?.[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV];
|
|
expect(spoolRoot).toBeTruthy();
|
|
const eventFileName = '20260505T120000000Z-test.opencode.json';
|
|
await fs.promises.writeFile(
|
|
path.join(spoolRoot!, 'incoming', eventFileName),
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
provider: 'opencode',
|
|
source: 'agent-teams-orchestrator-opencode',
|
|
eventName: 'runtime_turn_settled',
|
|
hookEventName: 'Stop',
|
|
sessionId: 'ses-opencode-1',
|
|
runtimePromptMessageId: 'msg_123',
|
|
laneId: 'secondary:opencode:bob',
|
|
memberName,
|
|
teamName,
|
|
cwd: claudeRoot,
|
|
outcome: 'success',
|
|
recordedAt: '2026-05-05T12:00:00.000Z',
|
|
})}\n`,
|
|
'utf8'
|
|
);
|
|
|
|
const drain = await feature.drainRuntimeTurnSettledEvents();
|
|
expect(drain).toMatchObject({
|
|
invalid: 0,
|
|
unresolved: 0,
|
|
});
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
const status = await feature.getStatus({ teamName, memberName });
|
|
expect(status).toMatchObject({
|
|
state: 'needs_sync',
|
|
providerId: 'opencode',
|
|
shadow: {
|
|
wouldNudge: true,
|
|
triggerReasons: ['turn_settled'],
|
|
},
|
|
});
|
|
});
|
|
|
|
const processedMeta = JSON.parse(
|
|
await fs.promises.readFile(
|
|
path.join(spoolRoot!, 'processed', `${eventFileName}.meta.json`),
|
|
'utf8'
|
|
)
|
|
) as { outcome?: string; teamName?: string; memberName?: string };
|
|
expect(processedMeta).toMatchObject({
|
|
outcome: 'enqueued',
|
|
teamName,
|
|
memberName,
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('delivers a status-only recovery nudge when a delivered Codex nudge settles without report proof', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-codex-status-only-recovery';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync after status-only turn',
|
|
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),
|
|
queueQuietWindowMs: 1,
|
|
resolveControlUrl: vi.fn(async () => 'http://127.0.0.1:43123'),
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
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(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudges[0]?.text).toContain('controlUrl "http://127.0.0.1:43123"');
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems).toEqual([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
}),
|
|
]);
|
|
const deliveredOutboxItem = outboxItems[0] as {
|
|
payload?: { workSyncIntentKey?: string };
|
|
};
|
|
expect(deliveredOutboxItem.payload?.workSyncIntentKey).toBeUndefined();
|
|
});
|
|
|
|
feature.noteTeamChange({
|
|
type: 'tool-activity',
|
|
teamName,
|
|
detail: JSON.stringify({
|
|
action: 'start',
|
|
activity: {
|
|
memberName,
|
|
toolUseId: 'status-tool-1',
|
|
toolName: 'member_work_sync_status',
|
|
startedAt: '2026-05-05T12:00:00.000Z',
|
|
source: 'runtime',
|
|
},
|
|
}),
|
|
} as never);
|
|
feature.noteTeamChange({
|
|
type: 'tool-activity',
|
|
teamName,
|
|
detail: JSON.stringify({
|
|
action: 'finish',
|
|
memberName,
|
|
toolUseId: 'status-tool-1',
|
|
finishedAt: new Date().toISOString(),
|
|
}),
|
|
} as never);
|
|
|
|
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' });
|
|
const spoolRoot = env?.[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV];
|
|
expect(spoolRoot).toBeTruthy();
|
|
const eventFileName = '20260505T120001000Z-status-only.codex.json';
|
|
await fs.promises.writeFile(
|
|
path.join(spoolRoot!, 'incoming', eventFileName),
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
provider: 'codex',
|
|
source: 'agent-teams-orchestrator-codex-native',
|
|
eventName: 'runtime_turn_settled',
|
|
hookEventName: 'Stop',
|
|
sessionId: 'ses-codex-1',
|
|
memberName,
|
|
teamName,
|
|
cwd: claudeRoot,
|
|
outcome: 'success',
|
|
recordedAt: '2026-05-05T12:00:01.000Z',
|
|
})}\n`,
|
|
'utf8'
|
|
);
|
|
|
|
await expect(feature.drainRuntimeTurnSettledEvents()).resolves.toMatchObject({
|
|
claimed: 1,
|
|
enqueued: 1,
|
|
invalid: 0,
|
|
unresolved: 0,
|
|
});
|
|
|
|
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('status-only');
|
|
expect(nudges[1]?.text).toContain('previous work-sync turn appears to have stopped');
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
payload: expect.objectContaining({
|
|
workSyncIntentKey: expect.stringContaining('status-only:'),
|
|
}),
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('delivers targeted OpenCode nudges during shadow collection and schedules a delivery wake', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-opencode-targeted';
|
|
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: 'opencode' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship OpenCode targeted nudge',
|
|
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 seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
|
|
teamName,
|
|
memberName,
|
|
messageId: nudges[0]?.messageId,
|
|
providerId: 'opencode',
|
|
reason: 'member_work_sync_nudge_inserted',
|
|
delayMs: 500,
|
|
});
|
|
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
|
phase2Readiness: { state: 'collecting_shadow_data' },
|
|
});
|
|
await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({
|
|
state: 'needs_sync',
|
|
providerId: 'opencode',
|
|
shadow: { wouldNudge: true },
|
|
});
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
deliveredMessageId: nudges[0]?.messageId,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
expect(journal).not.toContain('"reason":"phase2_not_ready"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('delivers Codex inbox-watch nudges while shadow data is still collecting', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-codex-shadow-gated';
|
|
const memberName = 'bob';
|
|
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: 'Keep Codex gated during shadow collection',
|
|
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 seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudges[0]?.text).toContain('mcp__agent-teams__member_work_sync_report');
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
|
|
teamName,
|
|
memberName,
|
|
messageId: nudges[0]?.messageId,
|
|
providerId: 'codex',
|
|
reason: 'member_work_sync_nudge_inserted',
|
|
delayMs: 500,
|
|
});
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
deliveredMessageId: nudges[0]?.messageId,
|
|
}),
|
|
]);
|
|
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
|
phase2Readiness: { state: 'collecting_shadow_data' },
|
|
});
|
|
await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({
|
|
state: 'needs_sync',
|
|
providerId: 'codex',
|
|
shadow: { wouldNudge: true },
|
|
});
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
expect(journal).not.toContain('"reason":"phase2_not_ready"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('delivers native stale in-progress recovery nudges despite noisy global metrics', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-native-stale-in-progress';
|
|
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: 'Review landing',
|
|
status: 'in_progress',
|
|
owner: memberName,
|
|
},
|
|
]),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
nudgeDeliveryWake,
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
let agendaFingerprint = '';
|
|
await waitForAssertion(async () => {
|
|
const status = await feature.getStatus({ teamName, memberName });
|
|
expect(status).toMatchObject({
|
|
state: 'needs_sync',
|
|
providerId: 'codex',
|
|
diagnostics: expect.arrayContaining(['no_current_report']),
|
|
agenda: {
|
|
items: [
|
|
expect.objectContaining({
|
|
reason: 'owned_in_progress_task',
|
|
evidence: expect.objectContaining({ status: 'in_progress' }),
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
agendaFingerprint = status.agenda.fingerprint;
|
|
});
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
|
|
expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled();
|
|
|
|
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(1);
|
|
expect(nudges[0]?.text).toContain('Work sync check');
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
|
|
teamName,
|
|
memberName,
|
|
messageId: nudges[0]?.messageId,
|
|
providerId: 'codex',
|
|
reason: 'member_work_sync_nudge_inserted',
|
|
delayMs: 500,
|
|
});
|
|
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
|
phase2Readiness: {
|
|
reasons: expect.arrayContaining(['would_nudge_rate_high']),
|
|
},
|
|
});
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
deliveredMessageId: nudges[0]?.messageId,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
expect(journal).toContain('"reason":"created"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('delivers native stale pending-work recovery nudges despite noisy global metrics', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-native-stale-pending';
|
|
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: 'Start assigned pending work',
|
|
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 {
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
let agendaFingerprint = '';
|
|
await waitForAssertion(async () => {
|
|
const status = await feature.getStatus({ teamName, memberName });
|
|
expect(status).toMatchObject({
|
|
state: 'needs_sync',
|
|
providerId: 'codex',
|
|
diagnostics: expect.arrayContaining(['no_current_report']),
|
|
agenda: {
|
|
items: [
|
|
expect.objectContaining({
|
|
reason: 'owned_pending_task',
|
|
evidence: expect.objectContaining({ status: 'pending' }),
|
|
}),
|
|
],
|
|
},
|
|
});
|
|
agendaFingerprint = status.agenda.fingerprint;
|
|
});
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
|
|
expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled();
|
|
|
|
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(1);
|
|
expect(nudges[0]?.text).toContain('Work sync check');
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
|
|
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
|
phase2Readiness: {
|
|
reasons: expect.arrayContaining(['would_nudge_rate_high']),
|
|
},
|
|
});
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
expect(journal).not.toContain('"reason":"blocking_metrics"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
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 () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-opencode-blocking-metrics';
|
|
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: 'opencode' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Nudge OpenCode despite noisy global metrics',
|
|
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 seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
|
|
teamName,
|
|
memberName,
|
|
messageId: nudges[0]?.messageId,
|
|
providerId: 'opencode',
|
|
reason: 'member_work_sync_nudge_inserted',
|
|
delayMs: 500,
|
|
});
|
|
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
|
phase2Readiness: {
|
|
reasons: expect.arrayContaining(['would_nudge_rate_high']),
|
|
},
|
|
});
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
expect(journal).not.toContain('"reason":"blocking_metrics"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('delivers targeted lead nudges even when global phase2 metrics are noisy', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-lead-blocking-metrics';
|
|
const memberName = 'team-lead';
|
|
const nudgeDeliveryWake = {
|
|
schedule: vi.fn(async () => undefined),
|
|
};
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex', agentType: 'team-lead' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Resolve lead clarification',
|
|
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 seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
|
|
teamName,
|
|
memberName,
|
|
messageId: nudges[0]?.messageId,
|
|
providerId: 'codex',
|
|
reason: 'member_work_sync_nudge_inserted',
|
|
delayMs: 500,
|
|
});
|
|
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
|
phase2Readiness: {
|
|
reasons: expect.arrayContaining(['would_nudge_rate_high']),
|
|
},
|
|
});
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
expect(journal).not.toContain('"reason":"blocking_metrics"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('keeps targeted OpenCode nudge idempotent after noisy metrics become ready', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-opencode-metrics-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: 'opencode' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Keep OpenCode nudge idempotent after metrics ready',
|
|
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 seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
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(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenLastCalledWith({
|
|
teamName,
|
|
memberName,
|
|
messageId: nudges[0]?.messageId,
|
|
providerId: 'opencode',
|
|
reason: 'member_work_sync_nudge_inserted',
|
|
delayMs: 500,
|
|
});
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
deliveredMessageId: nudges[0]?.messageId,
|
|
}),
|
|
]);
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('keeps targeted OpenCode nudges retryable when prompt delivery is busy', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-opencode-busy';
|
|
const memberName = 'alice';
|
|
const nudgeDeliveryWake = {
|
|
schedule: vi.fn(async () => undefined),
|
|
};
|
|
let promptDeliveryBusy = true;
|
|
const promptDeliveryBusySignal = {
|
|
isBusy: vi.fn(async () =>
|
|
promptDeliveryBusy
|
|
? {
|
|
busy: true,
|
|
reason: 'opencode_prompt_delivery_active',
|
|
retryAfterIso: '2026-05-05T12:05:00.000Z',
|
|
}
|
|
: { busy: false }
|
|
),
|
|
};
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'opencode' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship OpenCode busy nudge',
|
|
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),
|
|
extraBusySignals: [promptDeliveryBusySignal],
|
|
nudgeDeliveryWake,
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
|
|
expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled();
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
status: 'failed_retryable',
|
|
lastError: 'member_busy:opencode_prompt_delivery_active',
|
|
nextAttemptAt: '2026-05-05T12:05:00.000Z',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"member_busy"');
|
|
expect(journal).toContain('"reason":"member_busy:opencode_prompt_delivery_active"');
|
|
expect(journal).not.toContain('"event":"nudge_delivered"');
|
|
|
|
promptDeliveryBusy = false;
|
|
await forceRetryableOutboxDue({
|
|
teamsBasePath,
|
|
teamName,
|
|
memberName,
|
|
nextAttemptAt: new Date(Date.now() - 1_000).toISOString(),
|
|
});
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
await waitForAssertion(async () => {
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1);
|
|
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
|
|
teamName,
|
|
memberName,
|
|
messageId: nudges[0]?.messageId,
|
|
providerId: 'opencode',
|
|
reason: 'member_work_sync_nudge_inserted',
|
|
delayMs: 500,
|
|
});
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
deliveredMessageId: nudges[0]?.messageId,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
const recoveredJournal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(recoveredJournal).toContain('"event":"nudge_delivered"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('keeps nudges gated until shadow readiness is reached, then delivers on the next reconcile', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync after readiness',
|
|
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),
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
|
|
expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({});
|
|
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
|
|
phase2Readiness: { state: 'collecting_shadow_data' },
|
|
});
|
|
});
|
|
|
|
await waitForAssertion(async () => {
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_skipped"');
|
|
expect(journal).toContain('"reason":"blocking_metrics"');
|
|
});
|
|
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
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(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems).toEqual([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
}),
|
|
]);
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('runs the active bounded loop without duplicate nudges across report and fingerprint changes', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
let tasks = [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => tasks),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
let firstStatus = await feature.getStatus({ teamName, memberName });
|
|
await waitForAssertion(async () => {
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
firstStatus = await feature.getStatus({ teamName, memberName });
|
|
expect(firstStatus).toMatchObject({
|
|
state: 'needs_sync',
|
|
providerId: 'codex',
|
|
shadow: { wouldNudge: true },
|
|
});
|
|
expect(firstStatus.reportToken).toBeTruthy();
|
|
});
|
|
|
|
const firstFingerprint = firstStatus.agenda.fingerprint;
|
|
await expect(
|
|
feature.report({
|
|
teamName,
|
|
memberName,
|
|
state: 'still_working',
|
|
agendaFingerprint: firstFingerprint,
|
|
reportToken: firstStatus.reportToken,
|
|
taskIds: ['task-1'],
|
|
source: 'test',
|
|
})
|
|
).resolves.toMatchObject({
|
|
accepted: true,
|
|
status: {
|
|
state: 'still_working',
|
|
report: { accepted: true, state: 'still_working' },
|
|
},
|
|
});
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 0,
|
|
delivered: 0,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
expect(
|
|
(await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
)
|
|
).toHaveLength(1);
|
|
|
|
tasks = [
|
|
...tasks,
|
|
{
|
|
id: 'task-2',
|
|
displayId: '22222222',
|
|
subject: 'Ship follow-up sync',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } as never);
|
|
|
|
let secondStatus = firstStatus;
|
|
await waitForAssertion(async () => {
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(2);
|
|
expect(new Set(nudges.map((message) => message.messageId)).size).toBe(2);
|
|
expect(nudges.at(-1)?.text).toContain('22222222');
|
|
secondStatus = await feature.getStatus({ teamName, memberName });
|
|
expect(secondStatus.state).toBe('needs_sync');
|
|
expect(secondStatus.agenda.fingerprint).not.toBe(firstFingerprint);
|
|
expect(secondStatus.shadow).toMatchObject({
|
|
wouldNudge: true,
|
|
fingerprintChanged: true,
|
|
previousFingerprint: firstFingerprint,
|
|
});
|
|
});
|
|
|
|
const secondTaskIds = secondStatus.agenda.items.map((item) => item.taskId);
|
|
await expect(
|
|
feature.report({
|
|
teamName,
|
|
memberName,
|
|
state: 'still_working',
|
|
agendaFingerprint: secondStatus.agenda.fingerprint,
|
|
reportToken: secondStatus.reportToken,
|
|
taskIds: secondTaskIds,
|
|
source: 'test',
|
|
})
|
|
).resolves.toMatchObject({
|
|
accepted: true,
|
|
status: {
|
|
state: 'still_working',
|
|
report: { accepted: true, taskIds: secondTaskIds },
|
|
},
|
|
});
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
|
|
claimed: 0,
|
|
delivered: 0,
|
|
});
|
|
|
|
tasks = tasks.map((task) => ({ ...task, status: 'completed' }));
|
|
const clearedStatus = await feature.refreshStatus({ teamName, memberName });
|
|
expect(clearedStatus).toMatchObject({
|
|
state: 'caught_up',
|
|
agenda: { items: [] },
|
|
shadow: { wouldNudge: false },
|
|
});
|
|
await expect(
|
|
feature.report({
|
|
teamName,
|
|
memberName,
|
|
state: 'caught_up',
|
|
agendaFingerprint: clearedStatus.agenda.fingerprint,
|
|
reportToken: clearedStatus.reportToken,
|
|
source: 'test',
|
|
})
|
|
).resolves.toMatchObject({
|
|
accepted: true,
|
|
status: {
|
|
state: 'caught_up',
|
|
report: { accepted: true, state: 'caught_up' },
|
|
},
|
|
});
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
|
|
claimed: 0,
|
|
delivered: 0,
|
|
});
|
|
expect(
|
|
(await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
)
|
|
).toHaveLength(2);
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
const events = journal
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => (JSON.parse(line) as { event: string }).event);
|
|
expect(events.filter((event) => event === 'nudge_delivered')).toHaveLength(2);
|
|
expect(events.filter((event) => event === 'report_accepted')).toHaveLength(3);
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('supersedes stale file-backed nudges and rejects stale reports before accepting the current fingerprint', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
let tasks = [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => tasks),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const staleStatus = await feature.refreshStatus({ teamName, memberName });
|
|
expect(staleStatus).toMatchObject({
|
|
state: 'needs_sync',
|
|
shadow: { wouldNudge: true },
|
|
});
|
|
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
|
|
status: staleStatus,
|
|
hash: new NodeHashAdapter(),
|
|
nowIso: staleStatus.evaluatedAt,
|
|
});
|
|
expect(outboxInput).not.toBeNull();
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
|
|
ok: true,
|
|
outcome: 'existing',
|
|
});
|
|
const staleOutboxId = `member-work-sync:${teamName}:${memberName}:${staleStatus.agenda.fingerprint}`;
|
|
await expect(
|
|
readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
).resolves.toMatchObject({
|
|
[staleOutboxId]: { status: 'pending' },
|
|
});
|
|
|
|
tasks = tasks.map((task) => ({ ...task, status: 'completed' }));
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 0,
|
|
superseded: 1,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]);
|
|
await expect(
|
|
readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
).resolves.toMatchObject({
|
|
[staleOutboxId]: {
|
|
status: 'superseded',
|
|
lastError: 'status_no_longer_matches_outbox',
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
feature.report({
|
|
teamName,
|
|
memberName,
|
|
state: 'still_working',
|
|
agendaFingerprint: staleStatus.agenda.fingerprint,
|
|
reportToken: staleStatus.reportToken,
|
|
taskIds: ['task-1'],
|
|
source: 'test',
|
|
})
|
|
).resolves.toMatchObject({
|
|
accepted: false,
|
|
code: 'stale_fingerprint',
|
|
status: {
|
|
state: 'caught_up',
|
|
report: {
|
|
accepted: false,
|
|
rejectionCode: 'stale_fingerprint',
|
|
},
|
|
},
|
|
});
|
|
|
|
const currentStatus = await feature.getStatus({ teamName, memberName });
|
|
await expect(
|
|
feature.report({
|
|
teamName,
|
|
memberName,
|
|
state: 'caught_up',
|
|
agendaFingerprint: currentStatus.agenda.fingerprint,
|
|
reportToken: currentStatus.reportToken,
|
|
source: 'test',
|
|
})
|
|
).resolves.toMatchObject({
|
|
accepted: true,
|
|
status: {
|
|
state: 'caught_up',
|
|
report: { accepted: true, state: 'caught_up' },
|
|
},
|
|
});
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 0,
|
|
delivered: 0,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
const events = journal
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => (JSON.parse(line) as { event: string }).event);
|
|
expect(events).toContain('nudge_superseded');
|
|
expect(events).toContain('report_rejected');
|
|
expect(events).toContain('report_accepted');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('supersedes pending nudges without delivery when the team becomes inactive', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
let teamActive = true;
|
|
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: 'Ship sync before shutdown',
|
|
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 () => teamActive),
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const status = await feature.refreshStatus({ teamName, memberName });
|
|
expect(status).toMatchObject({
|
|
state: 'needs_sync',
|
|
shadow: { wouldNudge: true },
|
|
});
|
|
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
|
|
status,
|
|
hash: new NodeHashAdapter(),
|
|
nowIso: status.evaluatedAt,
|
|
});
|
|
expect(outboxInput).not.toBeNull();
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
|
|
ok: true,
|
|
outcome: 'existing',
|
|
});
|
|
|
|
teamActive = false;
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 0,
|
|
superseded: 1,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
await expect(readInboxMessages({ teamsBasePath, teamName, memberName })).resolves.toEqual([]);
|
|
await expect(
|
|
readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
).resolves.toMatchObject({
|
|
[outboxInput!.id]: {
|
|
status: 'superseded',
|
|
lastError: 'team_inactive',
|
|
},
|
|
});
|
|
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_superseded"');
|
|
expect(journal).toContain('"reason":"team_inactive"');
|
|
expect(journal).not.toContain('"event":"nudge_delivered"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('replays legacy controller pending report intents through the real app validator', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync after offline report',
|
|
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),
|
|
});
|
|
|
|
try {
|
|
const status = await feature.refreshStatus({ teamName, memberName });
|
|
expect(status).toMatchObject({
|
|
state: 'needs_sync',
|
|
agenda: { items: [expect.objectContaining({ taskId: 'task-1' })] },
|
|
});
|
|
expect(status.reportToken).toBeTruthy();
|
|
|
|
const legacyIntentPath = path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'.member-work-sync',
|
|
'pending-reports.json'
|
|
);
|
|
const intentId = 'legacy-intent-1';
|
|
await fs.promises.mkdir(path.dirname(legacyIntentPath), { recursive: true });
|
|
await fs.promises.writeFile(
|
|
legacyIntentPath,
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
intents: {
|
|
[intentId]: {
|
|
id: intentId,
|
|
teamName,
|
|
memberName,
|
|
status: 'pending',
|
|
reason: 'control_api_unavailable',
|
|
recordedAt: '2026-05-05T12:00:00.000Z',
|
|
request: {
|
|
teamName,
|
|
memberName,
|
|
state: 'still_working',
|
|
agendaFingerprint: status.agenda.fingerprint,
|
|
reportToken: status.reportToken,
|
|
taskIds: ['task-1'],
|
|
source: 'mcp',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)}\n`,
|
|
'utf8'
|
|
);
|
|
|
|
await expect(feature.replayPendingReports([teamName])).resolves.toEqual({
|
|
processed: 1,
|
|
accepted: 1,
|
|
rejected: 0,
|
|
superseded: 0,
|
|
});
|
|
|
|
const finalStatus = await feature.getStatus({ teamName, memberName });
|
|
expect(finalStatus).toMatchObject({
|
|
state: 'still_working',
|
|
report: {
|
|
accepted: true,
|
|
state: 'still_working',
|
|
taskIds: ['task-1'],
|
|
source: 'mcp',
|
|
},
|
|
});
|
|
const memberReports = JSON.parse(
|
|
await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'reports.json'
|
|
),
|
|
'utf8'
|
|
)
|
|
) as { intents?: Record<string, { status?: string; resultCode?: string }> };
|
|
expect(memberReports.intents?.[intentId]).toMatchObject({
|
|
status: 'accepted',
|
|
resultCode: 'accepted',
|
|
});
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"legacy_fallback_used"');
|
|
expect(journal).toContain('"event":"report_accepted"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('defers nudges while a member is busy and recovers on the next agenda change', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
let tasks = [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync while busy',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => tasks),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({
|
|
type: 'tool-activity',
|
|
teamName,
|
|
detail: JSON.stringify({
|
|
action: 'start',
|
|
activity: {
|
|
memberName,
|
|
toolUseId: 'tool-1',
|
|
toolName: 'bash',
|
|
startedAt: new Date(Date.now()).toISOString(),
|
|
source: 'runtime',
|
|
},
|
|
}),
|
|
} as never);
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems).toEqual([
|
|
expect.objectContaining({
|
|
status: 'failed_retryable',
|
|
lastError: 'member_busy:active_tool_activity',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
feature.noteTeamChange({
|
|
type: 'tool-activity',
|
|
teamName,
|
|
detail: JSON.stringify({
|
|
action: 'reset',
|
|
memberName,
|
|
toolUseIds: ['tool-1'],
|
|
}),
|
|
} as never);
|
|
tasks = [
|
|
...tasks,
|
|
{
|
|
id: 'task-2',
|
|
displayId: '22222222',
|
|
subject: 'Ship sync after busy clears',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('22222222');
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'failed_retryable',
|
|
lastError: 'member_busy:active_tool_activity',
|
|
}),
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
await waitForAssertion(async () => {
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"member_busy"');
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('clears stale retry delay and recovers when tool activity finishes without agenda changes', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const tasks = [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync after tool finish',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => tasks),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
feature.noteTeamChange({
|
|
type: 'tool-activity',
|
|
teamName,
|
|
detail: JSON.stringify({
|
|
action: 'start',
|
|
activity: {
|
|
memberName,
|
|
toolUseId: 'tool-1',
|
|
toolName: 'bash',
|
|
startedAt: new Date(Date.now()).toISOString(),
|
|
source: 'runtime',
|
|
},
|
|
}),
|
|
} as never);
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems).toEqual([
|
|
expect.objectContaining({
|
|
status: 'failed_retryable',
|
|
lastError: 'member_busy:active_tool_activity',
|
|
nextAttemptAt: expect.any(String),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
feature.noteTeamChange({
|
|
type: 'tool-activity',
|
|
teamName,
|
|
detail: JSON.stringify({
|
|
action: 'finish',
|
|
memberName,
|
|
toolUseId: 'tool-1',
|
|
finishedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
}),
|
|
} as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems).toEqual([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
}),
|
|
]);
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('rate-limits the active loop after two delivered nudges per member per hour', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
let tasks = [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync first',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => tasks),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
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(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
});
|
|
|
|
tasks = [
|
|
...tasks,
|
|
{
|
|
id: 'task-2',
|
|
displayId: '22222222',
|
|
subject: 'Ship sync second',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-2' } 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.at(-1)?.text).toContain('22222222');
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems.filter((item) => item.status === 'delivered')).toHaveLength(2);
|
|
});
|
|
|
|
tasks = [
|
|
...tasks,
|
|
{
|
|
id: 'task-3',
|
|
displayId: '33333333',
|
|
subject: 'Ship sync third',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-3' } 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.some((message) => message.text?.includes('33333333'))).toBe(false);
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems.filter((item) => item.status === 'delivered')).toHaveLength(2);
|
|
expect(outboxItems).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'failed_retryable',
|
|
lastError: 'member_nudge_rate_limited',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
await waitForAssertion(async () => {
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
const events = journal
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => JSON.parse(line) as { event: string; reason?: string });
|
|
expect(events.filter((event) => event.event === 'nudge_delivered')).toHaveLength(2);
|
|
expect(events).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
event: 'nudge_skipped',
|
|
reason: 'member_nudge_rate_limited',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('recovers retryable inbox delivery failures without duplicate nudges', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync after inbox retry',
|
|
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),
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const inboxPath = path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`);
|
|
await fs.promises.mkdir(inboxPath, { recursive: true });
|
|
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
await waitForAssertion(async () => {
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0);
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'failed_retryable',
|
|
lastError: expect.stringMatching(/EISDIR|ENOTDIR|EEXIST/),
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
await waitForQueueIdle(feature);
|
|
|
|
await fs.promises.rm(inboxPath, { recursive: true, force: true });
|
|
await forceRetryableOutboxDue({
|
|
teamsBasePath,
|
|
teamName,
|
|
memberName,
|
|
nextAttemptAt: new Date(Date.now() - 1_000).toISOString(),
|
|
});
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'delivered',
|
|
deliveredMessageId: expect.any(String),
|
|
}),
|
|
])
|
|
);
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"nudge_retryable"');
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('respects watchdog cooldown and delivers after the retry window is due', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync after watchdog cooldown',
|
|
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),
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const stallJournalPath = path.join(teamsBasePath, teamName, 'stall-monitor-journal.json');
|
|
await fs.promises.mkdir(path.dirname(stallJournalPath), { recursive: true });
|
|
await fs.promises.writeFile(
|
|
stallJournalPath,
|
|
`${JSON.stringify([
|
|
{
|
|
taskId: 'task-1',
|
|
state: 'alerted',
|
|
alertedAt: new Date().toISOString(),
|
|
},
|
|
])}\n`,
|
|
'utf8'
|
|
);
|
|
|
|
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(0);
|
|
const outboxItems = Object.values(
|
|
await readMemberOutboxItems({ teamsBasePath, teamName, memberName })
|
|
);
|
|
expect(outboxItems).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'failed_retryable',
|
|
lastError: 'watchdog_cooldown_active',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
await waitForQueueIdle(feature);
|
|
|
|
await fs.promises.writeFile(
|
|
stallJournalPath,
|
|
`${JSON.stringify([
|
|
{
|
|
taskId: 'task-1',
|
|
state: 'alerted',
|
|
alertedAt: new Date(Date.now() - 11 * 60_000).toISOString(),
|
|
},
|
|
])}\n`,
|
|
'utf8'
|
|
);
|
|
await forceRetryableOutboxDue({
|
|
teamsBasePath,
|
|
teamName,
|
|
memberName,
|
|
nextAttemptAt: new Date(Date.now() - 1_000).toISOString(),
|
|
});
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"watchdog_cooldown_active"');
|
|
expect(journal).toContain('"reason":"watchdog_cooldown_active"');
|
|
expect(journal).toContain('"event":"nudge_delivered"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('supersedes retryable nudges when the member reports before retry delivery', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Ship sync without stale retry',
|
|
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),
|
|
queueQuietWindowMs: 1,
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const stallJournalPath = path.join(teamsBasePath, teamName, 'stall-monitor-journal.json');
|
|
await fs.promises.mkdir(path.dirname(stallJournalPath), { recursive: true });
|
|
await fs.promises.writeFile(
|
|
stallJournalPath,
|
|
`${JSON.stringify([
|
|
{
|
|
taskId: 'task-1',
|
|
state: 'alerted',
|
|
alertedAt: new Date().toISOString(),
|
|
},
|
|
])}\n`,
|
|
'utf8'
|
|
);
|
|
|
|
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
|
|
|
|
let status = await feature.getStatus({ teamName, memberName });
|
|
await waitForAssertion(async () => {
|
|
status = await feature.getStatus({ teamName, memberName });
|
|
expect(status).toMatchObject({
|
|
state: 'needs_sync',
|
|
shadow: { wouldNudge: true },
|
|
});
|
|
expect(status.reportToken).toBeTruthy();
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0);
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'failed_retryable',
|
|
lastError: 'watchdog_cooldown_active',
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
await waitForQueueIdle(feature);
|
|
|
|
await expect(
|
|
feature.report({
|
|
teamName,
|
|
memberName,
|
|
state: 'still_working',
|
|
agendaFingerprint: status.agenda.fingerprint,
|
|
reportToken: status.reportToken,
|
|
taskIds: ['task-1'],
|
|
source: 'test',
|
|
})
|
|
).resolves.toMatchObject({
|
|
accepted: true,
|
|
status: { state: 'still_working', report: { accepted: true } },
|
|
});
|
|
await forceRetryableOutboxDue({
|
|
teamsBasePath,
|
|
teamName,
|
|
memberName,
|
|
nextAttemptAt: new Date(Date.now() - 1_000).toISOString(),
|
|
});
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 0,
|
|
superseded: 1,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0);
|
|
expect(
|
|
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: 'superseded',
|
|
lastError: 'status_no_longer_matches_outbox',
|
|
}),
|
|
])
|
|
);
|
|
const journal = await fs.promises.readFile(
|
|
path.join(
|
|
teamsBasePath,
|
|
teamName,
|
|
'members',
|
|
memberName,
|
|
'.member-work-sync',
|
|
'journal.jsonl'
|
|
),
|
|
'utf8'
|
|
);
|
|
expect(journal).toContain('"event":"watchdog_cooldown_active"');
|
|
expect(journal).toContain('"event":"report_accepted"');
|
|
expect(journal).toContain('"event":"nudge_superseded"');
|
|
expect(journal).not.toContain('"event":"nudge_delivered"');
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('refreshes an expired still_working lease during nudge dispatch without a status read', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Wake after lease expiry',
|
|
status: 'in_progress',
|
|
owner: memberName,
|
|
},
|
|
]),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
canDispatchNudges: vi.fn(async () => true),
|
|
});
|
|
|
|
try {
|
|
await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
const current = await feature.refreshStatus({ teamName, memberName });
|
|
expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({});
|
|
|
|
await expect(
|
|
feature.report({
|
|
teamName,
|
|
memberName,
|
|
state: 'still_working',
|
|
agendaFingerprint: current.agenda.fingerprint,
|
|
reportToken: current.reportToken,
|
|
taskIds: ['task-1'],
|
|
source: 'test',
|
|
})
|
|
).resolves.toMatchObject({
|
|
accepted: true,
|
|
status: { state: 'still_working', report: { accepted: true } },
|
|
});
|
|
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
const acceptedStatus = await store.read({ teamName, memberName });
|
|
expect(acceptedStatus?.report?.accepted).toBe(true);
|
|
const expiredReportedAt = new Date(Date.now() - 7 * 60_000).toISOString();
|
|
const expiredAt = new Date(Date.now() - 6 * 60_000).toISOString();
|
|
await store.write({
|
|
...acceptedStatus!,
|
|
evaluatedAt: expiredReportedAt,
|
|
report: {
|
|
...acceptedStatus!.report!,
|
|
reportedAt: expiredReportedAt,
|
|
expiresAt: expiredAt,
|
|
},
|
|
});
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1);
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('refreshes a legacy still_working report without a lease during nudge dispatch', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Wake after missing lease',
|
|
status: 'in_progress',
|
|
owner: memberName,
|
|
},
|
|
]),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
canDispatchNudges: vi.fn(async () => true),
|
|
});
|
|
|
|
try {
|
|
await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
const current = await feature.refreshStatus({ teamName, memberName });
|
|
await expect(
|
|
feature.report({
|
|
teamName,
|
|
memberName,
|
|
state: 'still_working',
|
|
agendaFingerprint: current.agenda.fingerprint,
|
|
reportToken: current.reportToken,
|
|
taskIds: ['task-1'],
|
|
source: 'test',
|
|
})
|
|
).resolves.toMatchObject({
|
|
accepted: true,
|
|
status: { state: 'still_working', report: { accepted: true } },
|
|
});
|
|
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
const acceptedStatus = await store.read({ teamName, memberName });
|
|
const legacyReport = { ...acceptedStatus!.report! };
|
|
delete legacyReport.expiresAt;
|
|
await store.write({
|
|
...acceptedStatus!,
|
|
evaluatedAt: new Date(Date.now() - 7 * 60_000).toISOString(),
|
|
report: legacyReport,
|
|
});
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1);
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('repairs a legacy working status when the stored agenda is empty', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => []),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
canDispatchNudges: vi.fn(async () => true),
|
|
});
|
|
|
|
try {
|
|
const current = await feature.refreshStatus({ teamName, memberName });
|
|
expect(current.state).toBe('caught_up');
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
await store.write({
|
|
...current,
|
|
state: 'still_working',
|
|
report: {
|
|
teamName,
|
|
memberName,
|
|
state: 'still_working',
|
|
agendaFingerprint: current.agenda.fingerprint,
|
|
reportedAt: current.evaluatedAt,
|
|
expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
|
|
accepted: true,
|
|
},
|
|
});
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 0,
|
|
delivered: 0,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
const repaired = await store.read({ teamName, memberName });
|
|
expect(repaired).toMatchObject({
|
|
state: 'caught_up',
|
|
diagnostics: expect.arrayContaining(['agenda_empty']),
|
|
});
|
|
expect(repaired?.report).toBeUndefined();
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(0);
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('refreshes stale caught_up status during nudge dispatch when new work appears', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
let tasks: Array<{
|
|
id: string;
|
|
displayId: string;
|
|
subject: string;
|
|
status: 'pending';
|
|
owner: string;
|
|
}> = [];
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => tasks),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
canDispatchNudges: vi.fn(async () => true),
|
|
});
|
|
|
|
try {
|
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
|
const current = await feature.refreshStatus({ teamName, memberName });
|
|
expect(current.state).toBe('caught_up');
|
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
|
await store.write({
|
|
...current,
|
|
evaluatedAt: new Date(Date.now() - 7 * 60_000).toISOString(),
|
|
});
|
|
tasks = [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Wake after missed task event',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
];
|
|
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter(
|
|
(message) => message.messageKind === 'member_work_sync_nudge'
|
|
);
|
|
expect(nudges).toHaveLength(1);
|
|
expect(nudges[0]?.text).toContain('11111111');
|
|
await expect(store.read({ teamName, memberName })).resolves.toMatchObject({
|
|
state: 'needs_sync',
|
|
agenda: {
|
|
items: [expect.objectContaining({ taskId: 'task-1' })],
|
|
},
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('materializes a missing active-member status during nudge dispatch', async () => {
|
|
const claudeRoot = makeTempRoot();
|
|
setClaudeBasePathOverride(claudeRoot);
|
|
const teamsBasePath = getTeamsBasePath();
|
|
const teamName = 'team-a';
|
|
const memberName = 'bob';
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath,
|
|
configReader: {
|
|
getConfig: vi.fn(async () => ({
|
|
name: teamName,
|
|
members: [{ name: memberName, providerId: 'codex' }],
|
|
})),
|
|
} as never,
|
|
taskReader: {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-1',
|
|
displayId: '11111111',
|
|
subject: 'Wake after app restart',
|
|
status: 'pending',
|
|
owner: memberName,
|
|
},
|
|
]),
|
|
} as never,
|
|
kanbanManager: {
|
|
getState: vi.fn(async () => ({
|
|
teamName,
|
|
reviewers: [],
|
|
tasks: {},
|
|
})),
|
|
} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
isTeamActive: vi.fn(async () => true),
|
|
canDispatchNudges: vi.fn(async () => true),
|
|
});
|
|
|
|
try {
|
|
await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
|
claimed: 1,
|
|
delivered: 1,
|
|
superseded: 0,
|
|
retryable: 0,
|
|
terminal: 0,
|
|
});
|
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1);
|
|
await expect(
|
|
new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)).read({
|
|
teamName,
|
|
memberName,
|
|
})
|
|
).resolves.toMatchObject({
|
|
state: 'needs_sync',
|
|
shadow: { triggerReasons: ['startup_scan'] },
|
|
});
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('uses snapshot config reads for startup roster materialization', async () => {
|
|
const getConfig = vi.fn(async () => ({ members: [] }));
|
|
const getConfigSnapshot = vi.fn(async () => ({
|
|
members: [{ name: 'alice' }],
|
|
}));
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath: makeTempRoot(),
|
|
configReader: {
|
|
getConfig,
|
|
getConfigSnapshot,
|
|
} as never,
|
|
taskReader: {} as never,
|
|
kanbanManager: {} as never,
|
|
membersMetaStore: {
|
|
getMembers: vi.fn(async () => []),
|
|
} as never,
|
|
});
|
|
|
|
try {
|
|
await feature.enqueueStartupScan(['my-team']);
|
|
expect(getConfigSnapshot).toHaveBeenCalledWith('my-team');
|
|
expect(getConfig).not.toHaveBeenCalled();
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('builds Claude Stop hook settings with nudges active by default', async () => {
|
|
const root = makeTempRoot();
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath: root,
|
|
configReader: {} as never,
|
|
taskReader: {} as never,
|
|
kanbanManager: {} as never,
|
|
membersMetaStore: {} as never,
|
|
});
|
|
|
|
try {
|
|
const settings = await feature.buildRuntimeTurnSettledHookSettings({ provider: 'claude' });
|
|
expect(settings).toMatchObject({
|
|
hooks: {
|
|
Stop: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: expect.stringContaining('agent-teams:member-work-sync-turn-settled:v1'),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await expect(
|
|
fs.promises.stat(
|
|
path.join(root, '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh')
|
|
)
|
|
).resolves.toMatchObject({ mode: expect.any(Number) });
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('builds Codex turn-settled environment with nudges active by default', async () => {
|
|
const root = makeTempRoot();
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath: root,
|
|
configReader: {} as never,
|
|
taskReader: {} as never,
|
|
kanbanManager: {} as never,
|
|
membersMetaStore: {} as never,
|
|
});
|
|
|
|
try {
|
|
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'codex' });
|
|
expect(env).toEqual({
|
|
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
|
|
});
|
|
await expect(
|
|
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
|
|
).resolves.toMatchObject({ mode: expect.any(Number) });
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('builds OpenCode turn-settled environment with nudges active by default', async () => {
|
|
const root = makeTempRoot();
|
|
const feature = createMemberWorkSyncFeature({
|
|
teamsBasePath: root,
|
|
configReader: {} as never,
|
|
taskReader: {} as never,
|
|
kanbanManager: {} as never,
|
|
membersMetaStore: {} as never,
|
|
});
|
|
|
|
try {
|
|
const env = await feature.buildRuntimeTurnSettledEnvironment({ provider: 'opencode' });
|
|
expect(env).toEqual({
|
|
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
|
|
});
|
|
await expect(
|
|
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
|
|
).resolves.toMatchObject({ mode: expect.any(Number) });
|
|
} finally {
|
|
await feature.dispose();
|
|
}
|
|
});
|
|
|
|
it('builds OpenCode bridge environment before feature facade initialization', async () => {
|
|
const root = makeTempRoot();
|
|
|
|
const env = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
|
teamsBasePath: root,
|
|
provider: 'opencode',
|
|
});
|
|
|
|
expect(env).toEqual({
|
|
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: path.join(root, '.member-work-sync/runtime-hooks'),
|
|
});
|
|
await expect(
|
|
fs.promises.stat(path.join(root, '.member-work-sync/runtime-hooks/incoming'))
|
|
).resolves.toMatchObject({ mode: expect.any(Number) });
|
|
});
|
|
});
|