chore(runtime): pin orchestrator 0.0.20
This commit is contained in:
parent
e96a74f4fa
commit
9e1abb0332
4 changed files with 785 additions and 13 deletions
|
|
@ -1,27 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "0.0.19",
|
"version": "0.0.20",
|
||||||
"sourceRef": "v0.0.19",
|
"sourceRef": "v0.0.20",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.19.tar.gz",
|
"file": "agent-teams-runtime-darwin-arm64-v0.0.20.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"darwin-x64": {
|
||||||
"file": "agent-teams-runtime-darwin-x64-v0.0.19.tar.gz",
|
"file": "agent-teams-runtime-darwin-x64-v0.0.20.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"linux-x64": {
|
||||||
"file": "agent-teams-runtime-linux-x64-v0.0.19.tar.gz",
|
"file": "agent-teams-runtime-linux-x64-v0.0.20.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.19.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.20.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11064,16 +11064,23 @@ export class TeamProvisioningService {
|
||||||
return { busy: true, reason: 'opencode_no_active_lane', retryAfterIso };
|
return { busy: true, reason: 'opencode_no_active_lane', retryAfterIso };
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeRecord = await this.createOpenCodePromptDeliveryLedger(
|
let activeRecord: OpenCodePromptDeliveryLedgerRecord | null;
|
||||||
input.teamName,
|
try {
|
||||||
identity.laneId
|
activeRecord = await this.createOpenCodePromptDeliveryLedger(
|
||||||
)
|
input.teamName,
|
||||||
.getActiveForMember({
|
identity.laneId
|
||||||
|
).getActiveForMember({
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
memberName: identity.canonicalMemberName,
|
memberName: identity.canonicalMemberName,
|
||||||
laneId: identity.laneId,
|
laneId: identity.laneId,
|
||||||
})
|
});
|
||||||
.catch(() => null);
|
} catch {
|
||||||
|
return {
|
||||||
|
busy: true,
|
||||||
|
reason: 'opencode_prompt_ledger_unavailable',
|
||||||
|
retryAfterIso,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (activeRecord) {
|
if (activeRecord) {
|
||||||
return {
|
return {
|
||||||
busy: true,
|
busy: true,
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,111 @@ async function seedShadowReadyMetrics(input: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
|
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
|
||||||
const deadline = Date.now() + 2_000;
|
const deadline = Date.now() + 2_000;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
@ -443,6 +548,492 @@ describe('createMemberWorkSyncFeature composition', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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('does not apply the OpenCode shadow-collection exception to Codex members', async () => {
|
||||||
|
const claudeRoot = makeTempRoot();
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
const teamName = 'team-codex-shadow-gated';
|
||||||
|
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: '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),
|
||||||
|
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(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({});
|
||||||
|
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_skipped"');
|
||||||
|
expect(journal).toContain('"reason":"phase2_not_ready"');
|
||||||
|
expect(journal).not.toContain('"event":"nudge_delivered"');
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks targeted OpenCode nudges when phase2 metrics are unsafe', 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: 'Do not nudge when metrics are unsafe',
|
||||||
|
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 });
|
||||||
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
|
||||||
|
expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({});
|
||||||
|
expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled();
|
||||||
|
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_skipped"');
|
||||||
|
expect(journal).toContain('"reason":"phase2_not_ready"');
|
||||||
|
expect(journal).not.toContain('"event":"nudge_delivered"');
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers targeted OpenCode nudge delivery after unsafe 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: 'Recover OpenCode nudge 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 });
|
||||||
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]);
|
||||||
|
expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({});
|
||||||
|
expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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).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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} 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 () => {
|
it('keeps nudges gated until shadow readiness is reached, then delivers on the next reconcile', async () => {
|
||||||
const claudeRoot = makeTempRoot();
|
const claudeRoot = makeTempRoot();
|
||||||
setClaudeBasePathOverride(claudeRoot);
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,10 @@ vi.mock('agent-teams-controller', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { buildLegacyInboxMessageId } from '../../../../src/main/services/team/inboxMessageIdentity';
|
import { buildLegacyInboxMessageId } from '../../../../src/main/services/team/inboxMessageIdentity';
|
||||||
|
import * as OpenCodeRuntimeStore from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||||
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader';
|
||||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
|
import { getTeamsBasePath } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
function seedConfig(teamName: string): void {
|
function seedConfig(teamName: string): void {
|
||||||
hoisted.files.set(
|
hoisted.files.set(
|
||||||
|
|
@ -2738,4 +2740,176 @@ Messages:
|
||||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||||
expect(rows[0].read).toBe(false);
|
expect(rows[0].read).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails closed when OpenCode prompt ledger cannot be inspected for work-sync busy checks', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
const laneId = 'secondary:opencode:jack';
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
hoisted.files.set(
|
||||||
|
`${teamsBasePath}/${teamName}/config.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
name: teamName,
|
||||||
|
projectPath: '/tmp/my-team',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
hoisted.files.set(
|
||||||
|
OpenCodeRuntimeStore.getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
lanes: {
|
||||||
|
primary: {
|
||||||
|
laneId: 'primary',
|
||||||
|
state: 'active',
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
},
|
||||||
|
[laneId]: {
|
||||||
|
laneId,
|
||||||
|
state: 'active',
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
|
||||||
|
version: 1,
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
lanes: {
|
||||||
|
[laneId]: {
|
||||||
|
laneId,
|
||||||
|
state: 'active',
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/jack.json`, JSON.stringify([]));
|
||||||
|
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
canonicalMemberName: 'jack',
|
||||||
|
laneId,
|
||||||
|
}));
|
||||||
|
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||||
|
getActiveForMember: vi.fn(async () => {
|
||||||
|
throw new Error('ledger read failed');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||||
|
teamName,
|
||||||
|
memberName: 'jack',
|
||||||
|
nowIso: '2026-02-23T17:31:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(busy).toMatchObject({
|
||||||
|
busy: true,
|
||||||
|
reason: 'opencode_prompt_ledger_unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats unread OpenCode foreground inbox messages as busy for work-sync checks', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
hoisted.files.set(
|
||||||
|
`${teamsBasePath}/${teamName}/config.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
name: teamName,
|
||||||
|
projectPath: '/tmp/my-team',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
hoisted.files.set(
|
||||||
|
`${teamsBasePath}/${teamName}/inboxes/jack.json`,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
from: 'user',
|
||||||
|
to: 'jack',
|
||||||
|
text: 'Please check the current issue.',
|
||||||
|
timestamp: '2026-02-23T17:31:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'foreground-message-1',
|
||||||
|
messageKind: 'direct',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||||
|
teamName,
|
||||||
|
memberName: 'jack',
|
||||||
|
nowIso: '2026-02-23T17:31:10.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(busy).toMatchObject({
|
||||||
|
busy: true,
|
||||||
|
reason: 'opencode_foreground_inbox_unread',
|
||||||
|
activeMessageId: 'foreground-message-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat unread OpenCode work-sync nudges as foreground busy blockers', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
const laneId = 'secondary:opencode:jack';
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
hoisted.files.set(
|
||||||
|
`${teamsBasePath}/${teamName}/config.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
name: teamName,
|
||||||
|
projectPath: '/tmp/my-team',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
hoisted.files.set(
|
||||||
|
`${teamsBasePath}/${teamName}/inboxes/jack.json`,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
from: 'system',
|
||||||
|
to: 'jack',
|
||||||
|
text: 'Work sync check.',
|
||||||
|
timestamp: '2026-02-23T17:31:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'work-sync-nudge-1',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
canonicalMemberName: 'jack',
|
||||||
|
laneId,
|
||||||
|
}));
|
||||||
|
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({
|
||||||
|
version: 1,
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
lanes: {
|
||||||
|
[laneId]: {
|
||||||
|
laneId,
|
||||||
|
state: 'active',
|
||||||
|
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||||
|
getActiveForMember: vi.fn(async () => null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||||
|
teamName,
|
||||||
|
memberName: 'jack',
|
||||||
|
nowIso: '2026-02-23T17:31:10.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(busy).toEqual({ busy: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue