feat: add member work sync stop hook ingest

This commit is contained in:
777genius 2026-04-29 18:18:02 +03:00
parent b2edfe5d2a
commit 05d9b5a9b1
28 changed files with 3379 additions and 12 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,96 @@
import type { MemberWorkSyncClockPort, MemberWorkSyncLoggerPort } from './ports';
import type {
RuntimeTurnSettledEventStorePort,
RuntimeTurnSettledPayloadNormalizerPort,
RuntimeTurnSettledReconcileQueuePort,
RuntimeTurnSettledTargetResolverPort,
} from './RuntimeTurnSettledPorts';
export interface RuntimeTurnSettledIngestorDeps {
eventStore: RuntimeTurnSettledEventStorePort;
normalizer: RuntimeTurnSettledPayloadNormalizerPort;
targetResolver: RuntimeTurnSettledTargetResolverPort;
reconcileQueue: RuntimeTurnSettledReconcileQueuePort;
clock: MemberWorkSyncClockPort;
logger?: MemberWorkSyncLoggerPort;
}
export interface RuntimeTurnSettledDrainSummary {
claimed: number;
enqueued: number;
unresolved: number;
invalid: number;
failed: number;
}
export class RuntimeTurnSettledIngestor {
constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {}
async drainPending(limit: number = 50): Promise<RuntimeTurnSettledDrainSummary> {
const summary: RuntimeTurnSettledDrainSummary = {
claimed: 0,
enqueued: 0,
unresolved: 0,
invalid: 0,
failed: 0,
};
const payloads = await this.deps.eventStore.claimPending(limit);
summary.claimed = payloads.length;
for (const payload of payloads) {
const processedAt = this.deps.clock.now().toISOString();
try {
const normalized = this.deps.normalizer.normalize({
provider: payload.provider,
raw: payload.raw,
recordedAt: payload.claimedAt,
});
if (!normalized.ok) {
summary.invalid += 1;
await this.deps.eventStore.markInvalid(payload, {
reason: normalized.reason,
processedAt,
});
continue;
}
const resolution = await this.deps.targetResolver.resolve(normalized.event);
if (!resolution.ok) {
summary.unresolved += 1;
await this.deps.eventStore.markProcessed(payload, {
event: normalized.event,
outcome: 'unresolved',
reason: resolution.reason,
processedAt,
});
continue;
}
this.deps.reconcileQueue.enqueueRuntimeTurnSettled({
teamName: resolution.teamName,
memberName: resolution.memberName,
event: normalized.event,
});
summary.enqueued += 1;
await this.deps.eventStore.markProcessed(payload, {
event: normalized.event,
teamName: resolution.teamName,
memberName: resolution.memberName,
outcome: 'enqueued',
processedAt,
});
} catch (error) {
summary.failed += 1;
this.deps.logger?.warn('runtime turn settled ingest failed', {
filePath: payload.filePath,
provider: payload.provider,
error: String(error),
});
}
}
return summary;
}
}

View file

@ -0,0 +1,64 @@
import type { RuntimeTurnSettledEvent, RuntimeTurnSettledProvider } from '../domain';
export interface RuntimeTurnSettledClaimedPayload {
id: string;
filePath: string;
fileName: string;
provider: RuntimeTurnSettledProvider;
raw: string;
claimedAt: string;
}
export interface RuntimeTurnSettledEventStorePort {
claimPending(limit: number): Promise<RuntimeTurnSettledClaimedPayload[]>;
markProcessed(
payload: RuntimeTurnSettledClaimedPayload,
result: RuntimeTurnSettledProcessedResult
): Promise<void>;
markInvalid(
payload: RuntimeTurnSettledClaimedPayload,
result: RuntimeTurnSettledInvalidResult
): Promise<void>;
}
export interface RuntimeTurnSettledProcessedResult {
event: RuntimeTurnSettledEvent;
teamName?: string;
memberName?: string;
outcome: 'enqueued' | 'unresolved' | 'duplicate';
reason?: string;
processedAt: string;
}
export interface RuntimeTurnSettledInvalidResult {
reason: string;
processedAt: string;
}
export type RuntimeTurnSettledPayloadNormalization =
| { ok: true; event: RuntimeTurnSettledEvent }
| { ok: false; reason: string };
export interface RuntimeTurnSettledPayloadNormalizerPort {
normalize(input: {
provider: RuntimeTurnSettledProvider;
raw: string;
recordedAt: string;
}): RuntimeTurnSettledPayloadNormalization;
}
export type RuntimeTurnSettledTargetResolution =
| { ok: true; teamName: string; memberName: string }
| { ok: false; reason: string };
export interface RuntimeTurnSettledTargetResolverPort {
resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution>;
}
export interface RuntimeTurnSettledReconcileQueuePort {
enqueueRuntimeTurnSettled(input: {
teamName: string;
memberName: string;
event: RuntimeTurnSettledEvent;
}): void;
}

View file

@ -6,3 +6,5 @@ export * from './MemberWorkSyncPendingReportIntentReplayer';
export * from './MemberWorkSyncReconciler';
export * from './MemberWorkSyncReporter';
export * from './ports';
export * from './RuntimeTurnSettledIngestor';
export * from './RuntimeTurnSettledPorts';

View file

@ -0,0 +1,31 @@
import type { RuntimeTurnSettledProvider } from './RuntimeTurnSettledProvider';
export interface RuntimeTurnSettledEvent {
schemaVersion: 1;
provider: RuntimeTurnSettledProvider;
hookEventName: 'Stop';
sourceId: string;
payloadHash: string;
recordedAt: string;
sessionId?: string;
turnId?: string;
transcriptPath?: string;
cwd?: string;
}
export function buildRuntimeTurnSettledSourceId(input: {
provider: RuntimeTurnSettledProvider;
sessionId?: string;
turnId?: string;
transcriptPath?: string;
payloadHash: string;
}): string {
return [
'runtime-turn-settled',
input.provider,
input.sessionId?.trim() || 'no-session',
input.turnId?.trim() || 'no-turn',
input.transcriptPath?.trim() || 'no-transcript',
input.payloadHash,
].join(':');
}

View file

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

View file

@ -5,4 +5,6 @@ export * from './memberName';
export * from './MemberWorkSyncPhase2Readiness';
export * from './MemberWorkSyncNudge';
export * from './MemberWorkSyncReportValidator';
export * from './RuntimeTurnSettledEvent';
export * from './RuntimeTurnSettledProvider';
export * from './SyncDecisionPolicy';

View file

