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",
|
||||
"sourceRef": "v0.0.19",
|
||||
"version": "0.0.20",
|
||||
"sourceRef": "v0.0.20",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.19.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.20.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.19.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.20.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11064,16 +11064,23 @@ export class TeamProvisioningService {
|
|||
return { busy: true, reason: 'opencode_no_active_lane', retryAfterIso };
|
||||
}
|
||||
|
||||
const activeRecord = await this.createOpenCodePromptDeliveryLedger(
|
||||
input.teamName,
|
||||
identity.laneId
|
||||
)
|
||||
.getActiveForMember({
|
||||
let activeRecord: OpenCodePromptDeliveryLedgerRecord | null;
|
||||
try {
|
||||
activeRecord = await this.createOpenCodePromptDeliveryLedger(
|
||||
input.teamName,
|
||||
identity.laneId
|
||||
).getActiveForMember({
|
||||
teamName: input.teamName,
|
||||
memberName: identity.canonicalMemberName,
|
||||
laneId: identity.laneId,
|
||||
})
|
||||
.catch(() => null);
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
busy: true,
|
||||
reason: 'opencode_prompt_ledger_unavailable',
|
||||
retryAfterIso,
|
||||
};
|
||||
}
|
||||
if (activeRecord) {
|
||||
return {
|
||||
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> {
|
||||
const deadline = Date.now() + 2_000;
|
||||
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 () => {
|
||||
const claudeRoot = makeTempRoot();
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
|
|
|
|||
|
|
@ -156,8 +156,10 @@ vi.mock('agent-teams-controller', () => ({
|
|||
}));
|
||||
|
||||
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 { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import { getTeamsBasePath } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
function seedConfig(teamName: string): void {
|
||||
hoisted.files.set(
|
||||
|
|
@ -2738,4 +2740,176 @@ Messages:
|
|||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
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