fix(member-work-sync): ignore non-terminal opencode turns
This commit is contained in:
parent
f0a5cf7617
commit
0b28d8f4b2
4 changed files with 178 additions and 2 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import type { MemberWorkSyncClockPort, MemberWorkSyncLoggerPort } from './ports';
|
import type { MemberWorkSyncClockPort, MemberWorkSyncLoggerPort } from './ports';
|
||||||
|
import type { RuntimeTurnSettledEvent } from '../domain';
|
||||||
import type {
|
import type {
|
||||||
RuntimeTurnSettledEventStorePort,
|
RuntimeTurnSettledEventStorePort,
|
||||||
RuntimeTurnSettledPayloadNormalizerPort,
|
RuntimeTurnSettledPayloadNormalizerPort,
|
||||||
|
|
@ -19,10 +20,30 @@ export interface RuntimeTurnSettledDrainSummary {
|
||||||
claimed: number;
|
claimed: number;
|
||||||
enqueued: number;
|
enqueued: number;
|
||||||
unresolved: number;
|
unresolved: number;
|
||||||
|
ignored: number;
|
||||||
invalid: number;
|
invalid: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NON_TERMINAL_OPENCODE_OUTCOMES = new Set([
|
||||||
|
'timeout',
|
||||||
|
'stream_unavailable',
|
||||||
|
'prompt_rejected',
|
||||||
|
'idle_without_assistant_activity',
|
||||||
|
'unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getIgnoredReason(event: RuntimeTurnSettledEvent): string | null {
|
||||||
|
if (event.provider !== 'opencode') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const outcome = event.outcome?.trim();
|
||||||
|
if (!outcome || !NON_TERMINAL_OPENCODE_OUTCOMES.has(outcome)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `opencode_non_terminal_outcome:${outcome}`;
|
||||||
|
}
|
||||||
|
|
||||||
export class RuntimeTurnSettledIngestor {
|
export class RuntimeTurnSettledIngestor {
|
||||||
constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {}
|
constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {}
|
||||||
|
|
||||||
|
|
@ -31,6 +52,7 @@ export class RuntimeTurnSettledIngestor {
|
||||||
claimed: 0,
|
claimed: 0,
|
||||||
enqueued: 0,
|
enqueued: 0,
|
||||||
unresolved: 0,
|
unresolved: 0,
|
||||||
|
ignored: 0,
|
||||||
invalid: 0,
|
invalid: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
|
|
@ -56,6 +78,18 @@ export class RuntimeTurnSettledIngestor {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ignoredReason = getIgnoredReason(normalized.event);
|
||||||
|
if (ignoredReason) {
|
||||||
|
summary.ignored += 1;
|
||||||
|
await this.deps.eventStore.markProcessed(payload, {
|
||||||
|
event: normalized.event,
|
||||||
|
outcome: 'ignored',
|
||||||
|
reason: ignoredReason,
|
||||||
|
processedAt,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const resolution = await this.deps.targetResolver.resolve(normalized.event);
|
const resolution = await this.deps.targetResolver.resolve(normalized.event);
|
||||||
if (!resolution.ok) {
|
if (!resolution.ok) {
|
||||||
summary.unresolved += 1;
|
summary.unresolved += 1;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export interface RuntimeTurnSettledProcessedResult {
|
||||||
event: RuntimeTurnSettledEvent;
|
event: RuntimeTurnSettledEvent;
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
outcome: 'enqueued' | 'unresolved' | 'duplicate';
|
outcome: 'enqueued' | 'unresolved' | 'duplicate' | 'ignored';
|
||||||
reason?: string;
|
reason?: string;
|
||||||
processedAt: string;
|
processedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ export interface OpenCodeReadinessBridgeCommandBody {
|
||||||
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
||||||
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
|
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
|
||||||
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
|
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_SEND_TIMEOUT_MS = 30_000;
|
// Longer than the renderer-facing UI timeout: late OpenCode turns should still
|
||||||
|
// finish bridge-side observation and emit member-work-sync signals.
|
||||||
|
const DEFAULT_SEND_TIMEOUT_MS = 45_000;
|
||||||
const DEFAULT_OBSERVE_TIMEOUT_MS = 8_000;
|
const DEFAULT_OBSERVE_TIMEOUT_MS = 8_000;
|
||||||
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
|
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||||
import { RuntimeTurnSettledIngestor } from '@features/member-work-sync/core/application';
|
import { RuntimeTurnSettledIngestor } from '@features/member-work-sync/core/application';
|
||||||
import { ClaudeStopHookPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer';
|
import { ClaudeStopHookPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer';
|
||||||
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
|
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
|
||||||
|
import { OpenCodeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
RuntimeTurnSettledClaimedPayload,
|
RuntimeTurnSettledClaimedPayload,
|
||||||
|
|
@ -23,6 +24,17 @@ function makePayload(raw: string): RuntimeTurnSettledClaimedPayload {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeOpenCodePayload(raw: string): RuntimeTurnSettledClaimedPayload {
|
||||||
|
return {
|
||||||
|
id: 'event-1.opencode.json',
|
||||||
|
fileName: 'event-1.opencode.json',
|
||||||
|
filePath: '/tmp/event-1.opencode.json',
|
||||||
|
provider: 'opencode',
|
||||||
|
raw,
|
||||||
|
claimedAt: '2026-04-29T12:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('RuntimeTurnSettledIngestor', () => {
|
describe('RuntimeTurnSettledIngestor', () => {
|
||||||
it('normalizes Claude Stop payloads and enqueues resolved member reconcile', async () => {
|
it('normalizes Claude Stop payloads and enqueues resolved member reconcile', async () => {
|
||||||
const payload = makePayload(
|
const payload = makePayload(
|
||||||
|
|
@ -57,6 +69,7 @@ describe('RuntimeTurnSettledIngestor', () => {
|
||||||
claimed: 1,
|
claimed: 1,
|
||||||
enqueued: 1,
|
enqueued: 1,
|
||||||
unresolved: 0,
|
unresolved: 0,
|
||||||
|
ignored: 0,
|
||||||
invalid: 0,
|
invalid: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -133,6 +146,7 @@ describe('RuntimeTurnSettledIngestor', () => {
|
||||||
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
||||||
claimed: 1,
|
claimed: 1,
|
||||||
unresolved: 1,
|
unresolved: 1,
|
||||||
|
ignored: 0,
|
||||||
enqueued: 0,
|
enqueued: 0,
|
||||||
});
|
});
|
||||||
expect(processed[0]).toMatchObject({
|
expect(processed[0]).toMatchObject({
|
||||||
|
|
@ -140,4 +154,130 @@ describe('RuntimeTurnSettledIngestor', () => {
|
||||||
reason: 'no_matching_member_session',
|
reason: 'no_matching_member_session',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes OpenCode turn-settled payloads and enqueues provider-owned member reconcile', async () => {
|
||||||
|
const payload = makeOpenCodePayload(
|
||||||
|
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:jack',
|
||||||
|
memberName: 'jack',
|
||||||
|
teamName: 'team-a',
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
outcome: 'success',
|
||||||
|
recordedAt: '2026-04-29T12:00:00.000Z',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const processed: RuntimeTurnSettledProcessedResult[] = [];
|
||||||
|
const store: RuntimeTurnSettledEventStorePort = {
|
||||||
|
claimPending: vi.fn(async () => [payload]),
|
||||||
|
markProcessed: vi.fn(async (_payload, result) => {
|
||||||
|
processed.push(result);
|
||||||
|
}),
|
||||||
|
markInvalid: vi.fn(),
|
||||||
|
};
|
||||||
|
const resolver: RuntimeTurnSettledTargetResolverPort = {
|
||||||
|
resolve: vi.fn(async () => ({ ok: true as const, teamName: 'team-a', memberName: 'jack' })),
|
||||||
|
};
|
||||||
|
const enqueueRuntimeTurnSettled = vi.fn();
|
||||||
|
|
||||||
|
const ingestor = new RuntimeTurnSettledIngestor({
|
||||||
|
eventStore: store,
|
||||||
|
normalizer: new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()),
|
||||||
|
targetResolver: resolver,
|
||||||
|
reconcileQueue: { enqueueRuntimeTurnSettled },
|
||||||
|
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
||||||
|
claimed: 1,
|
||||||
|
enqueued: 1,
|
||||||
|
invalid: 0,
|
||||||
|
unresolved: 0,
|
||||||
|
ignored: 0,
|
||||||
|
});
|
||||||
|
expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'jack',
|
||||||
|
event: expect.objectContaining({
|
||||||
|
provider: 'opencode',
|
||||||
|
hookEventName: 'Stop',
|
||||||
|
sessionId: 'ses-opencode-1',
|
||||||
|
turnId: 'msg_123',
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'jack',
|
||||||
|
agentId: 'secondary:opencode:jack',
|
||||||
|
outcome: 'success',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(processed[0]).toMatchObject({
|
||||||
|
outcome: 'enqueued',
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'jack',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'timeout',
|
||||||
|
'stream_unavailable',
|
||||||
|
'prompt_rejected',
|
||||||
|
'idle_without_assistant_activity',
|
||||||
|
'unknown',
|
||||||
|
])('ignores non-terminal OpenCode outcome %s without enqueueing premature reconcile', async (outcome) => {
|
||||||
|
const payload = makeOpenCodePayload(
|
||||||
|
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:jack',
|
||||||
|
memberName: 'jack',
|
||||||
|
teamName: 'team-a',
|
||||||
|
outcome,
|
||||||
|
recordedAt: '2026-04-29T12:00:00.000Z',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const processed: RuntimeTurnSettledProcessedResult[] = [];
|
||||||
|
const store: RuntimeTurnSettledEventStorePort = {
|
||||||
|
claimPending: vi.fn(async () => [payload]),
|
||||||
|
markProcessed: vi.fn(async (_payload, result) => {
|
||||||
|
processed.push(result);
|
||||||
|
}),
|
||||||
|
markInvalid: vi.fn(),
|
||||||
|
};
|
||||||
|
const resolver: RuntimeTurnSettledTargetResolverPort = {
|
||||||
|
resolve: vi.fn(),
|
||||||
|
};
|
||||||
|
const enqueueRuntimeTurnSettled = vi.fn();
|
||||||
|
|
||||||
|
const ingestor = new RuntimeTurnSettledIngestor({
|
||||||
|
eventStore: store,
|
||||||
|
normalizer: new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter()),
|
||||||
|
targetResolver: resolver,
|
||||||
|
reconcileQueue: { enqueueRuntimeTurnSettled },
|
||||||
|
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(ingestor.drainPending()).resolves.toMatchObject({
|
||||||
|
claimed: 1,
|
||||||
|
enqueued: 0,
|
||||||
|
unresolved: 0,
|
||||||
|
ignored: 1,
|
||||||
|
invalid: 0,
|
||||||
|
});
|
||||||
|
expect(resolver.resolve).not.toHaveBeenCalled();
|
||||||
|
expect(enqueueRuntimeTurnSettled).not.toHaveBeenCalled();
|
||||||
|
expect(processed[0]).toMatchObject({
|
||||||
|
outcome: 'ignored',
|
||||||
|
reason: `opencode_non_terminal_outcome:${outcome}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue