feat(member-work-sync): ingest opencode turn-settled events

This commit is contained in:
777genius 2026-04-30 00:36:25 +03:00
parent 62a1b0e865
commit 0af9994c9a
14 changed files with 3131 additions and 41 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
export type RuntimeTurnSettledProvider = 'claude' | 'codex';
export type RuntimeTurnSettledProvider = 'claude' | 'codex' | 'opencode';
export function isRuntimeTurnSettledProvider(value: unknown): value is RuntimeTurnSettledProvider {
return value === 'claude' || value === 'codex';
return value === 'claude' || value === 'codex' || value === 'opencode';
}

View file

@ -80,7 +80,11 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT
async resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution> {
if (event.provider === 'codex') {
return this.resolveCodexEvent(event);
return this.resolveProviderOwnedEvent(event, 'codex');
}
if (event.provider === 'opencode') {
return this.resolveProviderOwnedEvent(event, 'opencode');
}
if (event.provider !== 'claude') {
@ -154,8 +158,9 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT
};
}
private async resolveCodexEvent(
event: RuntimeTurnSettledEvent
private async resolveProviderOwnedEvent(
event: RuntimeTurnSettledEvent,
expectedProviderId: 'codex' | 'opencode'
): Promise<RuntimeTurnSettledTargetResolution> {
const teamName = event.teamName?.trim();
const memberName = event.memberName?.trim();
@ -172,7 +177,7 @@ export class TeamRuntimeTurnSettledTargetResolver implements RuntimeTurnSettledT
}
const providerId = providerForMember(member);
if (providerId && providerId !== 'codex') {
if (providerId && providerId !== expectedProviderId) {
return { ok: false, reason: 'provider_mismatch' };
}

View file

@ -31,11 +31,9 @@ import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWo
import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths';
import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal';
import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
import { OpenCodeTurnSettledPayloadNormalizer } from '../infrastructure/OpenCodeTurnSettledPayloadNormalizer';
import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler';
import { buildRuntimeTurnSettledEnvironment } from '../infrastructure/runtimeTurnSettledEnvironment';
import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings';
import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths';
import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller';
import { RuntimeTurnSettledSpoolInitializer } from '../infrastructure/RuntimeTurnSettledSpoolInitializer';
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
import type {
@ -82,6 +80,15 @@ export function resolveMemberWorkSyncNudgeSideEffectsEnabled(
return false;
}
export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
teamsBasePath: string;
provider: RuntimeTurnSettledProvider;
}): Promise<Record<string, string> | null> {
return new RuntimeTurnSettledSpoolInitializer(input.teamsBasePath).buildEnvironment({
provider: input.provider,
});
}
export interface MemberWorkSyncFeatureFacade {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
@ -126,16 +133,14 @@ export function createMemberWorkSyncFeature(deps: {
});
const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath);
const store = new JsonMemberWorkSyncStore(storePaths);
const runtimeTurnSettledSpoolPaths = new RuntimeTurnSettledSpoolPaths(deps.teamsBasePath);
const runtimeTurnSettledHookInstaller = new ShellRuntimeTurnSettledHookScriptInstaller(
runtimeTurnSettledSpoolPaths
);
const runtimeTurnSettledSpool = new RuntimeTurnSettledSpoolInitializer(deps.teamsBasePath);
const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({
paths: runtimeTurnSettledSpoolPaths,
paths: runtimeTurnSettledSpool.getPaths(),
});
const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([
new ClaudeStopHookPayloadNormalizer(hash),
new CodexNativeTurnSettledPayloadNormalizer(hash),
new OpenCodeTurnSettledPayloadNormalizer(hash),
]);
const runtimeTurnSettledTargetResolver =
deps.runtimeTurnSettledTargetResolver ??
@ -257,27 +262,10 @@ export function createMemberWorkSyncFeature(deps: {
claimedBy: `member-work-sync:${process.pid}`,
})
: Promise.resolve(emptyNudgeDispatchSummary()),
buildRuntimeTurnSettledHookSettings: async ({ provider }) => {
if (provider !== 'claude') {
return null;
}
const installed = await runtimeTurnSettledHookInstaller.install();
return buildRuntimeTurnSettledHookSettings({
scriptPath: installed.scriptPath,
spoolRoot: installed.spoolRoot,
provider,
});
},
buildRuntimeTurnSettledEnvironment: async ({ provider }) => {
if (provider !== 'codex') {
return null;
}
const installed = await runtimeTurnSettledHookInstaller.install();
return buildRuntimeTurnSettledEnvironment({
provider,
spoolRoot: installed.spoolRoot,
});
},
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
runtimeTurnSettledSpool.buildHookSettings({ provider }),
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>
runtimeTurnSettledSpool.buildEnvironment({ provider }),
drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(),
getQueueDiagnostics: () => queue.getDiagnostics(),
dispose: async () => {

View file

@ -5,6 +5,7 @@ export {
} from './adapters/input/registerMemberWorkSyncIpc';
export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature';
export {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
resolveMemberWorkSyncNudgeSideEffectsEnabled,

View file

@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promis
import path from 'path';
import { isRuntimeTurnSettledProvider } from '../../core/domain';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
import type {
RuntimeTurnSettledClaimedPayload,
RuntimeTurnSettledEventStorePort,
@ -25,7 +25,7 @@ export interface FileRuntimeTurnSettledEventStoreDeps {
now?: () => Date;
}
function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null {
function parseProviderFromFileName(fileName: string): RuntimeTurnSettledProvider | null {
const parts = fileName.split('.');
const provider = parts.length >= 3 ? parts[parts.length - 2] : null;
return isRuntimeTurnSettledProvider(provider) ? provider : null;

View file

@ -0,0 +1,123 @@
import {
buildRuntimeTurnSettledSourceId,
type RuntimeTurnSettledProvider,
} from '../../core/domain';
import type {
MemberWorkSyncHashPort,
RuntimeTurnSettledPayloadNormalization,
RuntimeTurnSettledPayloadNormalizerPort,
} from '../../core/application';
const SUPPORTED_SOURCE = 'agent-teams-orchestrator-opencode';
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function getString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value !== 'string') {
continue;
}
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return undefined;
}
export class OpenCodeTurnSettledPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort {
constructor(private readonly hash: MemberWorkSyncHashPort) {}
normalize(input: {
provider: RuntimeTurnSettledProvider;
raw: string;
recordedAt: string;
}): RuntimeTurnSettledPayloadNormalization {
if (input.provider !== 'opencode') {
return { ok: false, reason: 'unsupported_provider' };
}
let parsed: unknown;
try {
parsed = JSON.parse(input.raw);
} catch {
return { ok: false, reason: 'invalid_json' };
}
const payload = asRecord(parsed);
if (!payload) {
return { ok: false, reason: 'payload_not_object' };
}
const provider = getString(payload, 'provider');
if (provider !== 'opencode') {
return { ok: false, reason: 'provider_mismatch' };
}
const source = getString(payload, 'source');
if (source !== SUPPORTED_SOURCE) {
return { ok: false, reason: 'source_mismatch' };
}
const eventName = getString(payload, 'eventName', 'event_name');
const hookEventName = getString(payload, 'hookEventName', 'hook_event_name');
if (eventName !== 'runtime_turn_settled' && hookEventName !== 'Stop') {
return { ok: false, reason: 'not_turn_settled_event' };
}
const sessionId = getString(payload, 'sessionId', 'session_id', 'opencodeSessionId');
const teamName = getString(payload, 'teamName', 'team_name', 'teamId', 'team_id');
const memberName = getString(payload, 'memberName', 'member_name', 'agentName', 'agent_name');
if (!sessionId) {
return { ok: false, reason: 'missing_session_identity' };
}
if (!teamName || !memberName) {
return { ok: false, reason: 'missing_team_member_identity' };
}
const payloadHash = this.hash.sha256Hex(input.raw);
const promptMessageId = getString(
payload,
'runtimePromptMessageId',
'runtime_prompt_message_id',
'promptMessageId',
'prompt_message_id'
);
const turnId = getString(payload, 'turnId', 'turn_id') ?? promptMessageId;
const cwd = getString(payload, 'cwd', 'projectPath', 'project_path');
const agentId = getString(payload, 'agentId', 'agent_id', 'laneId', 'lane_id');
const outcome = getString(payload, 'outcome');
return {
ok: true,
event: {
schemaVersion: 1,
provider: 'opencode',
hookEventName: 'Stop',
payloadHash,
recordedAt:
getString(payload, 'recordedAt', 'recorded_at', 'observedAt', 'observed_at') ??
input.recordedAt,
sourceId: buildRuntimeTurnSettledSourceId({
provider: 'opencode',
sessionId,
turnId,
payloadHash,
}),
sessionId,
...(turnId ? { turnId } : {}),
...(cwd ? { cwd } : {}),
teamName,
memberName,
...(agentId ? { agentId } : {}),
...(promptMessageId ? { threadId: promptMessageId } : {}),
...(outcome ? { outcome } : {}),
},
};
}
}

View file

@ -0,0 +1,47 @@
import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller';
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment';
import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
export class RuntimeTurnSettledSpoolInitializer {
private readonly paths: RuntimeTurnSettledSpoolPaths;
private readonly installer: ShellRuntimeTurnSettledHookScriptInstaller;
constructor(teamsBasePath: string) {
this.paths = new RuntimeTurnSettledSpoolPaths(teamsBasePath);
this.installer = new ShellRuntimeTurnSettledHookScriptInstaller(this.paths);
}
getPaths(): RuntimeTurnSettledSpoolPaths {
return this.paths;
}
async buildHookSettings(input: {
provider: RuntimeTurnSettledProvider;
}): Promise<Record<string, unknown> | null> {
if (input.provider !== 'claude') {
return null;
}
const installed = await this.installer.install();
return buildRuntimeTurnSettledHookSettings({
scriptPath: installed.scriptPath,
spoolRoot: installed.spoolRoot,
provider: input.provider,
});
}
async buildEnvironment(input: {
provider: RuntimeTurnSettledProvider;
}): Promise<Record<string, string> | null> {
if (input.provider !== 'codex' && input.provider !== 'opencode') {
return null;
}
const installed = await this.installer.install();
return buildRuntimeTurnSettledEnvironment({
provider: input.provider,
spoolRoot: installed.spoolRoot,
});
}
}

View file

@ -1,13 +1,12 @@
import type { RuntimeTurnSettledProvider } from '../../core/domain';
export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV =
'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT';
export const RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV = 'AGENT_TEAMS_RUNTIME_TURN_SETTLED_SPOOL_ROOT';
export function buildRuntimeTurnSettledEnvironment(input: {
provider: RuntimeTurnSettledProvider;
spoolRoot: string;
}): Record<string, string> | null {
if (input.provider !== 'codex') {
if (input.provider !== 'codex' && input.provider !== 'opencode') {
return null;
}
return {

View file

@ -31,6 +31,7 @@ import {
createCodexModelCatalogFeature,
} from '@features/codex-model-catalog/main';
import {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
type MemberWorkSyncFeatureFacade,
registerMemberWorkSyncIpc,
@ -246,6 +247,21 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
try {
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
teamsBasePath: getTeamsBasePath(),
provider: 'opencode',
});
if (turnSettledEnv) {
Object.assign(bridgeEnv, turnSettledEnv);
}
} catch (error) {
logger.warn(
`[OpenCode] Runtime adapter bridge turn-settled spool unavailable: ${
error instanceof Error ? error.message : String(error)
}`
);
}
try {
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
const mcpEntry = mcpLaunchSpec.args[0];

View file

@ -113,6 +113,29 @@ describe('FileRuntimeTurnSettledEventStore', () => {
});
});
it('claims OpenCode runtime turn-settled payloads', async () => {
const paths = await makePaths();
await fs.mkdir(paths.getIncomingDir(), { recursive: true });
await fs.writeFile(
path.join(paths.getIncomingDir(), '20260429-1.opencode.json'),
'{"eventName":"runtime_turn_settled"}',
'utf8'
);
const store = new FileRuntimeTurnSettledEventStore({
paths,
now: () => new Date('2026-04-29T12:00:00.000Z'),
});
const claimed = await store.claimPending(10);
expect(claimed).toHaveLength(1);
expect(claimed[0]).toMatchObject({
fileName: '20260429-1.opencode.json',
provider: 'opencode',
raw: '{"eventName":"runtime_turn_settled"}',
});
});
it('does not reclaim fresh processing payloads from an active drain', async () => {
const paths = await makePaths();
await fs.mkdir(paths.getProcessingDir(), { recursive: true });

View file

@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { OpenCodeTurnSettledPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer';
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
describe('OpenCodeTurnSettledPayloadNormalizer', () => {
it('normalizes orchestrator-native OpenCode turn-settled payloads', () => {
const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter());
const result = normalizer.normalize({
provider: 'opencode',
raw: 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: 'lane-jack',
memberName: 'jack',
teamName: 'team-a',
projectPath: '/tmp/project',
outcome: 'success',
observedAt: '2026-04-29T12:00:00.000Z',
}),
recordedAt: '2026-04-29T12:00:01.000Z',
});
expect(result).toEqual({
ok: true,
event: expect.objectContaining({
provider: 'opencode',
hookEventName: 'Stop',
sessionId: 'ses-opencode-1',
turnId: 'msg_123',
threadId: 'msg_123',
teamName: 'team-a',
memberName: 'jack',
agentId: 'lane-jack',
cwd: '/tmp/project',
outcome: 'success',
recordedAt: '2026-04-29T12:00:00.000Z',
}),
});
});
it('rejects OpenCode payloads without durable team/member identity', () => {
const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter());
expect(
normalizer.normalize({
provider: 'opencode',
raw: JSON.stringify({
provider: 'opencode',
source: 'agent-teams-orchestrator-opencode',
eventName: 'runtime_turn_settled',
sessionId: 'ses-opencode-1',
}),
recordedAt: '2026-04-29T12:00:01.000Z',
})
).toEqual({ ok: false, reason: 'missing_team_member_identity' });
});
it('rejects payloads from non-agent-teams OpenCode sources', () => {
const normalizer = new OpenCodeTurnSettledPayloadNormalizer(new NodeHashAdapter());
expect(
normalizer.normalize({
provider: 'opencode',
raw: JSON.stringify({
provider: 'opencode',
source: 'some-other-source',
eventName: 'runtime_turn_settled',
sessionId: 'ses-opencode-1',
teamName: 'team-a',
memberName: 'jack',
}),
recordedAt: '2026-04-29T12:00:01.000Z',
})
).toEqual({ ok: false, reason: 'source_mismatch' });
});
});

View file

@ -133,4 +133,63 @@ describe('TeamRuntimeTurnSettledTargetResolver', () => {
})
).resolves.toEqual({ ok: false, reason: 'provider_mismatch' });
});
it('resolves OpenCode turn-settled payloads from durable team/member identity', async () => {
const resolver = new TeamRuntimeTurnSettledTargetResolver({
teamSource: {
listTeams: vi.fn(async () => {
throw new Error('opencode path should not scan attributed files');
}),
getConfig: vi.fn(async () => ({
name: 'team-a',
members: [{ name: 'Jack', providerId: 'opencode' }],
}) satisfies TeamConfig),
},
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
memberLogsFinder: {
listAttributedMemberFiles: vi.fn(async () => []),
},
});
await expect(
resolver.resolve({
schemaVersion: 1,
provider: 'opencode',
hookEventName: 'Stop',
sourceId: 'source-1',
payloadHash: 'hash',
recordedAt: '2026-04-29T12:00:00.000Z',
sessionId: 'ses-1',
teamName: 'team-a',
memberName: 'jack',
})
).resolves.toEqual({ ok: true, teamName: 'team-a', memberName: 'jack' });
});
it('rejects OpenCode events for non-OpenCode teammates', async () => {
const resolver = new TeamRuntimeTurnSettledTargetResolver({
teamSource: {
listTeams: vi.fn(async () => []),
getConfig: vi.fn(async () => ({
name: 'team-a',
members: [{ name: 'Jack', providerId: 'codex' }],
}) satisfies TeamConfig),
},
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
});
await expect(
resolver.resolve({
schemaVersion: 1,
provider: 'opencode',
hookEventName: 'Stop',
sourceId: 'source-1',
payloadHash: 'hash',
recordedAt: '2026-04-29T12:00:00.000Z',
sessionId: 'ses-1',
teamName: 'team-a',
memberName: 'jack',
})
).resolves.toEqual({ ok: false, reason: 'provider_mismatch' });
});
});

View file

@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import {
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
resolveMemberWorkSyncNudgeSideEffectsEnabled,
} from '@features/member-work-sync/main';
@ -142,4 +143,50 @@ describe('createMemberWorkSyncFeature composition', () => {
await feature.dispose();
}
});
it('builds OpenCode turn-settled environment without requiring nudge side effects', async () => {
const root = makeTempRoot();
const feature = createMemberWorkSyncFeature({
teamsBasePath: root,
configReader: {} as never,
taskReader: {} as never,
kanbanManager: {} as never,
membersMetaStore: {} as never,
nudgeSideEffectsEnabled: false,
});
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) });
});
});