@ -4,6 +4,12 @@ import type {
} from '../../infrastructure/MemberWorkSyncEventQueue';
import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
interface MemberTurnSettledEventPayload {
memberName?: string;
sourceId?: string;
provider?: string;
}
interface MemberWorkSyncRosterSource {
loadActiveMemberNames(teamName: string): Promise<string[]>;
}
@ -37,6 +43,18 @@ function parseToolActivity(detail: string | undefined): ToolActivityEventPayload
}
}
function parseMemberTurnSettled(detail: string | undefined): MemberTurnSettledEventPayload | null {
if (!detail) {
return null;
}
try {
const parsed = JSON.parse(detail) as MemberTurnSettledEventPayload;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
export class MemberWorkSyncTeamChangeRouter {
constructor(
private readonly rosterSource: MemberWorkSyncRosterSource,
@ -82,6 +100,19 @@ export class MemberWorkSyncTeamChangeRouter {
return;
}
if (event.type === 'member-turn-settled') {
const payload = parseMemberTurnSettled(event.detail);
const memberName = payload?.memberName?.trim();
if (memberName) {
this.queue.enqueue({
teamName: event.teamName,
memberName,
triggerReason: 'turn_settled',
});
}
return;
}
if (event.type === 'inbox' || event.type === 'lead-message') {
const recipient = parseInboxRecipient(event.detail);
if (recipient) {

View file

@ -0,0 +1,174 @@
import path from 'path';
import { isReservedMemberName, normalizeMemberName } from '../../../core/domain';
import type {
RuntimeTurnSettledTargetResolution,
RuntimeTurnSettledTargetResolverPort,
} from '../../../core/application';
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import type { RuntimeTurnSettledEvent } from '../../../core/domain';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
import type { TeamMember, TeamSummary } from '@shared/types';
export interface RuntimeTurnSettledTeamSource {
listTeams(): Promise<TeamSummary[]>;
getConfig(teamName: string): ReturnType<TeamConfigReader['getConfig']>;
}
export interface AttributedMemberFileSource {
listAttributedMemberFiles(
teamName: string
): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]>;
}
export interface TeamRuntimeTurnSettledTargetResolverDeps {
teamSource: RuntimeTurnSettledTeamSource;
membersMetaStore: TeamMembersMetaStore;
memberLogsFinder?: AttributedMemberFileSource;
maxTeamsToScan?: number;
}
function memberKey(member: Pick<TeamMember, 'name'>): string {
return normalizeMemberName(member.name);
}
function mergeMembers(configMembers: TeamMember[], metaMembers: TeamMember[]): TeamMember[] {
const byName = new Map<string, TeamMember>();
for (const member of configMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, member);
}
}
for (const member of metaMembers) {
const key = memberKey(member);
if (key) {
byName.set(key, { ...byName.get(key), ...member });
}
}
return [...byName.values()];
}
function providerForMember(member: TeamMember | undefined): string | undefined {
return (
normalizeOptionalTeamProviderId(member?.providerId) ??
inferTeamProviderIdFromModel(member?.model)
);
}
function normalizePath(value: string | undefined): string | null {
if (!value?.trim()) {
return null;
}
return path.resolve(value.trim());
}
export class TeamRuntimeTurnSettledTargetResolver
implements RuntimeTurnSettledTargetResolverPort
{
private readonly memberLogsFinder: AttributedMemberFileSource;
private readonly maxTeamsToScan: number;
constructor(private readonly deps: TeamRuntimeTurnSettledTargetResolverDeps) {
this.memberLogsFinder = deps.memberLogsFinder ?? new TeamMemberLogsFinder();
this.maxTeamsToScan = Math.max(1, deps.maxTeamsToScan ?? 200);
}
async resolve(event: RuntimeTurnSettledEvent): Promise<RuntimeTurnSettledTargetResolution> {
if (event.provider !== 'claude') {
return { ok: false, reason: 'unsupported_provider' };
}
const transcriptPath = normalizePath(event.transcriptPath);
const sessionId = event.sessionId?.trim() || null;
if (!transcriptPath && !sessionId) {
return { ok: false, reason: 'missing_session_identity' };
}
const teams = (await this.deps.teamSource.listTeams())
.filter((team) => !team.deletedAt)
.slice(0, this.maxTeamsToScan);
const candidates: Array<{
teamName: string;
memberName: string;
exactPath: boolean;
mtimeMs: number;
}> = [];
for (const team of teams) {
const attributedFiles = await this.memberLogsFinder
.listAttributedMemberFiles(team.teamName)
.catch(() => []);
for (const file of attributedFiles) {
const exactPath = transcriptPath ? normalizePath(file.filePath) === transcriptPath : false;
const sessionMatch = sessionId ? file.sessionId === sessionId : false;
if (!exactPath && !sessionMatch) {
continue;
}
candidates.push({
teamName: team.teamName,
memberName: file.memberName,
exactPath,
mtimeMs: file.mtimeMs,
});
}
}
const candidate = candidates.sort((left, right) => {
if (left.exactPath !== right.exactPath) {
return left.exactPath ? -1 : 1;
}
return right.mtimeMs - left.mtimeMs;
})[0];
if (!candidate) {
return { ok: false, reason: 'no_matching_member_session' };
}
const member = await this.resolveActiveMember(candidate.teamName, candidate.memberName);
if (!member) {
return { ok: false, reason: 'member_not_active' };
}
if (isReservedMemberName(member.name)) {
return { ok: false, reason: 'reserved_member' };
}
const providerId = providerForMember(member);
if (providerId && providerId !== 'anthropic') {
return { ok: false, reason: 'provider_mismatch' };
}
return {
ok: true,
teamName: candidate.teamName,
memberName: normalizeMemberName(member.name),
};
}
private async resolveActiveMember(
teamName: string,
memberName: string
): Promise<TeamMember | null> {
const [config, metaMembers] = await Promise.all([
this.deps.teamSource.getConfig(teamName),
this.deps.membersMetaStore.getMembers(teamName).catch(() => []),
]);
if (!config || config.deletedAt) {
return null;
}
const normalizedTarget = normalizeMemberName(memberName);
return (
mergeMembers(config.members ?? [], metaMembers).find(
(member) => !member.removedAt && memberKey(member) === normalizedTarget
) ?? null
);
}
}

View file

@ -16,11 +16,16 @@ import {
MemberWorkSyncReconciler,
MemberWorkSyncReporter,
type MemberWorkSyncReconcileContext,
RuntimeTurnSettledIngestor,
type RuntimeTurnSettledDrainSummary,
} from '../../core/application';
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown';
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
import {
MemberWorkSyncEventQueue,
@ -31,6 +36,10 @@ import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStoreP
import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWorkSyncNudgeDispatchScheduler';
import { MemberWorkSyncToolActivityBusySignal } from '../infrastructure/MemberWorkSyncToolActivityBusySignal';
import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
import { RuntimeTurnSettledDrainScheduler } from '../infrastructure/RuntimeTurnSettledDrainScheduler';
import { RuntimeTurnSettledSpoolPaths } from '../infrastructure/RuntimeTurnSettledSpoolPaths';
import { ShellRuntimeTurnSettledHookScriptInstaller } from '../infrastructure/ShellRuntimeTurnSettledHookScriptInstaller';
import { buildRuntimeTurnSettledHookSettings } from '../infrastructure/runtimeTurnSettledHookSettings';
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
@ -39,6 +48,35 @@ import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaSt
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamChangeEvent } from '@shared/types';
import type { MemberWorkSyncLoggerPort } from '../../core/application';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
export const MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV =
'CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED';
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off', '']);
function emptyNudgeDispatchSummary(): MemberWorkSyncNudgeDispatchSummary {
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
}
export function resolveMemberWorkSyncNudgeSideEffectsEnabled(
env: Record<string, string | undefined> = process.env
): boolean {
const rawValue = env[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV];
if (rawValue == null) {
return false;
}
const value = rawValue.trim().toLowerCase();
if (TRUE_ENV_VALUES.has(value)) {
return true;
}
if (FALSE_ENV_VALUES.has(value)) {
return false;
}
return false;
}
export interface MemberWorkSyncFeatureFacade {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
@ -48,6 +86,10 @@ export interface MemberWorkSyncFeatureFacade {
enqueueStartupScan(teamNames: string[]): Promise<void>;
replayPendingReports(teamNames: string[]): Promise<MemberWorkSyncPendingReportReplaySummary>;
dispatchDueNudges(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
buildRuntimeTurnSettledHookSettings(input: {
provider: RuntimeTurnSettledProvider;
}): Promise<Record<string, unknown> | null>;
drainRuntimeTurnSettledEvents(): Promise<RuntimeTurnSettledDrainSummary>;
getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics;
dispose(): Promise<void>;
}
@ -60,6 +102,7 @@ export function createMemberWorkSyncFeature(deps: {
membersMetaStore: TeamMembersMetaStore;
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
listLifecycleActiveTeamNames?: () => Promise<string[]>;
nudgeSideEffectsEnabled?: boolean;
logger?: MemberWorkSyncLoggerPort;
}): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter();
@ -74,18 +117,32 @@ 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 runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({
paths: runtimeTurnSettledSpoolPaths,
});
const runtimeTurnSettledNormalizer = new ClaudeStopHookPayloadNormalizer(hash);
const runtimeTurnSettledTargetResolver = new TeamRuntimeTurnSettledTargetResolver({
teamSource: deps.configReader,
membersMetaStore: deps.membersMetaStore,
});
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
const busySignal = new MemberWorkSyncToolActivityBusySignal();
const nudgeSideEffectsEnabled =
deps.nudgeSideEffectsEnabled ?? resolveMemberWorkSyncNudgeSideEffectsEnabled();
const inboxNudge = nudgeSideEffectsEnabled ? new TeamInboxMemberWorkSyncNudgeSink() : null;
const useCaseDeps = {
clock,
hash,
agendaSource,
statusStore: store,
reportStore: store,
outboxStore: store,
inboxNudge,
...(nudgeSideEffectsEnabled ? { outboxStore: store } : {}),
...(inboxNudge ? { inboxNudge } : {}),
watchdogCooldown,
busySignal,
reportToken,
@ -101,16 +158,42 @@ export function createMemberWorkSyncFeature(deps: {
const queue = new MemberWorkSyncEventQueue({
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
await reconciler.execute(request, context);
await nudgeDispatcher.dispatchDue({
teamNames: [request.teamName],
claimedBy: `member-work-sync:${process.pid}`,
});
if (nudgeSideEffectsEnabled) {
await nudgeDispatcher.dispatchDue({
teamNames: [request.teamName],
claimedBy: `member-work-sync:${process.pid}`,
});
}
},
isTeamActive: deps.isTeamActive ?? (() => true),
logger: deps.logger,
});
const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue);
const nudgeDispatchScheduler = deps.listLifecycleActiveTeamNames
const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({
eventStore: runtimeTurnSettledStore,
normalizer: runtimeTurnSettledNormalizer,
targetResolver: runtimeTurnSettledTargetResolver,
reconcileQueue: {
enqueueRuntimeTurnSettled: ({ teamName, memberName, event }) => {
router.noteTeamChange({
type: 'member-turn-settled',
teamName,
detail: JSON.stringify({
memberName,
sourceId: event.sourceId,
provider: event.provider,
}),
});
},
},
clock,
logger: deps.logger,
});
const runtimeTurnSettledDrainScheduler = new RuntimeTurnSettledDrainScheduler({
drain: () => runtimeTurnSettledIngestor.drainPending(),
logger: deps.logger,
});
const nudgeDispatchScheduler = nudgeSideEffectsEnabled && deps.listLifecycleActiveTeamNames
? new MemberWorkSyncNudgeDispatchScheduler({
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
dispatchDue: (teamNames) =>
@ -121,6 +204,7 @@ export function createMemberWorkSyncFeature(deps: {
logger: deps.logger,
})
: null;
runtimeTurnSettledDrainScheduler.start();
nudgeDispatchScheduler?.start();
return {
@ -151,9 +235,27 @@ export function createMemberWorkSyncFeature(deps: {
);
},
dispatchDueNudges: (teamNames) =>
nudgeDispatcher.dispatchDue({ teamNames, claimedBy: `member-work-sync:${process.pid}` }),
nudgeSideEffectsEnabled
? nudgeDispatcher.dispatchDue({
teamNames,
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,
});
},
drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(),
getQueueDiagnostics: () => queue.getDiagnostics(),
dispose: async () => {
runtimeTurnSettledDrainScheduler.dispose();
await Promise.allSettled([queue.stop(), nudgeDispatchScheduler?.dispose()]);
},
};

View file

@ -2,5 +2,9 @@ export {
registerMemberWorkSyncIpc,
removeMemberWorkSyncIpc,
} from './adapters/input/registerMemberWorkSyncIpc';
export { createMemberWorkSyncFeature } from './composition/createMemberWorkSyncFeature';
export {
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
createMemberWorkSyncFeature,
resolveMemberWorkSyncNudgeSideEffectsEnabled,
} from './composition/createMemberWorkSyncFeature';
export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature';

View file

@ -0,0 +1,86 @@
import {
buildRuntimeTurnSettledSourceId,
type RuntimeTurnSettledProvider,
} from '../../core/domain';
import type {
MemberWorkSyncHashPort,
RuntimeTurnSettledPayloadNormalization,
RuntimeTurnSettledPayloadNormalizerPort,
} from '../../core/application';
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 ClaudeStopHookPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort {
constructor(private readonly hash: MemberWorkSyncHashPort) {}
normalize(input: {
provider: RuntimeTurnSettledProvider;
raw: string;
recordedAt: string;
}): RuntimeTurnSettledPayloadNormalization {
if (input.provider !== 'claude') {
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 hookEventName = getString(payload, 'hook_event_name', 'hookEventName');
if (hookEventName !== 'Stop') {
return { ok: false, reason: 'not_stop_hook' };
}
const payloadHash = this.hash.sha256Hex(input.raw);
const event = {
schemaVersion: 1 as const,
provider: 'claude' as const,
hookEventName: 'Stop' as const,
payloadHash,
recordedAt: input.recordedAt,
sourceId: buildRuntimeTurnSettledSourceId({
provider: 'claude',
sessionId: getString(payload, 'session_id', 'sessionId'),
turnId: getString(payload, 'turn_id', 'turnId'),
transcriptPath: getString(payload, 'transcript_path', 'transcriptPath'),
payloadHash,
}),
sessionId: getString(payload, 'session_id', 'sessionId'),
turnId: getString(payload, 'turn_id', 'turnId'),
transcriptPath: getString(payload, 'transcript_path', 'transcriptPath'),
cwd: getString(payload, 'cwd'),
};
if (!event.sessionId && !event.transcriptPath) {
return { ok: false, reason: 'missing_session_identity' };
}
return { ok: true, event };
}
}

View file

@ -0,0 +1,191 @@
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises';
import path from 'path';
import { isRuntimeTurnSettledProvider } from '../../core/domain';
import type {
RuntimeTurnSettledClaimedPayload,
RuntimeTurnSettledEventStorePort,
RuntimeTurnSettledInvalidResult,
RuntimeTurnSettledProcessedResult,
} from '../../core/application';
import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
const DEFAULT_PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000;
const DEFAULT_PROCESSED_RETENTION_COUNT = 1000;
export interface FileRuntimeTurnSettledEventStoreDeps {
paths: RuntimeTurnSettledSpoolPaths;
maxPayloadBytes?: number;
processedRetentionMs?: number;
processedRetentionCount?: number;
now?: () => Date;
}
function parseProviderFromFileName(fileName: string): 'claude' | 'codex' | null {
const parts = fileName.split('.');
const provider = parts.length >= 3 ? parts[parts.length - 2] : null;
return isRuntimeTurnSettledProvider(provider) ? provider : null;
}
function buildMetaFilePath(filePath: string): string {
return `${filePath}.meta.json`;
}
async function moveFileBestEffort(sourcePath: string, targetPath: string): Promise<void> {
await mkdir(path.dirname(targetPath), { recursive: true });
try {
await rename(sourcePath, targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
export class FileRuntimeTurnSettledEventStore implements RuntimeTurnSettledEventStorePort {
private readonly maxPayloadBytes: number;
private readonly processedRetentionMs: number;
private readonly processedRetentionCount: number;
private readonly now: () => Date;
constructor(private readonly deps: FileRuntimeTurnSettledEventStoreDeps) {
this.maxPayloadBytes = deps.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
this.processedRetentionMs = deps.processedRetentionMs ?? DEFAULT_PROCESSED_RETENTION_MS;
this.processedRetentionCount =
deps.processedRetentionCount ?? DEFAULT_PROCESSED_RETENTION_COUNT;
this.now = deps.now ?? (() => new Date());
}
async claimPending(limit: number): Promise<RuntimeTurnSettledClaimedPayload[]> {
await this.ensureDirectories();
const entries = await readdir(this.deps.paths.getIncomingDir(), { withFileTypes: true }).catch(
() => []
);
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => !fileName.startsWith('.'))
.sort()
.slice(0, Math.max(0, limit));
const claimed: RuntimeTurnSettledClaimedPayload[] = [];
for (const fileName of files) {
const provider = parseProviderFromFileName(fileName);
const incomingPath = path.join(this.deps.paths.getIncomingDir(), fileName);
if (!provider) {
await this.quarantineIncoming(incomingPath, fileName, 'unsupported_provider');
continue;
}
const processingPath = path.join(this.deps.paths.getProcessingDir(), fileName);
try {
await rename(incomingPath, processingPath);
} catch {
continue;
}
const fileStat = await stat(processingPath).catch(() => null);
const payloadTooLarge = Boolean(fileStat?.isFile() && fileStat.size > this.maxPayloadBytes);
if (!fileStat?.isFile() || payloadTooLarge) {
await this.markInvalid(
{
id: fileName,
filePath: processingPath,
fileName,
provider,
raw: '',
claimedAt: this.now().toISOString(),
},
{
reason: payloadTooLarge ? 'payload_too_large' : 'payload_missing',
processedAt: this.now().toISOString(),
}
);
continue;
}
const raw = await readFile(processingPath, 'utf8').catch(() => '');
claimed.push({
id: fileName,
filePath: processingPath,
fileName,
provider,
raw,
claimedAt: this.now().toISOString(),
});
}
return claimed;
}
async markProcessed(
payload: RuntimeTurnSettledClaimedPayload,
result: RuntimeTurnSettledProcessedResult
): Promise<void> {
const processedPath = path.join(this.deps.paths.getProcessedDir(), payload.fileName);
await moveFileBestEffort(payload.filePath, processedPath);
await writeFile(buildMetaFilePath(processedPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await this.cleanupDirectory(this.deps.paths.getProcessedDir());
}
async markInvalid(
payload: RuntimeTurnSettledClaimedPayload,
result: RuntimeTurnSettledInvalidResult
): Promise<void> {
const invalidPath = path.join(this.deps.paths.getInvalidDir(), payload.fileName);
await moveFileBestEffort(payload.filePath, invalidPath);
await writeFile(buildMetaFilePath(invalidPath), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await this.cleanupDirectory(this.deps.paths.getInvalidDir());
}
private async ensureDirectories(): Promise<void> {
await Promise.all([
mkdir(this.deps.paths.getIncomingDir(), { recursive: true }),
mkdir(this.deps.paths.getProcessingDir(), { recursive: true }),
mkdir(this.deps.paths.getProcessedDir(), { recursive: true }),
mkdir(this.deps.paths.getInvalidDir(), { recursive: true }),
]);
}
private async quarantineIncoming(
incomingPath: string,
fileName: string,
reason: string
): Promise<void> {
const invalidPath = path.join(this.deps.paths.getInvalidDir(), fileName);
await moveFileBestEffort(incomingPath, invalidPath);
await writeFile(
buildMetaFilePath(invalidPath),
`${JSON.stringify({ reason, processedAt: this.now().toISOString() }, null, 2)}\n`,
'utf8'
);
}
private async cleanupDirectory(directory: string): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const files = (
await Promise.all(
entries
.filter((entry) => entry.isFile() && !entry.name.endsWith('.meta.json'))
.map(async (entry) => {
const filePath = path.join(directory, entry.name);
const fileStat = await stat(filePath).catch(() => null);
return fileStat?.isFile() ? { filePath, mtimeMs: fileStat.mtimeMs } : null;
})
)
)
.filter((entry): entry is { filePath: string; mtimeMs: number } => Boolean(entry))
.sort((left, right) => right.mtimeMs - left.mtimeMs);
const cutoff = this.now().getTime() - this.processedRetentionMs;
const toRemove = files.filter(
(file, index) => index >= this.processedRetentionCount || file.mtimeMs < cutoff
);
await Promise.allSettled(
toRemove.flatMap((file) => [rm(file.filePath, { force: true }), rm(buildMetaFilePath(file.filePath), { force: true })])
);
}
}

View file

@ -9,6 +9,7 @@ export type MemberWorkSyncTriggerReason =
| 'member_spawned'
| 'tool_finished'
| 'runtime_activity'
| 'turn_settled'
| 'manual_refresh';
export interface MemberWorkSyncQueueDiagnostics {

View file

@ -0,0 +1,69 @@
import type {
MemberWorkSyncLoggerPort,
RuntimeTurnSettledDrainSummary,
} from '../../core/application';
export interface RuntimeTurnSettledDrainSchedulerDeps {
drain(): Promise<RuntimeTurnSettledDrainSummary>;
intervalMs?: number;
logger?: MemberWorkSyncLoggerPort;
}
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
timer.unref?.();
}
export class RuntimeTurnSettledDrainScheduler {
private readonly intervalMs: number;
private timer: ReturnType<typeof setTimeout> | null = null;
private running = false;
private disposed = false;
constructor(private readonly deps: RuntimeTurnSettledDrainSchedulerDeps) {
this.intervalMs = Math.max(1_000, deps.intervalMs ?? 15_000);
}
start(): void {
if (this.disposed || this.timer) {
return;
}
this.schedule(100);
}
async drainNow(): Promise<RuntimeTurnSettledDrainSummary | null> {
if (this.running || this.disposed) {
return null;
}
this.running = true;
try {
return await this.deps.drain();
} catch (error) {
this.deps.logger?.warn('runtime turn settled scheduled drain failed', {
error: String(error),
});
return null;
} finally {
this.running = false;
}
}
dispose(): void {
this.disposed = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
private schedule(delayMs: number = this.intervalMs): void {
if (this.disposed) {
return;
}
this.timer = setTimeout(() => {
this.timer = null;
void this.drainNow().finally(() => this.schedule());
}, delayMs);
unrefTimer(this.timer);
}
}

View file

@ -0,0 +1,33 @@
import path from 'path';
export class RuntimeTurnSettledSpoolPaths {
constructor(private readonly teamsBasePath: string) {}
getRootDir(): string {
return path.join(this.teamsBasePath, '.member-work-sync', 'runtime-hooks');
}
getBinDir(): string {
return path.join(this.getRootDir(), 'bin');
}
getHookScriptPath(): string {
return path.join(this.getBinDir(), 'turn-settled-hook-v1.sh');
}
getIncomingDir(): string {
return path.join(this.getRootDir(), 'incoming');
}
getProcessingDir(): string {
return path.join(this.getRootDir(), 'processing');
}
getProcessedDir(): string {
return path.join(this.getRootDir(), 'processed');
}
getInvalidDir(): string {
return path.join(this.getRootDir(), 'invalid');
}
}

View file

@ -0,0 +1,68 @@
import fs from 'fs/promises';
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
const HOOK_SCRIPT_CONTENT = `#!/bin/sh
set +e
spool_root="$1"
provider="$2"
max_bytes="\${3:-262144}"
if [ -z "$spool_root" ] || [ -z "$provider" ]; then
exit 0
fi
case "$provider" in
claude|codex) ;;
*) exit 0 ;;
esac
incoming="$spool_root/incoming"
mkdir -p "$incoming" 2>/dev/null || exit 0
stamp="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo unknown-time)"
tmp="$(mktemp "$incoming/.turn-settled.XXXXXX" 2>/dev/null)" || exit 0
suffix="$(basename "$tmp" | sed 's/^\\.turn-settled\\.//')"
final="$incoming/$stamp-$$-$suffix.$provider.json"
dd bs="$max_bytes" count=1 of="$tmp" 2>/dev/null || {
rm -f "$tmp" 2>/dev/null
exit 0
}
if [ ! -s "$tmp" ]; then
rm -f "$tmp" 2>/dev/null
exit 0
fi
mv "$tmp" "$final" 2>/dev/null || {
rm -f "$tmp" 2>/dev/null
exit 0
}
exit 0
`;
export class ShellRuntimeTurnSettledHookScriptInstaller {
constructor(private readonly paths: RuntimeTurnSettledSpoolPaths) {}
async install(): Promise<{ scriptPath: string; spoolRoot: string }> {
await Promise.all([
fs.mkdir(this.paths.getBinDir(), { recursive: true }),
fs.mkdir(this.paths.getIncomingDir(), { recursive: true }),
fs.mkdir(this.paths.getProcessingDir(), { recursive: true }),
fs.mkdir(this.paths.getProcessedDir(), { recursive: true }),
fs.mkdir(this.paths.getInvalidDir(), { recursive: true }),
]);
const scriptPath = this.paths.getHookScriptPath();
await fs.writeFile(scriptPath, HOOK_SCRIPT_CONTENT, 'utf8');
await fs.chmod(scriptPath, 0o755);
return {
scriptPath,
spoolRoot: this.paths.getRootDir(),
};
}
}

View file

@ -0,0 +1,48 @@
import type { RuntimeTurnSettledProvider } from '../../core/domain';
export const MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER =
'agent-teams:member-work-sync-turn-settled:v1';
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function buildRuntimeTurnSettledHookCommand(input: {
scriptPath: string;
spoolRoot: string;
provider: RuntimeTurnSettledProvider;
maxBytes?: number;
}): string {
return [
'/bin/sh',
shellQuote(input.scriptPath),
shellQuote(input.spoolRoot),
shellQuote(input.provider),
shellQuote(String(input.maxBytes ?? 262_144)),
'#',
MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER,
].join(' ');
}
export function buildRuntimeTurnSettledHookSettings(input: {
scriptPath: string;
spoolRoot: string;
provider: RuntimeTurnSettledProvider;
maxBytes?: number;
}): Record<string, unknown> {
return {
hooks: {
Stop: [
{
matcher: '',
hooks: [
{
type: 'command',
command: buildRuntimeTurnSettledHookCommand(input),
},
],
},
],
},
};
}

View file

@ -1249,6 +1249,11 @@ async function initializeServices(): Promise<void> {
},
logger: createLogger('Feature:MemberWorkSync'),
});
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
memberWorkSyncFeature
? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input)
: Promise.resolve(null)
);
void teamDataService
.listTeams()
.then(async (teams) => {

View file

@ -24,8 +24,11 @@ function getHookEntryDedupeKey(value: unknown): string | null {
return null;
}
const commands = value.hooks
.map((hook) => (isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null))
const hooks = Array.isArray(value.hooks) ? value.hooks : [];
const commands = hooks
.map((hook: unknown) =>
isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null
)
.filter((command): command is string => Boolean(command));
if (commands.length === 0) {
return null;

View file

@ -2,6 +2,7 @@ import {
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/main';
import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/core/domain';
import {
buildCodexFastModeArgs,
resolveCodexFastMode,
@ -4320,6 +4321,9 @@ export class TeamProvisioningService {
private inFlightResponses = new Set<string>();
private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null;
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
private runtimeTurnSettledHookSettingsProvider:
| ((input: { provider: RuntimeTurnSettledProvider }) => Promise<Record<string, unknown> | null>)
| null = null;
private readonly stoppedTeamOpenCodeRuntimeCleanupInFlight = new Map<string, Promise<number>>();
private readonly cleanedStoppedTeamOpenCodeRuntimeLanes = new Set<string>();
private crossTeamSender:
@ -4381,6 +4385,36 @@ export class TeamProvisioningService {
this.controlApiBaseUrlResolver = resolver;
}
setRuntimeTurnSettledHookSettingsProvider(
provider:
| ((input: {
provider: RuntimeTurnSettledProvider;
}) => Promise<Record<string, unknown> | null>)
| null
): void {
this.runtimeTurnSettledHookSettingsProvider = provider;
}
private async buildRuntimeTurnSettledHookSettingsArgs(
providerId: TeamProviderId
): Promise<string[]> {
if (providerId !== 'anthropic' || !this.runtimeTurnSettledHookSettingsProvider) {
return [];
}
try {
const settings = await this.runtimeTurnSettledHookSettingsProvider({ provider: 'claude' });
return settings ? ['--settings', JSON.stringify(settings)] : [];
} catch (error) {
logger.warn(
`Failed to build member work sync Stop hook settings: ${
error instanceof Error ? error.message : String(error)
}`
);
return [];
}
}
private async readRuntimeProviderLaunchFacts(params: {
claudePath: string;
cwd: string;
@ -9869,6 +9903,8 @@ export class TeamProvisioningService {
input.leadName
);
const bootstrapExpectedAfter = nowIso();
const runtimeTurnSettledHookArgs =
await this.buildRuntimeTurnSettledHookSettingsArgs(providerId);
const runtimeArgs = mergeJsonSettingsArgs([
'--agent-id',
@ -9894,6 +9930,7 @@ export class TeamProvisioningService {
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []),
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []),
...runtimeTurnSettledHookArgs,
...(provisioningEnv.providerArgs ?? []),
]);
const command = buildDirectTmuxRestartCommand({
@ -12970,6 +13007,8 @@ export class TeamProvisioningService {
);
const resolvedProviderId = resolveTeamProviderId(request.providerId);
const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity);
const runtimeTurnSettledHookArgs =
await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId);
const spawnArgs = mergeJsonSettingsArgs([
'--input-format',
'stream-json',
@ -12995,6 +13034,7 @@ export class TeamProvisioningService {
...(launchModelArg ? ['--model', launchModelArg] : []),
...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []),
...providerFastModeArgs,
...runtimeTurnSettledHookArgs,
...(request.worktree ? ['--worktree', request.worktree] : []),
...buildDesktopTeammateModeCliArgs(teammateModeDecision),
...parseCliArgs(request.extraCliArgs),
@ -14102,6 +14142,8 @@ export class TeamProvisioningService {
);
const resolvedProviderId = resolveTeamProviderId(request.providerId);
const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity);
const runtimeTurnSettledHookArgs =
await this.buildRuntimeTurnSettledHookSettingsArgs(resolvedProviderId);
if (launchModelArg) {
launchArgs.push('--model', launchModelArg);
}
@ -14109,6 +14151,7 @@ export class TeamProvisioningService {
launchArgs.push('--effort', launchIdentity.resolvedEffort);
}
launchArgs.push(...providerFastModeArgs);
launchArgs.push(...runtimeTurnSettledHookArgs);
if (request.worktree) {
launchArgs.push('--worktree', request.worktree);
}
@ -22885,6 +22928,7 @@ export class TeamProvisioningService {
for (const providerId of crossProviderIds) {
try {
const env = await this.buildProvisioningEnv(providerId);
args.push(...(await this.buildRuntimeTurnSettledHookSettingsArgs(providerId)));
if (env.providerArgs) {
args.push(...env.providerArgs);
}

View file

@ -1130,6 +1130,7 @@ export interface TeamChangeEvent {
| 'lead-context'
| 'lead-message'
| 'tool-activity'
| 'member-turn-settled'
| 'process'
| 'member-spawn';
teamName: string;

View file

@ -0,0 +1,89 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { FileRuntimeTurnSettledEventStore } from '@features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore';
import { RuntimeTurnSettledSpoolPaths } from '@features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths';
const roots: string[] = [];
async function makePaths(): Promise<RuntimeTurnSettledSpoolPaths> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'runtime-turn-settled-'));
roots.push(root);
return new RuntimeTurnSettledSpoolPaths(root);
}
afterEach(async () => {
await Promise.allSettled(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
describe('FileRuntimeTurnSettledEventStore', () => {
it('claims incoming payloads by atomically moving them to processing', async () => {
const paths = await makePaths();
await fs.mkdir(paths.getIncomingDir(), { recursive: true });
await fs.writeFile(
path.join(paths.getIncomingDir(), '20260429-1.claude.json'),
'{"hook_event_name":"Stop"}',
'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.claude.json',
provider: 'claude',
raw: '{"hook_event_name":"Stop"}',
});
await expect(
fs.stat(path.join(paths.getProcessingDir(), '20260429-1.claude.json'))
).resolves.toMatchObject({ isFile: expect.any(Function) });
await expect(
fs.stat(path.join(paths.getIncomingDir(), '20260429-1.claude.json'))
).rejects.toMatchObject({ code: 'ENOENT' });
});
it('moves processed payload and writes diagnostic metadata', async () => {
const paths = await makePaths();
await fs.mkdir(paths.getIncomingDir(), { recursive: true });
await fs.writeFile(
path.join(paths.getIncomingDir(), '20260429-1.claude.json'),
'{"hook_event_name":"Stop"}',
'utf8'
);
const store = new FileRuntimeTurnSettledEventStore({
paths,
now: () => new Date('2026-04-29T12:00:00.000Z'),
});
const [claimed] = await store.claimPending(1);
await store.markProcessed(claimed, {
outcome: 'enqueued',
teamName: 'team-a',
memberName: 'alice',
event: {
schemaVersion: 1,
provider: 'claude',
hookEventName: 'Stop',
sourceId: 'source-1',
payloadHash: 'hash',
recordedAt: '2026-04-29T12:00:00.000Z',
},
processedAt: '2026-04-29T12:01:00.000Z',
});
const processedPath = path.join(paths.getProcessedDir(), '20260429-1.claude.json');
await expect(fs.stat(processedPath)).resolves.toMatchObject({ isFile: expect.any(Function) });
const meta = JSON.parse(await fs.readFile(`${processedPath}.meta.json`, 'utf8')) as {
outcome?: string;
teamName?: string;
};
expect(meta).toMatchObject({ outcome: 'enqueued', teamName: 'team-a' });
});
});

View file

@ -67,4 +67,32 @@ describe('MemberWorkSyncTeamChangeRouter', () => {
expect(queue.dropTeam).toHaveBeenCalledWith('team-a');
expect(queue.enqueue).not.toHaveBeenCalled();
});
it('routes member-turn-settled events to one member reconcile', () => {
const { queue, router } = createRouter();
router.noteTeamChange({
type: 'member-turn-settled',
teamName: 'team-a',
detail: JSON.stringify({ memberName: 'alice', sourceId: 'source-1', provider: 'claude' }),
});
expect(queue.enqueue).toHaveBeenCalledWith({
teamName: 'team-a',
memberName: 'alice',
triggerReason: 'turn_settled',
});
});
it('ignores malformed member-turn-settled details', () => {
const { queue, router } = createRouter();
router.noteTeamChange({
type: 'member-turn-settled',
teamName: 'team-a',
detail: 'not-json',
});
expect(queue.enqueue).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,86 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { RuntimeTurnSettledSpoolPaths } from '@features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolPaths';
import { ShellRuntimeTurnSettledHookScriptInstaller } from '@features/member-work-sync/main/infrastructure/ShellRuntimeTurnSettledHookScriptInstaller';
import {
MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER,
buildRuntimeTurnSettledHookCommand,
buildRuntimeTurnSettledHookSettings,
} from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledHookSettings';
const tempRoots: string[] = [];
function makeTempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-turn-settled-hook-'));
tempRoots.push(root);
return root;
}
afterEach(() => {
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe('runtime turn-settled hook settings', () => {
it('builds a shell-quoted command without embedding team identity', () => {
const command = buildRuntimeTurnSettledHookCommand({
scriptPath: "/tmp/agent team's hook.sh",
spoolRoot: '/tmp/member work sync',
provider: 'claude',
});
expect(command).toContain('/bin/sh');
expect(command).toContain("'/tmp/agent team'\\''s hook.sh'");
expect(command).toContain("'claude'");
expect(command).toContain(MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER);
expect(command).not.toContain('--team-name');
expect(command).not.toContain('--agent-name');
});
it('builds Claude Stop hook settings with a stable marker', () => {
const settings = buildRuntimeTurnSettledHookSettings({
scriptPath: '/tmp/hook.sh',
spoolRoot: '/tmp/spool',
provider: 'claude',
});
expect(settings).toEqual({
hooks: {
Stop: [
{
matcher: '',
hooks: [
{
type: 'command',
command: expect.stringContaining(MEMBER_WORK_SYNC_TURN_SETTLED_HOOK_MARKER),
},
],
},
],
},
});
});
it('installs an executable POSIX shell writer and spool directories', async () => {
const root = makeTempRoot();
const paths = new RuntimeTurnSettledSpoolPaths(root);
const installer = new ShellRuntimeTurnSettledHookScriptInstaller(paths);
const result = await installer.install();
expect(result.scriptPath).toBe(paths.getHookScriptPath());
expect(result.spoolRoot).toBe(paths.getRootDir());
expect(fs.existsSync(paths.getIncomingDir())).toBe(true);
expect(fs.existsSync(paths.getProcessingDir())).toBe(true);
expect(fs.existsSync(paths.getProcessedDir())).toBe(true);
expect(fs.existsSync(paths.getInvalidDir())).toBe(true);
const stat = fs.statSync(result.scriptPath);
expect(stat.mode & 0o111).toBeGreaterThan(0);
expect(fs.readFileSync(result.scriptPath, 'utf8')).toContain('dd bs="$max_bytes"');
});
});

View file

@ -0,0 +1,143 @@
import { describe, expect, it, vi } from 'vitest';
import { RuntimeTurnSettledIngestor } from '@features/member-work-sync/core/application';
import { ClaudeStopHookPayloadNormalizer } from '@features/member-work-sync/main/infrastructure/ClaudeStopHookPayloadNormalizer';
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
import type {
RuntimeTurnSettledClaimedPayload,
RuntimeTurnSettledEventStorePort,
RuntimeTurnSettledInvalidResult,
RuntimeTurnSettledProcessedResult,
RuntimeTurnSettledTargetResolverPort,
} from '@features/member-work-sync/core/application';
function makePayload(raw: string): RuntimeTurnSettledClaimedPayload {
return {
id: 'event-1.claude.json',
fileName: 'event-1.claude.json',
filePath: '/tmp/event-1.claude.json',
provider: 'claude',
raw,
claimedAt: '2026-04-29T12:00:00.000Z',
};
}
describe('RuntimeTurnSettledIngestor', () => {
it('normalizes Claude Stop payloads and enqueues resolved member reconcile', async () => {
const payload = makePayload(
JSON.stringify({
hook_event_name: 'Stop',
session_id: 'ses-1',
transcript_path: '/tmp/ses-1.jsonl',
})
);
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: 'alice' })),
};
const enqueueRuntimeTurnSettled = vi.fn();
const ingestor = new RuntimeTurnSettledIngestor({
eventStore: store,
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
targetResolver: resolver,
reconcileQueue: { enqueueRuntimeTurnSettled },
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
});
await expect(ingestor.drainPending()).resolves.toEqual({
claimed: 1,
enqueued: 1,
unresolved: 0,
invalid: 0,
failed: 0,
});
expect(enqueueRuntimeTurnSettled).toHaveBeenCalledWith({
teamName: 'team-a',
memberName: 'alice',
event: expect.objectContaining({
provider: 'claude',
hookEventName: 'Stop',
sessionId: 'ses-1',
transcriptPath: '/tmp/ses-1.jsonl',
}),
});
expect(processed[0]).toMatchObject({
outcome: 'enqueued',
teamName: 'team-a',
memberName: 'alice',
});
});
it('quarantines malformed payloads without enqueueing reconcile', async () => {
const payload = makePayload('not-json');
const invalid: RuntimeTurnSettledInvalidResult[] = [];
const store: RuntimeTurnSettledEventStorePort = {
claimPending: vi.fn(async () => [payload]),
markProcessed: vi.fn(),
markInvalid: vi.fn(async (_payload, result) => {
invalid.push(result);
}),
};
const ingestor = new RuntimeTurnSettledIngestor({
eventStore: store,
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
targetResolver: { resolve: vi.fn() },
reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() },
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
});
await expect(ingestor.drainPending()).resolves.toMatchObject({
claimed: 1,
invalid: 1,
enqueued: 0,
});
expect(invalid[0]).toMatchObject({ reason: 'invalid_json' });
});
it('records unresolved Stop events as processed diagnostics', async () => {
const payload = makePayload(
JSON.stringify({ hook_event_name: 'Stop', transcript_path: '/tmp/unknown.jsonl' })
);
const processed: RuntimeTurnSettledProcessedResult[] = [];
const store: RuntimeTurnSettledEventStorePort = {
claimPending: vi.fn(async () => [payload]),
markProcessed: vi.fn(async (_payload, result) => {
processed.push(result);
}),
markInvalid: vi.fn(),
};
const ingestor = new RuntimeTurnSettledIngestor({
eventStore: store,
normalizer: new ClaudeStopHookPayloadNormalizer(new NodeHashAdapter()),
targetResolver: {
resolve: vi.fn(async () => ({
ok: false as const,
reason: 'no_matching_member_session',
})),
},
reconcileQueue: { enqueueRuntimeTurnSettled: vi.fn() },
clock: { now: () => new Date('2026-04-29T12:01:00.000Z') },
});
await expect(ingestor.drainPending()).resolves.toMatchObject({
claimed: 1,
unresolved: 1,
enqueued: 0,
});
expect(processed[0]).toMatchObject({
outcome: 'unresolved',
reason: 'no_matching_member_session',
});
});
});

View file

@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
import { TeamRuntimeTurnSettledTargetResolver } from '@features/member-work-sync/main/adapters/output/TeamRuntimeTurnSettledTargetResolver';
import type { TeamConfig } from '@shared/types';
describe('TeamRuntimeTurnSettledTargetResolver', () => {
it('resolves a Claude Stop transcript path to an active Anthropic teammate', async () => {
const resolver = new TeamRuntimeTurnSettledTargetResolver({
teamSource: {
listTeams: vi.fn(async () => [{ teamName: 'team-a', displayName: 'team-a' } as never]),
getConfig: vi.fn(async () => ({
name: 'team-a',
members: [{ name: 'Alice', providerId: 'anthropic' }],
}) satisfies TeamConfig),
},
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
memberLogsFinder: {
listAttributedMemberFiles: vi.fn(async () => [
{
memberName: 'Alice',
sessionId: 'ses-1',
filePath: '/tmp/ses-1.jsonl',
mtimeMs: 1,
},
]),
},
});
await expect(
resolver.resolve({
schemaVersion: 1,
provider: 'claude',
hookEventName: 'Stop',
sourceId: 'source-1',
payloadHash: 'hash',
recordedAt: '2026-04-29T12:00:00.000Z',
sessionId: 'ses-1',
transcriptPath: '/tmp/ses-1.jsonl',
})
).resolves.toEqual({ ok: true, teamName: 'team-a', memberName: 'alice' });
});
it('rejects matches for removed or non-Anthropic teammates', async () => {
const resolver = new TeamRuntimeTurnSettledTargetResolver({
teamSource: {
listTeams: vi.fn(async () => [{ teamName: 'team-a', displayName: 'team-a' } as never]),
getConfig: vi.fn(async () => ({
name: 'team-a',
members: [{ name: 'bob', providerId: 'opencode' }],
}) satisfies TeamConfig),
},
membersMetaStore: { getMembers: vi.fn(async () => []) } as never,
memberLogsFinder: {
listAttributedMemberFiles: vi.fn(async () => [
{
memberName: 'bob',
sessionId: 'ses-1',
filePath: '/tmp/ses-1.jsonl',
mtimeMs: 1,
},
]),
},
});
await expect(
resolver.resolve({
schemaVersion: 1,
provider: 'claude',
hookEventName: 'Stop',
sourceId: 'source-1',
payloadHash: 'hash',
recordedAt: '2026-04-29T12:00:00.000Z',
sessionId: 'ses-1',
})
).resolves.toEqual({ ok: false, reason: 'provider_mismatch' });
});
});

View file

@ -0,0 +1,117 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import {
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
createMemberWorkSyncFeature,
resolveMemberWorkSyncNudgeSideEffectsEnabled,
} from '@features/member-work-sync/main';
const tempRoots: string[] = [];
function makeTempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'member-work-sync-feature-'));
tempRoots.push(root);
return root;
}
afterEach(() => {
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe('createMemberWorkSyncFeature composition', () => {
it('keeps nudge side effects opt-in even when shadow readiness becomes green', () => {
expect(resolveMemberWorkSyncNudgeSideEffectsEnabled({})).toBe(false);
expect(
resolveMemberWorkSyncNudgeSideEffectsEnabled({
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: 'maybe',
})
).toBe(false);
});
it.each(['1', 'true', 'yes', 'on'])(
'enables nudge side effects only for explicit truthy env value %s',
(value) => {
expect(
resolveMemberWorkSyncNudgeSideEffectsEnabled({
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value,
})
).toBe(true);
}
);
it.each(['0', 'false', 'no', 'off', ''])(
'keeps nudge side effects disabled for explicit falsy env value %s',
(value) => {
expect(
resolveMemberWorkSyncNudgeSideEffectsEnabled({
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value,
})
).toBe(false);
}
);
it('returns an empty dispatch summary when nudge side effects are disabled', async () => {
const feature = createMemberWorkSyncFeature({
teamsBasePath: makeTempRoot(),
configReader: {} as never,
taskReader: {} as never,
kanbanManager: {} as never,
membersMetaStore: {} as never,
nudgeSideEffectsEnabled: false,
});
try {
await expect(feature.dispatchDueNudges(['team-a'])).resolves.toEqual({
claimed: 0,
delivered: 0,
superseded: 0,
retryable: 0,
terminal: 0,
});
} finally {
await feature.dispose();
}
});
it('builds Claude Stop hook settings 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 settings = await feature.buildRuntimeTurnSettledHookSettings({ provider: 'claude' });
expect(settings).toMatchObject({
hooks: {
Stop: [
{
hooks: [
{
type: 'command',
command: expect.stringContaining('agent-teams:member-work-sync-turn-settled:v1'),
},
],
},
],
},
});
await expect(
fs.promises.stat(
path.join(root, '.member-work-sync/runtime-hooks/bin/turn-settled-hook-v1.sh')
)
).resolves.toMatchObject({ mode: expect.any(Number) });
} finally {
await feature.dispose();
}
});
});