fix(team): refresh task logs from opencode session evidence
This commit is contained in:
parent
dd412355d2
commit
565362a911
4 changed files with 245 additions and 4 deletions
|
|
@ -8827,6 +8827,30 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitOpenCodePromptDeliveryTaskLogChange(
|
||||||
|
record: OpenCodePromptDeliveryLedgerRecord,
|
||||||
|
detail: string
|
||||||
|
): void {
|
||||||
|
if (!record.runtimeSessionId?.trim() || record.taskRefs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const taskIds = new Set(
|
||||||
|
record.taskRefs
|
||||||
|
.map((taskRef) => taskRef.taskId?.trim() || taskRef.displayId?.trim())
|
||||||
|
.filter((taskId): taskId is string => Boolean(taskId))
|
||||||
|
);
|
||||||
|
for (const taskId of taskIds) {
|
||||||
|
this.teamChangeEmitter?.({
|
||||||
|
type: 'task-log-change',
|
||||||
|
teamName: record.teamName,
|
||||||
|
...(record.runId ? { runId: record.runId } : {}),
|
||||||
|
taskId,
|
||||||
|
detail,
|
||||||
|
taskSignalKind: 'log',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleOpenCodeRuntimeDeliveryUserFacingSideEffects(
|
private async handleOpenCodeRuntimeDeliveryUserFacingSideEffects(
|
||||||
record: OpenCodePromptDeliveryLedgerRecord
|
record: OpenCodePromptDeliveryLedgerRecord
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -9964,6 +9988,10 @@ export class TeamProvisioningService {
|
||||||
reason: promptAccepted ? responseObservation?.reason : result.diagnostics[0],
|
reason: promptAccepted ? responseObservation?.reason : result.diagnostics[0],
|
||||||
now: nowIso(),
|
now: nowIso(),
|
||||||
});
|
});
|
||||||
|
this.emitOpenCodePromptDeliveryTaskLogChange(
|
||||||
|
ledgerRecord,
|
||||||
|
'opencode-prompt-delivery-session-evidence'
|
||||||
|
);
|
||||||
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
||||||
ledger,
|
ledger,
|
||||||
ledgerRecord,
|
ledgerRecord,
|
||||||
|
|
|
||||||
|
|
@ -2201,10 +2201,6 @@ export class BoardTaskLogStreamService {
|
||||||
taskId: string,
|
taskId: string,
|
||||||
records: BoardTaskActivityRecord[]
|
records: BoardTaskActivityRecord[]
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (records.some((record) => record.linkKind === 'execution')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [activeTasks, deletedTasks, metaMembers, config] = await Promise.all([
|
const [activeTasks, deletedTasks, metaMembers, config] = await Promise.all([
|
||||||
this.taskReader.getTasks(teamName).catch(() => []),
|
this.taskReader.getTasks(teamName).catch(() => []),
|
||||||
|
|
@ -2219,6 +2215,15 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedOwner = normalizeMemberName(ownerName);
|
const normalizedOwner = normalizeMemberName(ownerName);
|
||||||
|
const hasOwnerExecution = records.some(
|
||||||
|
(record) =>
|
||||||
|
record.linkKind === 'execution' &&
|
||||||
|
normalizeMemberName(record.actor.memberName ?? '') === normalizedOwner
|
||||||
|
);
|
||||||
|
if (hasOwnerExecution) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const member = [...metaMembers, ...(config?.members ?? [])].find(
|
const member = [...metaMembers, ...(config?.members ?? [])].find(
|
||||||
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
|
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,110 @@ describe('BoardTaskLogStreamService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not suppress exact OpenCode fallback because of unrelated execution records', async () => {
|
||||||
|
const lead = {
|
||||||
|
role: 'lead' as const,
|
||||||
|
sessionId: 'session-lead',
|
||||||
|
isSidechain: false,
|
||||||
|
};
|
||||||
|
const baseCandidate = makeCandidate(
|
||||||
|
'c1',
|
||||||
|
'2026-04-12T16:00:00.000Z',
|
||||||
|
lead,
|
||||||
|
'tool-board'
|
||||||
|
);
|
||||||
|
const executionRecord: BoardTaskActivityRecord = {
|
||||||
|
...baseCandidate.records[0]!,
|
||||||
|
linkKind: 'execution',
|
||||||
|
};
|
||||||
|
const candidate: BoardTaskExactLogBundleCandidate = {
|
||||||
|
...baseCandidate,
|
||||||
|
records: [executionRecord],
|
||||||
|
linkKinds: ['execution'],
|
||||||
|
};
|
||||||
|
const runtimeFallbackSource = {
|
||||||
|
getTaskLogStream: vi.fn(async () => ({
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
key: 'member:jack',
|
||||||
|
label: 'jack',
|
||||||
|
role: 'member' as const,
|
||||||
|
isLead: false,
|
||||||
|
isSidechain: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultFilter: 'member:jack',
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
id: 'opencode:demo:task-a:jack:session-opencode',
|
||||||
|
participantKey: 'member:jack',
|
||||||
|
actor: {
|
||||||
|
memberName: 'jack',
|
||||||
|
role: 'member' as const,
|
||||||
|
sessionId: 'session-opencode',
|
||||||
|
isSidechain: true,
|
||||||
|
},
|
||||||
|
startTimestamp: '2026-04-12T16:01:00.000Z',
|
||||||
|
endTimestamp: '2026-04-12T16:02:00.000Z',
|
||||||
|
chunks: [{ id: 'chunk-exact-opencode' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: 'opencode_runtime_attribution' as const,
|
||||||
|
runtimeProjection: {
|
||||||
|
provider: 'opencode' as const,
|
||||||
|
mode: 'attribution' as const,
|
||||||
|
attributionRecordCount: 1,
|
||||||
|
projectedMessageCount: 2,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const service = new BoardTaskLogStreamService(
|
||||||
|
{
|
||||||
|
getTaskRecords: vi.fn(async () => candidate.records),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
selectSummaries: vi.fn(() => [candidate]),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
selectDetail: vi.fn(() => ({
|
||||||
|
id: 'c1',
|
||||||
|
timestamp: '2026-04-12T16:00:00.000Z',
|
||||||
|
actor: lead,
|
||||||
|
source: candidate.source,
|
||||||
|
records: candidate.records,
|
||||||
|
filteredMessages: [makeMessage('c1', '2026-04-12T16:00:00.000Z', 'lead execution')],
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
buildBundleChunks: vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getTasks: vi.fn(async () => [{ id: 'task-a', owner: 'jack' }]),
|
||||||
|
getDeletedTasks: vi.fn(async () => []),
|
||||||
|
} as never,
|
||||||
|
undefined as never,
|
||||||
|
runtimeFallbackSource as never,
|
||||||
|
{
|
||||||
|
getMembers: vi.fn(async () => [{ name: 'jack', providerId: 'opencode' }]),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
getConfig: vi.fn(async () => null),
|
||||||
|
} as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await service.getTaskLogStream('demo', 'task-a');
|
||||||
|
|
||||||
|
expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledWith('demo', 'task-a');
|
||||||
|
expect(response.source).toBe('mixed_transcript_opencode_runtime');
|
||||||
|
expect(response.segments.map((segment) => segment.id)).toEqual([
|
||||||
|
'lead:c1:c1',
|
||||||
|
'opencode:demo:task-a:jack:session-opencode',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not probe OpenCode runtime for non-OpenCode task owners', async () => {
|
it('does not probe OpenCode runtime for non-OpenCode task owners', async () => {
|
||||||
const lead = {
|
const lead = {
|
||||||
role: 'lead' as const,
|
role: 'lead' as const,
|
||||||
|
|
|
||||||
|
|
@ -6050,6 +6050,110 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits a narrow task-log signal when OpenCode prompt delivery records exact session evidence', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const emitter = vi.fn();
|
||||||
|
svc.setTeamChangeEmitter(emitter);
|
||||||
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
memberName: String(input.memberName),
|
||||||
|
sessionId: 'oc-session-bob',
|
||||||
|
prePromptCursor: 'cursor-before',
|
||||||
|
responseObservation: {
|
||||||
|
state: 'pending',
|
||||||
|
deliveredUserMessageId: 'oc-user-1',
|
||||||
|
assistantMessageId: null,
|
||||||
|
toolCallNames: [],
|
||||||
|
visibleMessageToolCallId: null,
|
||||||
|
visibleReplyMessageId: null,
|
||||||
|
visibleReplyCorrelation: null,
|
||||||
|
latestAssistantPreview: null,
|
||||||
|
reason: 'assistant_response_pending',
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
const registry = new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: vi.fn(),
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
sendMessageToMember,
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
svc.setRuntimeAdapterRegistry(registry);
|
||||||
|
|
||||||
|
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
|
||||||
|
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
|
||||||
|
(svc as any).setSecondaryRuntimeRun({
|
||||||
|
teamName: 'team-a',
|
||||||
|
runId: 'opencode-run-bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
memberName: 'bob',
|
||||||
|
cwd: '/repo',
|
||||||
|
});
|
||||||
|
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||||
|
(svc as any).configReader = {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
projectPath: '/repo',
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||||
|
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).teamMetaStore = {
|
||||||
|
getMeta: vi.fn(async () => ({
|
||||||
|
launchIdentity: { providerId: 'codex' },
|
||||||
|
providerId: 'codex',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
(svc as any).membersMetaStore = {
|
||||||
|
getMembers: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'hello bob',
|
||||||
|
messageId: 'msg-ledger-session',
|
||||||
|
source: 'watcher',
|
||||||
|
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||||
|
taskRefs: [
|
||||||
|
{
|
||||||
|
taskId: 'task-a',
|
||||||
|
displayId: 'task-a',
|
||||||
|
teamName: 'team-a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
responsePending: true,
|
||||||
|
responseState: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emitter).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'task-log-change',
|
||||||
|
teamName: 'team-a',
|
||||||
|
runId: 'opencode-run-bob',
|
||||||
|
taskId: 'task-a',
|
||||||
|
detail: 'opencode-prompt-delivery-session-evidence',
|
||||||
|
taskSignalKind: 'log',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('retries due stale OpenCode sessions instead of observing forever', async () => {
|
it('retries due stale OpenCode sessions instead of observing forever', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue