merge(dev): sync dev into main

This commit is contained in:
777genius 2026-05-02 00:31:13 +03:00
commit de232ab994
234 changed files with 32449 additions and 826 deletions

View file

@ -6,6 +6,7 @@ Start here:
- Repo overview and commands: [README.md](README.md)
- Working instructions and project conventions: [CLAUDE.md](CLAUDE.md)
- Canonical feature architecture standard: [docs/FEATURE_ARCHITECTURE_STANDARD.md](docs/FEATURE_ARCHITECTURE_STANDARD.md)
- Agent team launch/runtime debugging runbook: [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md)
For new features:
- Default home for medium and large features: `src/features/<feature-name>/`
@ -15,6 +16,7 @@ For new features:
## Review guidelines
- Treat regressions in agent team messaging, task lifecycle, session parsing, code review UI, and provider/runtime detection as high priority.
- For team launch hangs, OpenCode `registered`/`bootstrap unconfirmed`, missing teammate replies, or suspicious task logs, follow [docs/team-management/debugging-agent-teams.md](docs/team-management/debugging-agent-teams.md) before changing code.
- Verify new medium and large features follow `docs/FEATURE_ARCHITECTURE_STANDARD.md`, especially cross-process boundaries and public feature entrypoints.
- Check that Electron main, preload, renderer, and shared code keep their responsibilities separate and use the documented path aliases.
- Flag changes that manually concatenate agent block markers instead of using `wrapAgentBlock(text)`.

View file

@ -110,6 +110,12 @@ Keep orphaned Task calls (no matching subagent) for visibility.
Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a team.
Official docs: https://code.claude.com/docs/en/agent-teams
#### Debugging Team Launches And Teammates
- Use [`docs/team-management/debugging-agent-teams.md`](docs/team-management/debugging-agent-teams.md) when a team launch hangs, a teammate remains `registered`, OpenCode shows `bootstrap unconfirmed`, messages are missing, or Task Log Stream looks wrong.
- Always correlate UI diagnostics with persisted files under `~/.claude/teams/<teamName>/`, live process state, and runtime-specific evidence before changing code.
- For OpenCode secondary lanes, do not confuse primary filesystem readiness with lane bootstrap readiness. A missing OpenCode inbox during primary launch is not automatically a bug.
- Do not treat `member_briefing` as runtime evidence. OpenCode deliverability requires lane-scoped committed runtime evidence such as `opencode-sessions.json` plus its manifest entry.
#### Message Delivery Architecture
- **Lead** reads ONLY stdin (stream-json). Messages to lead must go through `relayLeadInboxMessages()` which converts inbox entries to stdin.
- **Teammates** are independent CLI processes. Claude Code runtime monitors each teammate's inbox file and delivers messages between turns. No relay through lead needed.

View file

@ -7,6 +7,7 @@ const processes = require('./internal/processes.js');
const maintenance = require('./internal/maintenance.js');
const crossTeam = require('./internal/crossTeam.js');
const runtime = require('./internal/runtime.js');
const workSync = require('./internal/workSync.js');
const agentBlocks = require('./internal/agentBlocks.js');
function bindModule(context, moduleApi) {
@ -31,6 +32,7 @@ function createController(options) {
maintenance: bindModule(context, maintenance),
crossTeam: bindModule(context, crossTeam),
runtime: bindModule(context, runtime),
workSync: bindModule(context, workSync),
};
}
@ -51,4 +53,5 @@ module.exports = {
maintenance,
crossTeam,
runtime,
workSync,
};

View file

@ -188,6 +188,63 @@ function appendRow(filePath, row) {
return row;
}
const RUNTIME_DELIVERY_DUPLICATE_NOTICE =
'Duplicate runtime_delivery ignored. The visible reply is already recorded for this relayOfMessageId; do not call agent-teams_message_send again with the same text unless you have new information.';
function normalizeComparableText(value) {
return String(value || '')
.trim()
.replace(/\r\n/g, '\n')
.replace(/[ \t]+/g, ' ');
}
function normalizeComparableParticipant(value) {
return String(value || '').trim().toLowerCase();
}
function getRuntimeDeliveryDuplicate(list, row) {
if (
row.source !== 'runtime_delivery' ||
typeof row.relayOfMessageId !== 'string' ||
row.relayOfMessageId.trim().length === 0
) {
return null;
}
const relayOfMessageId = row.relayOfMessageId.trim();
const from = normalizeComparableParticipant(row.from);
const to = normalizeComparableParticipant(row.to);
const text = normalizeComparableText(row.text);
if (!from || !to || !text) {
return null;
}
return (
list.find(
(candidate) =>
candidate &&
candidate.source === 'runtime_delivery' &&
String(candidate.relayOfMessageId || '').trim() === relayOfMessageId &&
normalizeComparableParticipant(candidate.from) === from &&
normalizeComparableParticipant(candidate.to) === to &&
normalizeComparableText(candidate.text) === text
) || null
);
}
function appendInboxRow(filePath, row) {
const current = readJson(filePath, []);
const list = Array.isArray(current) ? current : [];
const duplicate = getRuntimeDeliveryDuplicate(list, row);
if (duplicate) {
return { row: duplicate, deduplicated: true };
}
list.push(row);
writeJson(filePath, list);
return { row, deduplicated: false };
}
function sendInboxMessage(paths, flags) {
const memberName =
typeof flags.member === 'string' && flags.member.trim()
@ -204,11 +261,18 @@ function sendInboxMessage(paths, flags) {
to: memberName,
read: false,
});
appendRow(getInboxPath(paths, memberName), payload);
const appended = appendInboxRow(getInboxPath(paths, memberName), payload);
return {
deliveredToInbox: true,
messageId: payload.messageId,
message: payload,
messageId: appended.row.messageId,
message: appended.row,
...(appended.deduplicated
? {
deduplicated: true,
duplicateOfMessageId: appended.row.messageId,
deduplicationNotice: RUNTIME_DELIVERY_DUPLICATE_NOTICE,
}
: {}),
};
}

View file

@ -8,6 +8,9 @@ const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const POLL_INTERVAL_MS = 1000;
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
const RETRYABLE_CONTROL_ERROR = 'retryableControlError';
const BOOTSTRAP_CHECKIN_MAX_ATTEMPTS = 3;
const BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS = 4000;
const BOOTSTRAP_CHECKIN_RETRY_DELAYS_MS = [300, 900];
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@ -67,9 +70,15 @@ function resolveControlBaseUrls(context, flags = {}) {
return candidates;
}
function makeRetryableControlError(message, cause) {
function makeRetryableControlError(message, cause, metadata = {}) {
const error = new Error(message);
error[RETRYABLE_CONTROL_ERROR] = true;
if (metadata.kind) {
error.retryableKind = metadata.kind;
}
if (metadata.statusCode) {
error.statusCode = metadata.statusCode;
}
if (cause) {
error.cause = cause;
}
@ -114,7 +123,9 @@ async function requestJson(baseUrl, pathname, options = {}) {
: `${response.status} ${response.statusText}`.trim();
if (isRetryableStatusCode(response.status)) {
throw makeRetryableControlError(
`Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}`
`Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}`,
undefined,
{ kind: 'status', statusCode: response.status }
);
}
throw new Error(detail || 'Team control API request failed');
@ -122,19 +133,24 @@ async function requestJson(baseUrl, pathname, options = {}) {
if (payload == null) {
throw makeRetryableControlError(
`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`
`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`,
undefined,
{ kind: 'empty' }
);
}
return payload;
} catch (error) {
if (error && error.name === 'AbortError') {
throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error);
throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error, {
kind: 'timeout',
});
}
if (error && error.name === 'TypeError') {
throw makeRetryableControlError(
`Failed to reach team control API at ${baseUrl}: ${error.message || 'fetch failed'}`,
error
error,
{ kind: 'network' }
);
}
throw error;
@ -161,6 +177,54 @@ async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
throw lastError || new Error('Team control API request failed');
}
function isBootstrapCheckinRetryableControlError(error) {
if (!isRetryableControlError(error)) {
return false;
}
if (error.retryableKind === 'timeout' || error.retryableKind === 'network') {
return true;
}
if (error.retryableKind === 'status') {
return typeof error.statusCode === 'number' && error.statusCode >= 500;
}
return false;
}
async function requestJsonWithBoundedRetry(baseUrls, pathname, options = {}, retryOptions = {}) {
const maxAttempts = Math.max(1, retryOptions.maxAttempts || 1);
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const result = await requestJsonWithFallback(baseUrls, pathname, options);
if (attempt > 1 && result && typeof result === 'object' && !Array.isArray(result)) {
return {
...result,
diagnostics: uniqueNonEmpty([
...(Array.isArray(result.diagnostics) ? result.diagnostics : []),
'opencode_bootstrap_checkin_retry',
]),
};
}
return result;
} catch (error) {
lastError = error;
if (attempt >= maxAttempts || !isBootstrapCheckinRetryableControlError(error)) {
throw error;
}
const delayMs = retryOptions.delaysMs?.[attempt - 1] || 0;
if (delayMs > 0) {
await sleep(delayMs);
}
}
}
throw lastError || new Error('Team control API request failed');
}
function buildLaunchRequest(flags = {}) {
const cwd = typeof flags.cwd === 'string' ? flags.cwd.trim() : '';
if (!cwd) {
@ -412,18 +476,31 @@ async function getRuntimeState(context, flags = {}) {
}
async function runtimeBootstrapCheckin(context, flags = {}) {
return postRuntimeTool(
context,
flags,
'bootstrap-checkin',
compactRuntimeToolBody(context, flags, [
'runId',
'memberName',
'runtimeSessionId',
'observedAt',
'diagnostics',
'metadata',
])
const baseUrls = resolveControlBaseUrls(context, flags);
const explicitTimeoutMs = flags.waitTimeoutMs || flags['wait-timeout-ms'];
const timeoutMs = Math.min(
normalizeTimeoutMs(explicitTimeoutMs || BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS),
BOOTSTRAP_CHECKIN_ATTEMPT_TIMEOUT_MS
);
return requestJsonWithBoundedRetry(
baseUrls,
`/api/teams/${encodeURIComponent(context.teamName)}/opencode/runtime/bootstrap-checkin`,
{
method: 'POST',
body: compactRuntimeToolBody(context, flags, [
'runId',
'memberName',
'runtimeSessionId',
'observedAt',
'diagnostics',
'metadata',
]),
timeoutMs,
},
{
maxAttempts: BOOTSTRAP_CHECKIN_MAX_ATTEMPTS,
delaysMs: BOOTSTRAP_CHECKIN_RETRY_DELAYS_MS,
}
);
}

View file

@ -781,6 +781,14 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
- If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Then run task_start only when you truly begin.
- If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass.
15. MEMBER WORK SYNC REPORTING:
- member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda. They do NOT start, complete, approve, or comment on tasks.
- Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment.
- When you are about to stop, wait, or go idle because you believe your current work queue is handled, first call member_work_sync_status for yourself.
- If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working", that exact agendaFingerprint, and the returned reportToken.
- If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task, and include the returned reportToken.
- If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint and the returned reportToken.
- Do not report more than once for the same agendaFingerprint unless your state changed.
Failure to follow this protocol means the task board will show incorrect status.`);
}

View file

@ -0,0 +1,273 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const runtimeHelpers = require('./runtimeHelpers.js');
const { withFileLockSync } = require('./fileLock.js');
const DEFAULT_WAIT_TIMEOUT_MS = 10000;
const MIN_WAIT_TIMEOUT_MS = 1000;
const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
function normalizeTimeoutMs(rawValue) {
const numeric =
typeof rawValue === 'number' && Number.isFinite(rawValue)
? Math.floor(rawValue)
: DEFAULT_WAIT_TIMEOUT_MS;
return Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, numeric));
}
function readControlApiState(context) {
const filePath = path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
return typeof parsed?.baseUrl === 'string' && parsed.baseUrl.trim()
? parsed.baseUrl.trim()
: '';
} catch {
return '';
}
}
function resolveControlBaseUrls(context, flags = {}) {
const explicit =
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
(typeof flags['control-url'] === 'string' && flags['control-url'].trim()) ||
'';
const stateFileUrl = readControlApiState(context);
const envUrl =
typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string'
? process.env.CLAUDE_TEAM_CONTROL_URL.trim()
: '';
const candidates = [...new Set([explicit, stateFileUrl, envUrl].filter(Boolean))];
if (candidates.length === 0) {
throw new Error(
'Team control API is unavailable. Start the desktop app team runtime first so it can validate member work sync reports.'
);
}
return candidates;
}
async function requestJson(baseUrl, pathname, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), normalizeTimeoutMs(options.timeoutMs));
try {
const response = await fetch(`${baseUrl}${pathname}`, {
method: options.method || 'GET',
headers: {
accept: 'application/json',
...(options.body ? { 'content-type': 'application/json' } : {}),
},
...(options.body ? { body: JSON.stringify(options.body) } : {}),
signal: controller.signal,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const detail =
payload && typeof payload.error === 'string' && payload.error.trim()
? payload.error.trim()
: `${response.status} ${response.statusText}`.trim();
const error = new Error(detail || 'Team control API request failed');
error.controlApiStatus = response.status;
throw error;
}
return payload;
} finally {
clearTimeout(timer);
}
}
async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
let lastError = null;
for (const baseUrl of baseUrls) {
try {
return await requestJson(baseUrl, pathname, options);
} catch (error) {
if (error && error.controlApiStatus) {
throw error;
}
lastError = error;
}
}
throw lastError || new Error('Team control API request failed');
}
function compactReportBody(context, memberName, flags = {}) {
return {
teamName: context.teamName,
memberName,
state: flags.state,
agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'],
reportToken: flags.reportToken || flags['report-token'],
...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}),
...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}),
...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}),
...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim()
? { reportedAt: flags.reportedAt.trim() }
: {}),
...(typeof flags.leaseTtlMs === 'number' ? { leaseTtlMs: flags.leaseTtlMs } : {}),
};
}
function stableStringify(value) {
if (value == null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(',')}}`;
}
function buildPendingIntentId(body) {
const taskIds = Array.isArray(body.taskIds)
? Array.from(new Set(body.taskIds.map((taskId) => String(taskId)).filter(Boolean))).sort()
: [];
const payload = {
teamName: body.teamName,
memberName: String(body.memberName || '').trim().toLowerCase(),
state: body.state,
agendaFingerprint: body.agendaFingerprint,
reportToken: body.reportToken || '',
...(taskIds.length > 0 ? { taskIds } : {}),
...(body.note ? { note: body.note } : {}),
...(body.leaseTtlMs ? { leaseTtlMs: body.leaseTtlMs } : {}),
...(body.source ? { source: body.source } : {}),
};
return `member-work-sync-intent:${crypto
.createHash('sha256')
.update(stableStringify(payload))
.digest('hex')}`;
}
function readPendingReportFile(filePath) {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (
parsed &&
typeof parsed === 'object' &&
parsed.schemaVersion === 1 &&
parsed.intents &&
typeof parsed.intents === 'object' &&
!Array.isArray(parsed.intents)
) {
return parsed;
}
} catch (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
}
return { schemaVersion: 1, intents: {} };
}
function writePendingReportFile(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, filePath);
}
function appendPendingReportIntent(context, body, reason) {
const filePath = path.join(context.paths.teamDir, '.member-work-sync', 'pending-reports.json');
const { id } = withFileLockSync(filePath, () => {
const data = readPendingReportFile(filePath);
const request = {
...body,
source: 'mcp',
};
const intentId = buildPendingIntentId(request);
const current = data.intents[intentId];
if (!current || current.status === 'pending') {
data.intents[intentId] = {
id: intentId,
teamName: body.teamName,
memberName: body.memberName,
request,
reason,
status: 'pending',
recordedAt: current && current.recordedAt ? current.recordedAt : new Date().toISOString(),
};
writePendingReportFile(filePath, data);
}
return { id: intentId };
});
return {
accepted: false,
pendingValidation: true,
code: 'pending_validation',
message:
'Member work sync report was recorded for app validation. Continue concrete task work; do not treat this as a confirmed lease yet.',
intentId: id,
};
}
function assertReportBody(body) {
if (!body.state || !['still_working', 'blocked', 'caught_up'].includes(body.state)) {
throw new Error('state must be still_working, blocked, or caught_up');
}
if (!body.agendaFingerprint) {
throw new Error('agendaFingerprint is required');
}
if (!body.reportToken) {
throw new Error('reportToken is required');
}
}
async function memberWorkSyncStatus(context, flags = {}) {
const memberName = runtimeHelpers.assertExplicitTeamMemberName(
context.paths,
flags.memberName || flags.member || flags.from,
'member work sync status member'
);
const baseUrls = resolveControlBaseUrls(context, flags);
return requestJsonWithFallback(
baseUrls,
`/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent(
memberName
)}`,
{ timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) }
);
}
async function memberWorkSyncReport(context, flags = {}) {
const memberName = runtimeHelpers.assertExplicitTeamMemberName(
context.paths,
flags.memberName || flags.member || flags.from,
'member work sync report member'
);
const body = compactReportBody(context, memberName, flags);
assertReportBody(body);
const pathname = `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/report`;
const options = {
method: 'POST',
body,
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']),
};
let baseUrls;
try {
baseUrls = resolveControlBaseUrls(context, flags);
} catch {
return appendPendingReportIntent(context, body, 'control_api_unavailable');
}
try {
return await requestJsonWithFallback(baseUrls, pathname, options);
} catch (error) {
if (error && error.controlApiStatus) {
throw error;
}
return appendPendingReportIntent(context, body, 'control_api_unavailable');
}
}
module.exports = {
memberWorkSyncStatus,
memberWorkSyncReport,
};

View file

@ -63,6 +63,8 @@ const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [
'runtime_heartbeat',
];
const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES = ['member_work_sync_status', 'member_work_sync_report'];
const AGENT_TEAMS_MCP_TOOL_GROUPS = [
{
id: 'team',
@ -104,6 +106,11 @@ const AGENT_TEAMS_MCP_TOOL_GROUPS = [
teammateOperational: false,
toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES,
},
{
id: 'workSync',
teammateOperational: true,
toolNames: AGENT_TEAMS_WORK_SYNC_TOOL_NAMES,
},
{
id: 'crossTeam',
teammateOperational: true,
@ -141,6 +148,7 @@ module.exports = {
AGENT_TEAMS_PROCESS_TOOL_NAMES,
AGENT_TEAMS_KANBAN_TOOL_NAMES,
AGENT_TEAMS_RUNTIME_TOOL_NAMES,
AGENT_TEAMS_WORK_SYNC_TOOL_NAMES,
AGENT_TEAMS_MCP_TOOL_GROUPS,
AGENT_TEAMS_REGISTERED_TOOL_NAMES,
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,

View file

@ -148,6 +148,7 @@ describe('agent-teams-controller API', () => {
expect(briefing).toContain('Task briefing for bob:');
expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.');
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
expect(briefing).toContain('member_work_sync_status and member_work_sync_report');
expect(briefing).toContain(
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
);
@ -234,6 +235,44 @@ describe('agent-teams-controller API', () => {
expect(delivered.deliveredToInbox).toBe(true);
});
it('deduplicates repeated runtime_delivery replies to the same inbound message', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const first = controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Да, я здесь!',
source: 'runtime_delivery',
relayOfMessageId: 'msg-inbound-1',
});
const second = controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: ' Да, я здесь! ',
source: 'runtime_delivery',
relayOfMessageId: 'msg-inbound-1',
});
const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(second).toMatchObject({
deliveredToInbox: true,
deduplicated: true,
messageId: first.messageId,
duplicateOfMessageId: first.messageId,
deduplicationNotice: expect.stringContaining('do not call agent-teams_message_send again'),
});
});
it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
@ -2250,6 +2289,301 @@ describe('agent-teams-controller API', () => {
}
});
it('retries OpenCode bootstrap check-in on retryable control API failures', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (calls.length < 3) {
return { statusCode: 500, body: { error: 'temporary bootstrap failure' } };
}
return { body: { ok: true, state: 'accepted', diagnostics: [] } };
});
try {
const result = await controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
expect(result).toMatchObject({
ok: true,
state: 'accepted',
diagnostics: expect.arrayContaining(['opencode_bootstrap_checkin_retry']),
});
expect(calls).toHaveLength(3);
expect(calls.map((call) => call.body)).toEqual([
{
teamName: 'my-team',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
},
{
teamName: 'my-team',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
},
{
teamName: 'my-team',
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
},
]);
} finally {
await server.close();
}
});
it('accepts idempotent OpenCode bootstrap check-in after a timed-out committed request', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
let committed = false;
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (!committed) {
committed = true;
await new Promise((resolve) => setTimeout(resolve, 1200));
return { body: { ok: true, state: 'accepted', diagnostics: [] } };
}
return {
body: {
ok: true,
state: 'accepted',
diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'],
},
};
});
try {
const result = await controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
waitTimeoutMs: 1000,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
});
expect(result).toMatchObject({
ok: true,
state: 'accepted',
diagnostics: expect.arrayContaining([
'opencode_bootstrap_checkin_duplicate_accepted',
'opencode_bootstrap_checkin_retry',
]),
});
expect(calls).toHaveLength(2);
} finally {
await server.close();
}
});
it('does not retry OpenCode bootstrap check-in on validation failures', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
return { statusCode: 400, body: { error: 'invalid bootstrap payload' } };
});
try {
await expect(
controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
})
).rejects.toThrow('invalid bootstrap payload');
expect(calls).toHaveLength(1);
} finally {
await server.close();
}
});
it('fails OpenCode bootstrap check-in clearly after bounded timeout retries', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
await new Promise((resolve) => setTimeout(resolve, 1200));
return { body: { ok: true, state: 'accepted' } };
});
try {
await expect(
controller.runtime.runtimeBootstrapCheckin({
controlUrl: server.baseUrl,
waitTimeoutMs: 1000,
runId: 'run-oc',
memberName: 'bob',
runtimeSessionId: 'ses-1',
})
).rejects.toThrow('Timed out calling team control API');
expect(calls).toHaveLength(3);
} finally {
await server.close();
}
});
it('forwards member work sync status and reports to the app validator', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const calls = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'GET' && url === '/api/teams/my-team/member-work-sync/bob') {
return {
body: {
teamName: 'my-team',
memberName: 'bob',
state: 'needs_sync',
agenda: {
teamName: 'my-team',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:abc',
items: [],
diagnostics: [],
},
reportToken: 'wrs:v1.test.token',
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/report') {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const status = await controller.workSync.memberWorkSyncStatus({
controlUrl: server.baseUrl,
from: 'bob',
});
const report = await controller.workSync.memberWorkSyncReport({
controlUrl: server.baseUrl,
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: ['task-1'],
note: 'Continuing work',
leaseTtlMs: 120000,
});
expect(status.state).toBe('needs_sync');
expect(report.accepted).toBe(true);
expect(calls).toEqual([
{
method: 'GET',
url: '/api/teams/my-team/member-work-sync/bob',
body: undefined,
},
{
method: 'POST',
url: '/api/teams/my-team/member-work-sync/report',
body: {
teamName: 'my-team',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: ['task-1'],
note: 'Continuing work',
leaseTtlMs: 120000,
},
},
]);
} finally {
await server.close();
}
});
it('records member work sync report intents only when the app validator is unavailable', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const pending = await controller.workSync.memberWorkSyncReport({
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: ['task-1'],
});
expect(pending.pendingValidation).toBe(true);
expect(pending.accepted).toBe(false);
const intentFile = path.join(
claudeDir,
'teams',
'my-team',
'.member-work-sync',
'pending-reports.json'
);
const intents = JSON.parse(fs.readFileSync(intentFile, 'utf8'));
expect(Object.values(intents.intents)).toEqual([
expect.objectContaining({
teamName: 'my-team',
memberName: 'bob',
reason: 'control_api_unavailable',
status: 'pending',
request: expect.objectContaining({
memberName: 'bob',
source: 'mcp',
reportToken: 'wrs:v1.test.token',
}),
}),
]);
});
it('does not record pending work sync intents for app-side validation rejections', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const server = await startControlServer(async () => ({
statusCode: 400,
body: { error: 'stale_fingerprint' },
}));
try {
await expect(
controller.workSync.memberWorkSyncReport({
controlUrl: server.baseUrl,
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:stale',
reportToken: 'wrs:v1.test.token',
})
).rejects.toThrow('stale_fingerprint');
expect(
fs.existsSync(
path.join(claudeDir, 'teams', 'my-team', '.member-work-sync', 'pending-reports.json')
)
).toBe(false);
} finally {
await server.close();
}
});
it('prefers the published control endpoint over a stale env URL', async () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -0,0 +1,176 @@
# Debugging Agent Teams
Use this runbook when a team launch hangs, a teammate is marked `registered` or `failed_to_start`, messages do not appear, or OpenCode participants look online but do not answer.
## First Rule
Do not guess from the UI alone. Always correlate:
- UI diagnostics copied from the launch/member detail panel
- persisted team files under `~/.claude/teams/<teamName>/`
- live process table
- runtime-specific evidence, especially OpenCode lane manifests
## Key Files
Team root:
```bash
TEAM="<team-name>"
TEAM_DIR="$HOME/.claude/teams/$TEAM"
```
Important files and folders:
- `config.json` - configured members, provider/model selection, project path
- `members-meta.json` - member metadata, removed members, worktree settings if present
- `launch-state.json` - current app-side truth for member launch/liveness
- `bootstrap-state.json` - bootstrap phase summary when present
- `bootstrap-journal.jsonl` - ordered bootstrap events from the CLI/runtime
- `inboxes/*.json` - durable inbox messages for user, lead, and native teammates
- `sentMessages.json` - app-side sent-message records
- `tasks/*.json` - task board state
- `.opencode-runtime/lanes.json` - OpenCode lane index
- `.opencode-runtime/lanes/<encoded-lane-id>/manifest.json` - lane-scoped runtime store manifest
- `.opencode-runtime/lanes/<encoded-lane-id>/opencode-sessions.json` - committed OpenCode session evidence
Quick inspection:
```bash
jq '.teamLaunchState, .summary, .members' "$TEAM_DIR/launch-state.json"
jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null
find "$TEAM_DIR/.opencode-runtime" -maxdepth 3 -type f | sort
tail -80 "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
```
## Launch Phases
Primary launch and OpenCode secondary lanes are different paths.
- Primary CLI members are created by the main provisioning process.
- OpenCode secondary members are launched as side lanes after primary filesystem readiness.
- Missing `inboxes/<opencode-member>.json` is not automatically a launch bug. OpenCode side lanes do not have to be primary inbox-created before they start.
- The UI can show the team still launching while primary members are already usable, because "all teammates joined" waits for secondary lanes too.
When a launch hangs at `Prepared communication channels for X/Y members`, check whether `Y` incorrectly includes secondary OpenCode members. The filesystem monitor should wait for `effectiveMembers`, not every requested member.
## Member State Meanings
Common `launch-state.json` cases:
- `confirmed_alive` with `bootstrapConfirmed: true` - member is usable.
- `registered` / `runtime_pending_bootstrap` - process or lane exists, but bootstrap proof is not committed yet.
- `registered_only` - app has persisted metadata, but no live runtime proof.
- `runtime_process_candidate` - process/session was observed, but committed runtime evidence is incomplete or pending.
- `failed_to_start` with `runtime_process` - a process exists, but the launch gate still failed. Inspect diagnostics and runtime evidence.
- `failed_to_start` with `stale_metadata` - persisted pid/session is old or dead.
Do not treat `member_briefing` alone as runtime evidence. For OpenCode, the authoritative proof is committed bootstrap/session evidence in the lane runtime store.
## OpenCode Debug Flow
For an OpenCode teammate:
```bash
MEMBER="<member-name>"
jq --arg member "$MEMBER" '.members[$member]' "$TEAM_DIR/launch-state.json"
jq '.lanes' "$TEAM_DIR/.opencode-runtime/lanes.json" 2>/dev/null
find "$TEAM_DIR/.opencode-runtime/lanes" -maxdepth 3 -type f | sort
```
Expected healthy OpenCode lane:
- `lanes.json` has the lane state `active`
- lane `manifest.json` has `activeRunId`
- lane manifest has at least one runtime evidence entry, usually `opencode.sessionStore`
- lane directory has `opencode-sessions.json`
- `launch-state.json` member has `runtimeRunId`, `runtimeSessionId`, and `bootstrapConfirmed: true`
If the bridge says bootstrap succeeded but the manifest has `entries: []`, the issue is evidence commit, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
OpenCode bridge ledger, if needed:
```bash
LEDGER="$HOME/Library/Application Support/claude-agent-teams-ui/opencode-bridge/command-ledger.json"
jq --arg team "$TEAM" '.data[] | select(.teamName == $team)' "$LEDGER" 2>/dev/null
```
Live process checks:
```bash
pgrep -af "opencode serve"
ps -p <pid> -o pid,ppid,etime,command
```
Do not kill all OpenCode processes as a debugging shortcut. First identify whether the pid belongs to the current team/lane. Some OpenCode temp `libopentui.dylib` files are held by live `opencode serve` processes and should only be cleaned after those processes are stopped.
## Messaging Debug Flow
Lead and teammates use different delivery paths:
- Lead reads stdin. Messages to lead go through `relayLeadInboxMessages()`.
- Native teammates read their inbox files directly.
- OpenCode teammates receive prompts through runtime delivery and must reply via `agent-teams_message_send`.
- Teammate-to-user replies should appear in `inboxes/user.json` or app sent-message projections.
If a notification appears but the Messages UI does not show it:
```bash
jq '.' "$TEAM_DIR/inboxes/user.json" 2>/dev/null
jq '.' "$TEAM_DIR/sentMessages.json" 2>/dev/null
```
Check `from`, `to`, `messageId`, `relayOfMessageId`, and `taskRefs`. Unknown authors should be rejected or normalized at the write boundary, not silently rendered as fake teammates.
For OpenCode "message saved but not delivered" cases, inspect the OpenCode prompt-delivery ledger and response proof. Do not synthesize visible replies in the frontend.
## Task And Work-Stall Debug Flow
For task stalls:
```bash
TASK="<short-or-full-task-id>"
rg -n "$TASK" "$TEAM_DIR/tasks" "$TEAM_DIR/inboxes" "$TEAM_DIR/bootstrap-journal.jsonl" 2>/dev/null
```
Important distinctions:
- Delivery proof means the agent received the message.
- Task progress proof means the agent made meaningful task progress.
- A weak comment like "starting work" is not strong progress.
- `task_add_comment` should be evaluated from the actual persisted comment text, not only from the tool call.
Task-stall monitor defaults:
- General task-stall monitor is for all agents.
- OpenCode direct remediation is provider-specific and should nudge the OpenCode owner first.
- If OpenCode remediation is not accepted, fallback to lead alert.
- Watchdog/remediation must not auto-start new OpenCode processes.
## Task Log Stream Debug Flow
Task Log Stream is a projection, not a separate source of truth.
For OpenCode tasks, a healthy stream should show native tool rows such as `read`, `bash`, `edit`, `write`, plus Agent Teams MCP rows. If it only shows `agent-teams_*` calls:
- confirm the task has OpenCode attribution for the member/session
- confirm the OpenCode transcript contains native tools inside the bounded task window
- check whether the task was assigned after the native work happened
- do not widen attribution so far that unrelated session work is pulled into the task
If Changes says "No file changes recorded" while native `write`/`edit` rows exist, inspect the ledger/backfill path. Task logs can show runtime tools even when `.board-task-changes/**` was not created.
## Safe Fix Checklist
Before changing launch or runtime logic:
- Preserve stale-run, tombstone, stopped-team, and removed-member guards.
- Do not make `member_briefing` runtime evidence.
- Do not make delivery/watchdog auto-launch a fresh OpenCode lane.
- Keep primary launch readiness separate from secondary OpenCode lane readiness.
- Keep runtime evidence lane-scoped. Never let one OpenCode lane satisfy another lane.
- Add a regression test for the exact state shape you found in `launch-state.json`.
Recommended verification:
```bash
pnpm vitest run test/main/services/team/TeamProvisioningService.test.ts
pnpm vitest run test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts
pnpm typecheck --pretty false
git diff --check
```
Use narrower test commands first when editing a focused path, then run the broader suite that covers launch, delivery, and liveness.

View file

@ -1,6 +1,6 @@
# Member Work Sync Control Plane Plan
**Status:** Proposed
**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and opt-in Phase 2 nudge outbox/dispatcher/scheduler implemented
**Scope:** Team management, task work synchronization, agent work coordination
**Primary repo:** `claude_team`
**Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller`
@ -29,6 +29,21 @@ Phase 1 does not send nudges. It computes agenda/fingerprint/status, validates `
Phase 2 adds durable nudges only after Phase 1 metrics prove that fingerprint churn and false positives are low.
Current implementation note:
- Phase 1 is intentionally shadow-only: it computes agendas, fingerprints, report tokens, reports, persisted status, passive queue reconciliation, startup replay, diagnostics, metrics, and a neutral read-only member details surface.
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics.
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
- Phase 2 storage foundation is implemented as a durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states.
- Queue reconciles can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; read-only diagnostics never create outbox intents. This preserves the anti-spam gate and keeps UI/status reads passive.
- Phase 2 nudge side effects are additionally disabled by default in production composition. Set `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1` only for isolated live validation. This keeps status/report/metrics active while guaranteeing that shadow-ready metrics cannot start inbox nudges by accident.
- Dispatcher use case can run after queued reconcile and is also exposed through the facade when nudge side effects are explicitly enabled. It claims due outbox rows, revalidates active team/status/current fingerprint/readiness/busy/watchdog cooldown, then writes one idempotent inbox nudge through a narrow port.
- Production busy revalidation is wired through a tool-activity busy signal adapter. Active or recently finished tool calls defer Phase 2 nudges instead of interrupting work.
- A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams only when nudge side effects are enabled. It is bounded, unref'ed, and still relies on dispatcher revalidation before any inbox write.
- Dispatcher applies per-member hourly rate limiting and bounded deterministic retry backoff with jitter before retrying failed nudge attempts.
- Superseded-but-undelivered outbox items can be revived by a fresh queued reconcile for the same agenda fingerprint. Delivered nudges remain one-per-fingerprint.
- Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
Patterns used:
- Kubernetes-style level-triggered reconcile: recompute from current desired/current state instead of trusting events.
@ -1136,7 +1151,7 @@ Validation result contract:
```ts
export type MemberWorkSyncReportValidationReason =
| 'feature_disabled'
| 'capability_unavailable'
| 'team_inactive'
| 'member_inactive'
| 'reserved_author'
@ -1349,7 +1364,7 @@ Expired leases are ignored by `SyncDecisionPolicy`.
### 10.4 Shadow Would-Nudge Semantics
Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send.
Phase 1 may compute `wouldNudgeCount`, but must not enqueue or send. Production composition enforces this by default by not wiring `outboxStore`/`inboxNudge` unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=1`.
`wouldNudge` is true only when all are true:
@ -1866,26 +1881,26 @@ Rules:
- In Phase 1, missing `member_work_sync_report` must not block team launch.
- If the tool is missing, omit work-sync instructions from `task_briefing`/`member_briefing`.
- If the tool exists but app validation bridge is unavailable, return `pending_validation`.
- If app says feature disabled, return `feature_disabled`.
- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tool is optional unless `CLAUDE_TEAM_MEMBER_WORK_SYNC_REQUIRE_MCP_TOOL=true`.
- Do not add a runtime/env flag to require the tool in Phase 1.
- OpenCode readiness tests should prove old required tools still gate launch, while work-sync tools remain optional compatibility capabilities.
Suggested rollout gate:
Important distinction:
```text
CLAUDE_TEAM_MEMBER_WORK_SYNC_REQUIRE_MCP_TOOL=false
capability-gated means "use it if both sides expose it".
feature-flagged means "runtime branch changes behavior based on env/config".
```
Default `false` until Phase 1 has shipped across both repos.
Phase 1 uses capability gating only. That avoids permanent `new vs legacy` branches while still supporting mixed repo versions during development.
Compatibility matrix:
| claude_team | orchestrator/controller | Expected behavior |
|---|---|---|
| no feature | no tool | no work-sync surface |
| feature enabled | no tool | status/reconcile only, no report instruction |
| feature enabled | tool exists, no app bridge | pending intent only |
| feature enabled | tool exists, app bridge live | full report validation |
| feature disabled | tool exists | tool returns `feature_disabled`, no writes |
| app has feature | no tool | status/reconcile only, no report instruction |
| app has feature | tool exists, no app bridge | pending intent only |
| app has feature | tool exists, app bridge live | full report validation |
### 13.1 Current Agenda Read Surface
@ -2869,8 +2884,7 @@ Details dialog can show:
- missing `member_work_sync_report` does not fail OpenCode readiness in Phase 1;
- work-sync instructions are omitted when the tool is unavailable;
- tool available + app bridge unavailable returns `pending_validation`;
- feature disabled returns `feature_disabled` and writes no intents;
- optional require-tool gate can fail readiness when explicitly enabled.
- no runtime flag is needed to require the tool in Phase 1.
### 20.4 Controller Tests
@ -2888,7 +2902,7 @@ In `agent-teams-controller`:
- returns structured stale fingerprint response;
- returns `pendingValidation` instead of accepted lease when app validator is unavailable;
- pending validation intent replay does not update lease until app accepts;
- disabled feature returns `feature_disabled` and does not write intents;
- capability unavailable returns `capability_unavailable` and does not write accepted reports;
- exposes current fingerprint through the chosen read surface;
- does not write task comments or messages.
@ -2976,6 +2990,7 @@ Check:
- stale report rate;
- invalid caught-up attempts;
- how many nudges Phase 2 would send.
- `phase2Readiness.state` remains `collecting_shadow_data` until the sample is large enough, `blocked` if rates are noisy, and only then `shadow_ready`.
Exit criteria:
@ -2999,6 +3014,15 @@ Includes:
- per-member token bucket;
- shared cooldown with watchdog.
Implemented safety constraints:
- only queued reconciles plan outbox rows;
- read-only diagnostics never plan outbox rows;
- outbox planning requires `phase2Readiness.state === "shadow_ready"`;
- dispatch revalidates lifecycle, current status, current fingerprint, readiness, busy state, rate limit, and watchdog cooldown immediately before inbox insert;
- scheduled dispatch lists lifecycle-active teams only, not all stored teams;
- undelivered `superseded` rows can be revived by a later fresh reconcile for the same fingerprint, while `delivered` rows remain one-per-fingerprint.
### Phase 3: Provider Accelerators
`🎯 8 🛡️ 8 🧠 5`, `300-600 LOC`.
@ -3012,57 +3036,59 @@ Includes:
No accelerator is proof.
Current implementation:
- tool-finish enqueue and tool-activity busy suppression are implemented through `TeamChangeEvent` and the feature-owned busy signal;
- Claude Stop hook and OpenCode turn-settled hooks are intentionally not wired yet because the current feature boundary does not expose one authoritative cross-provider "turn settled and idle" signal. Adding an adapter around prompt text, idle notifications, or provider-specific transcript heuristics would be less reliable than the current tool-finish + scheduled reconcile path;
- manual "sync now" remains optional because details/status reads are passive by design, and explicit manual nudges should reuse the existing outbox/dispatcher instead of bypassing readiness guards.
---
## 22. Feature Gates
## 22. Runtime Defaults And No Feature Flags
Phase 1:
Phase 1 shipped without feature flags.
```text
CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED=true
CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=true
Reason:
- Phase 1 has no nudges, no inbox writes, no task mutation, and no runtime restart behavior.
- Adding feature flags for passive status/report validation creates extra branches and makes failures harder to reason about.
- The safe boundary is architectural, not configurational: passive status/report validation stays independent from Phase 2 side effects.
Runtime defaults:
| Behavior | Default | Why |
|---|---:|---|
| agenda/fingerprint/status computation | on | passive, deterministic, app-owned |
| `member_work_sync_status` | on | read-only diagnostics |
| `member_work_sync_report` | on | server-validated, no board mutation |
| pending report intent fallback | on only when identity is not terminally invalid | compatibility with old app/runtime boundaries |
| outbox planning | on only for queued reconciles and only when `phase2Readiness=shadow_ready` | prevents status reads from causing side effects |
| scheduled nudge dispatch | on only for lifecycle-active teams | stopped teams must not claim or supersede pending nudges |
| inbox nudge writes | guarded by dispatcher revalidation | lifecycle, current fingerprint, readiness, busy signal, rate limit, and watchdog cooldown are checked immediately before write |
Do not add:
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED`
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY`
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED`
If Phase 1 needs to be disabled during development, revert or patch the narrow composition wiring. Do not add a permanent product branch for a passive feature.
Phase 2 policy:
- Phase 2 is implemented as a separate outbox/dispatcher/scheduler path, not as hidden branching inside passive diagnostics.
- Phase 2 does not bypass shadow readiness. If metrics are noisy, the planner returns `phase2_not_ready`.
- Phase 2 uses constants/configuration for rate limits and timing, but not a broad "new vs legacy" branch.
Phase 2 runtime constants can be normal typed defaults, not feature gates:
```ts
const MEMBER_WORK_SYNC_QUIET_WINDOW_MS = 90_000;
const MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS = 10 * 60_000;
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
```
Defaults:
- enabled can default `true` only if Phase 1 is read/status-only;
- shadow-only must default `true`;
- Phase 2 nudges default `false` until explicitly validated.
Gate behavior:
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED=false` disables queue, reconcile, status writes, and report acceptance. The MCP report tool should return `feature_disabled`.
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=true` allows reconcile/status/report validation but forbids outbox and inbox writes.
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY=false` is allowed only after Phase 2 implementation and metrics review.
- Report intent recording should also honor `ENABLED=false`; do not write intent files when the feature is explicitly disabled.
- Read surfaces can include `"feature": "disabled"` when disabled, but should not instruct agents to call the report tool.
Phase 2:
```text
CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED=false
CLAUDE_TEAM_MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR=2
CLAUDE_TEAM_MEMBER_WORK_SYNC_QUIET_WINDOW_MS=90000
CLAUDE_TEAM_MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS=600000
```
Recommended defaults by phase:
| Gate | Phase 1 default | Phase 2 default after metrics |
|---|---:|---:|
| `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED` | `true` | `true` |
| `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY` | `true` | `false` only after manual enable |
| `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED` | `false` | `false` until explicitly flipped |
| report tool enabled | `true` when feature enabled | `true` |
| report intent fallback | `true` when feature enabled | `true` |
Kill-switch expectations:
- turning `ENABLED=false` should stop queue processing within one event-loop tick;
- pending outbox items must not dispatch while disabled;
- report tool should return a structured disabled response;
- status read APIs may still return last known status marked stale/disabled;
- no feature flag should change task board state directly.
If we ever need an emergency kill switch for production nudges, it must only wrap the Phase 2 dispatcher. It must not disable agenda/status/report validation.
---
@ -3401,7 +3427,7 @@ Step order:
2. Extend current agenda read surface.
- Prefer `task_briefing.workSync`.
- Include compact agenda preview, `agendaFingerprint`, state, and `reportToken`.
- Omit report instructions when tool is unavailable or feature disabled.
- Omit report instructions when tool or app validation capability is unavailable.
- Keep old `task_briefing` fields unchanged.
3. Implement report validator.

View file

@ -0,0 +1,40 @@
# Member Work Sync Debugging
`member-work-sync` stores member-scoped control-plane state under each team member:
```text
~/.claude/teams/<team>/members/<member-key>/.member-work-sync/
status.json
reports.json
outbox.json
journal.jsonl
```
`member-key` is the normalized, percent-encoded member name. The canonical name is stored in:
```text
~/.claude/teams/<team>/members/<member-key>/member.meta.json
```
Use the journal for local debugging:
```bash
tail -f ~/.claude/teams/<team>/members/<member-key>/.member-work-sync/journal.jsonl
```
The journal is append-only JSONL and records sync decisions, not raw agent transcripts. Useful events:
- `reconcile_started`, `agenda_loaded`, `decision_made`, `status_written`
- `report_received`, `report_accepted`, `report_rejected`
- `nudge_planned`, `nudge_delivered`, `nudge_skipped`, `nudge_retryable`, `nudge_superseded`
- `member_busy`, `watchdog_cooldown_active`, `team_inactive`, `legacy_fallback_used`
Team-level shared/index state remains under:
```text
~/.claude/teams/<team>/.member-work-sync/
indexes/
report-token-secret.json
```
The indexes are implementation details used to avoid scanning every member directory on the hot path.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -102,6 +102,11 @@ declare module 'agent-teams-controller' {
runtimeHeartbeat(flags: Record<string, unknown>): Promise<unknown>;
}
export interface ControllerWorkSyncApi {
memberWorkSyncStatus(flags: Record<string, unknown>): Promise<unknown>;
memberWorkSyncReport(flags: Record<string, unknown>): Promise<unknown>;
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
@ -111,6 +116,7 @@ declare module 'agent-teams-controller' {
maintenance: ControllerMaintenanceApi;
crossTeam: ControllerCrossTeamApi;
runtime: ControllerRuntimeApi;
workSync: ControllerWorkSyncApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;
@ -143,6 +149,7 @@ declare module 'agent-teams-controller' {
| 'message'
| 'process'
| 'runtime'
| 'workSync'
| 'crossTeam';
export interface AgentTeamsMcpToolGroup {
@ -159,6 +166,7 @@ declare module 'agent-teams-controller' {
export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_WORK_SYNC_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[];
export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[];
export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[];

View file

@ -10,10 +10,17 @@ const { createController } = controllerModule;
const FORCED_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
type WorkSyncCapableController = ReturnType<typeof createController> & {
workSync: {
memberWorkSyncStatus(flags: Record<string, unknown>): Promise<unknown>;
memberWorkSyncReport(flags: Record<string, unknown>): Promise<unknown>;
};
};
/** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */
export const agentBlocks = controllerModule.agentBlocks;
export function getController(teamName: string, claudeDir?: string) {
export function getController(teamName: string, claudeDir?: string): WorkSyncCapableController {
const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim();
let resolvedClaudeDir = claudeDir;
if (forcedClaudeDir) {
@ -24,5 +31,5 @@ export function getController(teamName: string, claudeDir?: string) {
teamName,
...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}),
allowUserMessageSender: false,
});
}) as WorkSyncCapableController;
}

View file

@ -14,6 +14,7 @@ import { registerReviewTools } from './reviewTools';
import { registerRuntimeTools } from './runtimeTools';
import { registerTaskTools } from './taskTools';
import { registerTeamTools } from './teamTools';
import { registerWorkSyncTools } from './workSyncTools';
const REGISTRATION_BY_GROUP = {
team: registerTeamTools,
@ -24,6 +25,7 @@ const REGISTRATION_BY_GROUP = {
message: registerMessageTools,
process: registerProcessTools,
runtime: registerRuntimeTools,
workSync: registerWorkSyncTools,
crossTeam: registerCrossTeamTools,
} as const;

View file

@ -0,0 +1,86 @@
import type { FastMCP } from 'fastmcp';
import { z } from 'zod';
import { getController } from '../controller';
import { jsonTextContent } from '../utils/format';
import { assertConfiguredTeam } from '../utils/teamConfig';
const controlContextSchema = {
teamName: z.string().min(1),
claudeDir: z.string().min(1).optional(),
controlUrl: z.string().optional(),
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
};
const reportStateSchema = z.enum(['still_working', 'blocked', 'caught_up']);
export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({
name: 'member_work_sync_status',
description:
'Read your current actionable-work agenda and agendaFingerprint before reporting whether you are still working, blocked, or caught up.',
parameters: z.object({
...controlContextSchema,
memberName: z.string().min(1).optional(),
from: z.string().min(1).optional(),
}),
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, memberName, from }) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).workSync.memberWorkSyncStatus({
...(memberName ? { memberName } : {}),
...(from ? { from } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
);
},
});
server.addTool({
name: 'member_work_sync_report',
description:
'Report your validated work-sync state for the current agendaFingerprint. This never completes tasks. Use still_working while actively continuing, blocked only when the board has blocker evidence, and caught_up only when the status agenda is empty.',
parameters: z.object({
...controlContextSchema,
memberName: z.string().min(1).optional(),
from: z.string().min(1).optional(),
state: reportStateSchema,
agendaFingerprint: z.string().min(1),
reportToken: z.string().min(1),
taskIds: z.array(z.string().min(1)).optional(),
note: z.string().optional(),
leaseTtlMs: z.number().int().min(60000).max(3600000).optional(),
}),
execute: async ({
teamName,
claudeDir,
controlUrl,
waitTimeoutMs,
memberName,
from,
state,
agendaFingerprint,
reportToken,
taskIds,
note,
leaseTtlMs,
}) => {
assertConfiguredTeam(teamName, claudeDir);
return jsonTextContent(
await getController(teamName, claudeDir).workSync.memberWorkSyncReport({
...(memberName ? { memberName } : {}),
...(from ? { from } : {}),
state,
agendaFingerprint,
reportToken,
...(taskIds ? { taskIds } : {}),
...(note ? { note } : {}),
...(leaseTtlMs ? { leaseTtlMs } : {}),
...(controlUrl ? { controlUrl } : {}),
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
})
);
},
});
}

View file

@ -400,6 +400,97 @@ describe('agent-teams-mcp tools', () => {
}
});
it('forwards member work sync MCP tools through the app validator bridge', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
],
});
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body });
if (method === 'GET' && url === '/api/teams/alpha/member-work-sync/alice') {
return {
body: {
teamName: 'alpha',
memberName: 'alice',
state: 'needs_sync',
agenda: {
teamName: 'alpha',
memberName: 'alice',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:abc',
items: [],
diagnostics: [],
},
reportToken: 'wrs:v1.test.token',
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: ['no_current_report'],
},
};
}
if (method === 'POST' && url === '/api/teams/alpha/member-work-sync/report') {
return { body: { accepted: true, code: 'accepted', status: body } };
}
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
});
try {
const status = parseJsonToolResult(
await getTool('member_work_sync_status').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
from: 'alice',
})
);
expect(status.state).toBe('needs_sync');
const report = parseJsonToolResult(
await getTool('member_work_sync_report').execute({
claudeDir,
teamName: 'alpha',
controlUrl: server.baseUrl,
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: ['task-1'],
note: 'Still working',
leaseTtlMs: 120000,
})
);
expect(report.accepted).toBe(true);
expect(calls).toEqual([
{
method: 'GET',
url: '/api/teams/alpha/member-work-sync/alice',
body: undefined,
},
{
method: 'POST',
url: '/api/teams/alpha/member-work-sync/report',
body: {
teamName: 'alpha',
memberName: 'alice',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: ['task-1'],
note: 'Still working',
leaseTtlMs: 120000,
},
},
]);
} finally {
await server.close();
}
});
it('discovers the control endpoint from the published state file', async () => {
const claudeDir = makeClaudeDir();
writeTeamConfig(claudeDir, 'alpha', {

View file

@ -18,6 +18,7 @@
},
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.00000125,
"cache_creation_input_token_cost_above_1hr": 0.000002,
"cache_read_input_token_cost": 1e-7,
"input_cost_per_token": 0.000001,
"litellm_provider": "bedrock_converse",
@ -41,6 +42,7 @@
},
"anthropic.claude-haiku-4-5@20251001": {
"cache_creation_input_token_cost": 0.00000125,
"cache_creation_input_token_cost_above_1hr": 0.000002,
"cache_read_input_token_cost": 1e-7,
"input_cost_per_token": 0.000001,
"litellm_provider": "bedrock_converse",
@ -261,6 +263,7 @@
},
"anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -288,6 +291,7 @@
},
"anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -317,6 +321,7 @@
},
"global.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -346,6 +351,7 @@
},
"us.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
@ -433,6 +439,7 @@
},
"anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -477,6 +484,7 @@
},
"global.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -507,6 +515,7 @@
},
"us.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
@ -597,6 +606,7 @@
},
"anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375,
"cache_creation_input_token_cost_above_1hr": 0.000006,
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"litellm_provider": "bedrock_converse",
@ -625,6 +635,7 @@
},
"global.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375,
"cache_creation_input_token_cost_above_1hr": 0.000006,
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"litellm_provider": "bedrock_converse",
@ -653,6 +664,7 @@
},
"us.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125,
"cache_creation_input_token_cost_above_1hr": 0.0000066,
"cache_read_input_token_cost": 3.3e-7,
"input_cost_per_token": 0.0000033,
"litellm_provider": "bedrock_converse",
@ -767,11 +779,13 @@
},
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.00000375,
"cache_creation_input_token_cost_above_1hr": 0.000006,
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"input_cost_per_token_above_200k_tokens": 0.000006,
"output_cost_per_token_above_200k_tokens": 0.0000225,
"cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012,
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
@ -1963,12 +1977,12 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
"fast": 6
},
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-opus-4-7-20260416": {
@ -1997,12 +2011,12 @@
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346,
"provider_specific_entry": {
"us": 1.1,
"fast": 6
},
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-sonnet-4-20250514": {
@ -2523,6 +2537,11 @@
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.035,
"search_context_size_medium": 0.035,
"search_context_size_high": 0.035
},
"supports_service_tier": true
},
"gemini-2.5-flash-lite": {
@ -2569,6 +2588,11 @@
"supports_url_context": true,
"supports_vision": true,
"supports_web_search": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.035,
"search_context_size_medium": 0.035,
"search_context_size_high": 0.035
},
"supports_service_tier": true
},
"gemini-2.5-pro": {
@ -2615,6 +2639,11 @@
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.035,
"search_context_size_medium": 0.035,
"search_context_size_high": 0.035
},
"supports_service_tier": true
},
"gmi/anthropic/claude-opus-4.5": {
@ -2663,11 +2692,13 @@
},
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.00000375,
"cache_creation_input_token_cost_above_1hr": 0.000006,
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"input_cost_per_token_above_200k_tokens": 0.000006,
"output_cost_per_token_above_200k_tokens": 0.0000225,
"cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012,
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
@ -2724,6 +2755,7 @@
},
"global.anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.00000125,
"cache_creation_input_token_cost_above_1hr": 0.000002,
"cache_read_input_token_cost": 1e-7,
"input_cost_per_token": 0.000001,
"litellm_provider": "bedrock_converse",
@ -3036,7 +3068,9 @@
"supported_modalities": [
"text"
],
"supports_tool_choice": false
"supports_tool_choice": false,
"max_input_tokens": 200000,
"max_output_tokens": 1024
},
"gradient_ai/anthropic-claude-3.5-haiku": {
"input_cost_per_token": 8e-7,
@ -3050,7 +3084,9 @@
"supported_modalities": [
"text"
],
"supports_tool_choice": false
"supports_tool_choice": false,
"max_input_tokens": 200000,
"max_output_tokens": 1024
},
"gradient_ai/anthropic-claude-3.5-sonnet": {
"input_cost_per_token": 0.000003,
@ -3064,7 +3100,9 @@
"supported_modalities": [
"text"
],
"supports_tool_choice": false
"supports_tool_choice": false,
"max_input_tokens": 200000,
"max_output_tokens": 1024
},
"gradient_ai/anthropic-claude-3.7-sonnet": {
"input_cost_per_token": 0.000003,
@ -3078,7 +3116,9 @@
"supported_modalities": [
"text"
],
"supports_tool_choice": false
"supports_tool_choice": false,
"max_input_tokens": 200000,
"max_output_tokens": 1024
},
"jp.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.000004125,
@ -3138,12 +3178,14 @@
"input_cost_per_image": 0.0004,
"input_cost_per_token": 2.5e-7,
"litellm_provider": "openrouter",
"max_tokens": 200000,
"max_tokens": 4096,
"mode": "chat",
"output_cost_per_token": 0.00000125,
"supports_function_calling": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"max_input_tokens": 200000,
"max_output_tokens": 4096
},
"openrouter/anthropic/claude-3.5-sonnet": {
"input_cost_per_token": 0.000003,
@ -3468,6 +3510,7 @@
},
"us.anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.000001375,
"cache_creation_input_token_cost_above_1hr": 0.0000022,
"cache_read_input_token_cost": 1.1e-7,
"input_cost_per_token": 0.0000011,
"litellm_provider": "bedrock_converse",
@ -3619,11 +3662,13 @@
},
"us.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.000004125,
"cache_creation_input_token_cost_above_1hr": 0.0000066,
"cache_read_input_token_cost": 3.3e-7,
"input_cost_per_token": 0.0000033,
"input_cost_per_token_above_200k_tokens": 0.0000066,
"output_cost_per_token_above_200k_tokens": 0.00002475,
"cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000132,
"cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
@ -3724,6 +3769,7 @@
},
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
@ -3751,6 +3797,7 @@
},
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",

View file

@ -1,27 +1,27 @@
{
"version": "0.0.13",
"sourceRef": "v0.0.13",
"version": "0.0.15",
"sourceRef": "v0.0.15",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/claude_agent_teams_ui",
"releaseTag": "v1.2.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.13.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.15.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.13.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.15.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.13.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.15.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.13.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.15.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { api } from '@renderer/api';
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';

View file

@ -6,29 +6,53 @@ import {
} from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
import type { AppState } from '@renderer/store/types';
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
interface GraphMemberPopoverContext {
teamData:
| (NonNullable<ReturnType<typeof selectTeamDataForName>> & {
members: ReturnType<typeof selectResolvedMembersForTeamName>;
messageFeed: [];
})
| null;
teamMembers: ReturnType<typeof selectResolvedMembersForTeamName>;
spawnEntry: AppState['memberSpawnStatusesByTeam'][string][string] | undefined;
leadActivity: AppState['leadActivityByTeam'][string] | undefined;
progress: ReturnType<typeof getCurrentProvisioningProgressForTeam> | null;
memberSpawnSnapshot: AppState['memberSpawnSnapshotsByTeam'][string] | undefined;
memberSpawnStatuses: AppState['memberSpawnStatusesByTeam'][string] | undefined;
}
function selectGraphMemberPopoverContext(
state: AppState,
teamName: string,
memberName: string
): GraphMemberPopoverContext {
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
return {
teamData: snapshot
? {
...snapshot,
members: teamMembers,
messageFeed: [],
}
: null,
teamMembers,
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
};
}
export function useGraphMemberPopoverContext(
teamName: string,
memberName: string
): ReturnType<typeof selectGraphMemberPopoverContext> {
return useStore(
useShallow((state) => {
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
return {
teamData: snapshot
? {
...snapshot,
members: teamMembers,
messageFeed: [],
}
: null,
teamMembers,
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
};
})
useShallow((state) => selectGraphMemberPopoverContext(state, teamName, memberName))
);
}

View file

@ -289,6 +289,15 @@ export const GraphActivityHud = ({
const handleMessageClick = useCallback((item: TimelineItem) => {
setExpandedItem(item);
}, []);
const handleMessageKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>, item: TimelineItem): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMessageClick(item);
}
},
[handleMessageClick]
);
const handleMemberClick = useCallback(
(member: ResolvedTeamMember) => {
@ -360,6 +369,52 @@ export const GraphActivityHud = ({
};
}, [enabled, forwardWheelToGraph, visibleLanes]);
const renderLaneEntry = useCallback(
(entry: InlineActivityEntry, index: number): React.JSX.Element => {
const messageKey = toMessageKey(entry.message);
const timelineItem: TimelineItem = {
type: 'message',
message: entry.message,
};
const isUnread = !entry.message.read && !readSet.has(messageKey);
return (
<div
key={entry.graphItem.id}
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden"
role="button"
tabIndex={0}
onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => handleMessageKeyDown(event, timelineItem)}
>
<GraphActivityCard
message={entry.message}
teamName={teamName}
messageContext={messageContext}
teamNames={teamNames}
teamColorByName={teamColorByName}
isUnread={isUnread}
zebraShade={index % 2 === 1}
onClick={() => handleMessageClick(timelineItem)}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>
</div>
);
},
[
handleMessageClick,
handleMessageKeyDown,
messageContext,
onOpenMemberProfile,
onOpenTaskDetail,
readSet,
teamColorByName,
teamName,
teamNames,
]
);
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
return null;
}
@ -421,43 +476,7 @@ export const GraphActivityHud = ({
No recent activity
</div>
) : null}
{lane.entries.map((entry, index) => {
const messageKey = toMessageKey(entry.message);
const timelineItem: TimelineItem = {
type: 'message',
message: entry.message,
};
const isUnread = !entry.message.read && !readSet.has(messageKey);
return (
<div
key={entry.graphItem.id}
className="h-[72px] min-h-[72px] min-w-0 max-w-full cursor-pointer overflow-hidden"
role="button"
tabIndex={0}
onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMessageClick(timelineItem);
}
}}
>
<GraphActivityCard
message={entry.message}
teamName={teamName}
messageContext={messageContext}
teamNames={teamNames}
teamColorByName={teamColorByName}
isUnread={isUnread}
zebraShade={index % 2 === 1}
onClick={() => handleMessageClick(timelineItem)}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>
</div>
);
})}
{lane.entries.map(renderLaneEntry)}
{lane.overflowCount > 0 ? (
<button

View file

@ -19,7 +19,11 @@ import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud';
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
import type {
GraphDomainRef,
GraphEventPort,
TransientHandoffCard,
} from '@claude-teams/agent-graph';
import type {
MemberActivityFilter,
MemberDetailTab,
@ -149,7 +153,7 @@ export const TeamGraphOverlay = ({
focusNodeIds?: ReadonlySet<string> | null;
focusEdgeIds?: ReadonlySet<string> | null;
}) => {
cards: import('@claude-teams/agent-graph').TransientHandoffCard[];
cards: TransientHandoffCard[];
time: number;
};
worldToScreen?: (x: number, y: number) => { x: number; y: number };

View file

@ -19,7 +19,11 @@ import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud';
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
import type {
GraphDomainRef,
GraphEventPort,
TransientHandoffCard,
} from '@claude-teams/agent-graph';
import type {
MemberActivityFilter,
MemberDetailTab,
@ -169,7 +173,7 @@ export const TeamGraphTab = ({
focusNodeIds?: ReadonlySet<string> | null;
focusEdgeIds?: ReadonlySet<string> | null;
}) => {
cards: import('@claude-teams/agent-graph').TransientHandoffCard[];
cards: TransientHandoffCard[];
time: number;
};
worldToScreen?: (x: number, y: number) => { x: number; y: number };

View file

@ -260,6 +260,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
private mutationQueue: Promise<void> = Promise.resolve();
private mutationQueueRelease: (() => void) | null = null;
private activeMutationCount = 0;
private disposed = false;
constructor(
private readonly logger: LoggerPort,
@ -290,8 +291,12 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
}
async getSnapshot(): Promise<CodexAccountSnapshotDto> {
if (this.snapshotCache && Date.now() - this.snapshotObservedAt <= SNAPSHOT_CACHE_TTL_MS) {
return deepClone(this.snapshotCache);
const cached = this.getCachedSnapshotForOptions({
includeRateLimits: false,
forceRefreshToken: false,
});
if (cached) {
return cached;
}
return this.refreshSnapshot();
@ -301,10 +306,13 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
includeRateLimits?: boolean;
forceRefreshToken?: boolean;
}): Promise<CodexAccountSnapshotDto> {
this.pendingRefreshOptions = mergeRefreshOptions(
this.pendingRefreshOptions,
normalizeRefreshOptions(options)
);
const normalizedOptions = normalizeRefreshOptions(options);
const cached = this.getCachedSnapshotForOptions(normalizedOptions);
if (cached) {
return cached;
}
this.pendingRefreshOptions = mergeRefreshOptions(this.pendingRefreshOptions, normalizedOptions);
if (!this.refreshPromise) {
this.refreshPromise = this.drainRefreshQueue().finally(() => {
@ -386,6 +394,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
}
async dispose(): Promise<void> {
this.disposed = true;
await this.loginSessionManager.dispose();
this.listeners.clear();
this.snapshotCache = null;
@ -410,7 +419,8 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
this.pendingRefreshOptions = null;
await this.mutationQueue.catch(() => undefined);
lastSnapshot = await this.loadSnapshot(nextOptions);
lastSnapshot =
this.getCachedSnapshotForOptions(nextOptions) ?? (await this.loadSnapshot(nextOptions));
}
if (!lastSnapshot) {
@ -464,12 +474,17 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
let accountPayload = this.lastKnownAccount?.payload ?? null;
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
let runtimeContext = createRuntimeContext(binaryPath, null);
const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now);
const shouldRequestRateLimits =
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
let rateLimitsReadFailure: unknown | null = null;
try {
const accountResult = await this.appServerClient.readAccount({
const accountResult = await this.appServerClient.readAccountSnapshot({
binaryPath,
env,
refreshToken: options?.forceRefreshToken ?? false,
includeRateLimits: shouldRequestRateLimits,
});
runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome);
if (runtimeContext.codexHome) {
@ -498,6 +513,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
observedAt: now,
};
}
if (accountResult.rateLimits?.ok) {
this.lastKnownRateLimits = {
payload: accountResult.rateLimits.payload,
observedAt: now,
};
} else if (accountResult.rateLimits) {
rateLimitsReadFailure = accountResult.rateLimits.error;
}
} catch (error) {
const failure = classifyAppServerFailure(error);
appServerState = failure.appServerState;
@ -525,35 +548,18 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
let rateLimits: CodexRateLimitSnapshotDto | null = null;
const shouldLoadRateLimits =
options?.includeRateLimits === true ||
(this.lastKnownRateLimits !== null &&
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS);
options?.includeRateLimits === true || this.hasFreshRateLimits(now);
if (shouldLoadRateLimits) {
try {
if (
this.lastKnownRateLimits &&
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS
) {
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
} else {
const rateLimitsPayload = await this.appServerClient.readRateLimits({
binaryPath,
env,
});
this.lastKnownRateLimits = {
payload: rateLimitsPayload,
observedAt: now,
};
rateLimits = asRateLimits(rateLimitsPayload.rateLimits);
}
} catch (error) {
if (this.hasFreshRateLimits(now) && this.lastKnownRateLimits) {
rateLimits = asRateLimits(this.lastKnownRateLimits.payload.rateLimits);
} else if (rateLimitsReadFailure) {
this.logger.warn('codex account rate limits refresh failed', {
error: error instanceof Error ? error.message : String(error),
error:
rateLimitsReadFailure instanceof Error
? rateLimitsReadFailure.message
: String(rateLimitsReadFailure),
});
rateLimits = this.lastKnownRateLimits
? asRateLimits(this.lastKnownRateLimits.payload.rateLimits)
: null;
}
}
@ -590,6 +596,10 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
}
private setSnapshot(nextSnapshot: CodexAccountSnapshotDto): CodexAccountSnapshotDto {
if (this.disposed) {
return deepClone(nextSnapshot);
}
this.snapshotCache = deepClone(nextSnapshot);
this.snapshotObservedAt = Date.now();
const snapshot = deepClone(nextSnapshot);
@ -600,6 +610,36 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
return snapshot;
}
private getCachedSnapshotForOptions(
options: CodexSnapshotRefreshOptions
): CodexAccountSnapshotDto | null {
if (
this.hasPendingMutation() ||
options.forceRefreshToken ||
!this.snapshotCache ||
Date.now() - this.snapshotObservedAt > SNAPSHOT_CACHE_TTL_MS
) {
return null;
}
if (options.includeRateLimits && !this.hasFreshRateLimits(Date.now())) {
return null;
}
return deepClone(this.snapshotCache);
}
private hasPendingMutation(): boolean {
return this.activeMutationCount > 0 || this.mutationQueueRelease !== null;
}
private hasFreshRateLimits(now: number): boolean {
return (
this.lastKnownRateLimits !== null &&
now - this.lastKnownRateLimits.observedAt <= RATE_LIMITS_CACHE_TTL_MS
);
}
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
if (!this.snapshotCache) {
return this.refreshSnapshot();

View file

@ -12,26 +12,39 @@ const ACCOUNT_RATE_LIMITS_TIMEOUT_MS = 4_500;
const ACCOUNT_LOGOUT_TIMEOUT_MS = 3_500;
const INITIALIZE_TIMEOUT_MS = 6_000;
const TOTAL_TIMEOUT_MS = 9_000;
const TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS = 15_000;
type CodexAccountRateLimitsReadResult =
| { ok: true; payload: CodexAppServerGetAccountRateLimitsResponse }
| { ok: false; error: unknown };
export class CodexAccountAppServerClient {
constructor(private readonly sessionFactory: CodexAppServerSessionFactory) {}
async readAccount(options: {
async readAccountSnapshot(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
refreshToken?: boolean;
includeRateLimits?: boolean;
}): Promise<{
account: CodexAppServerGetAccountResponse;
rateLimits: CodexAccountRateLimitsReadResult | null;
initialize: { codexHome: string; platformFamily: string; platformOs: string };
}> {
const includeRateLimits = options.includeRateLimits === true;
return this.sessionFactory.withSession(
{
binaryPath: options.binaryPath,
env: options.env,
requestTimeoutMs: ACCOUNT_READ_TIMEOUT_MS,
requestTimeoutMs: includeRateLimits
? ACCOUNT_RATE_LIMITS_TIMEOUT_MS
: ACCOUNT_READ_TIMEOUT_MS,
initializeTimeoutMs: INITIALIZE_TIMEOUT_MS,
totalTimeoutMs: TOTAL_TIMEOUT_MS,
label: 'codex app-server account/read',
totalTimeoutMs: includeRateLimits ? TOTAL_WITH_RATE_LIMITS_TIMEOUT_MS : TOTAL_TIMEOUT_MS,
label: includeRateLimits
? 'codex app-server account/read with rateLimits/read'
: 'codex app-server account/read',
},
async (session) => {
const account = await session.request<CodexAppServerGetAccountResponse>(
@ -42,8 +55,25 @@ export class CodexAccountAppServerClient {
ACCOUNT_READ_TIMEOUT_MS
);
let rateLimits: CodexAccountRateLimitsReadResult | null = null;
if (includeRateLimits) {
try {
rateLimits = {
ok: true,
payload: await session.request<CodexAppServerGetAccountRateLimitsResponse>(
'account/rateLimits/read',
undefined,
ACCOUNT_RATE_LIMITS_TIMEOUT_MS
),
};
} catch (error) {
rateLimits = { ok: false, error };
}
}
return {
account,
rateLimits,
initialize: {
codexHome: session.initializeResponse.codexHome,
platformFamily: session.initializeResponse.platformFamily,
@ -54,6 +84,21 @@ export class CodexAccountAppServerClient {
);
}
async readAccount(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;
refreshToken?: boolean;
}): Promise<{
account: CodexAppServerGetAccountResponse;
initialize: { codexHome: string; platformFamily: string; platformOs: string };
}> {
const result = await this.readAccountSnapshot(options);
return {
account: result.account,
initialize: result.initialize,
};
}
async readRateLimits(options: {
binaryPath: string;
env: NodeJS.ProcessEnv;

View file

@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { CodexAccountAppServerClient } from '../CodexAccountAppServerClient';
import type {
CodexAppServerSession,
CodexAppServerSessionFactory,
} from '@main/services/infrastructure/codexAppServer';
function createFactory(request: CodexAppServerSession['request']): {
factory: CodexAppServerSessionFactory;
requests: { method: string; params: unknown }[];
} {
const requests: { method: string; params: unknown }[] = [];
const session: CodexAppServerSession = {
initializeResponse: {
userAgent: 'codex-cli 0.117.0',
codexHome: '/Users/me/.codex',
platformFamily: 'macos',
platformOs: 'darwin',
},
request: async <TResult>(method: string, params?: unknown, timeoutMs?: number) => {
requests.push({ method, params });
return request<TResult>(method, params, timeoutMs);
},
notify: async () => undefined,
onNotification: () => () => undefined,
close: async () => undefined,
};
const factory = {
withSession: async <TResult>(
_options: unknown,
handler: (session: CodexAppServerSession) => Promise<TResult>
): Promise<TResult> => handler(session),
} as unknown as CodexAppServerSessionFactory;
return { factory, requests };
}
describe('CodexAccountAppServerClient', () => {
it('reads account and optional rate limits in one app-server session', async () => {
let sessionCount = 0;
const { factory, requests } = createFactory(async <TResult>(method: string) => {
if (method === 'account/read') {
return {
account: { type: 'chatgpt', email: 'user@example.com', planType: 'pro' },
requiresOpenaiAuth: true,
} as TResult;
}
if (method === 'account/rateLimits/read') {
return {
rateLimits: {
limitId: 'codex',
limitName: null,
primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null },
secondary: null,
credits: null,
planType: 'pro',
},
rateLimitsByLimitId: null,
} as TResult;
}
throw new Error(`Unexpected method ${method}`);
});
const countedFactory = {
withSession: async <TResult>(
options: unknown,
handler: (session: CodexAppServerSession) => Promise<TResult>
): Promise<TResult> => {
sessionCount += 1;
return factory.withSession(options as never, handler);
},
} as unknown as CodexAppServerSessionFactory;
const client = new CodexAccountAppServerClient(countedFactory);
const result = await client.readAccountSnapshot({
binaryPath: '/usr/local/bin/codex',
env: {},
refreshToken: true,
includeRateLimits: true,
});
expect(sessionCount).toBe(1);
expect(result.account.account).toMatchObject({
type: 'chatgpt',
email: 'user@example.com',
});
expect(result.rateLimits).toMatchObject({
ok: true,
payload: {
rateLimits: {
primary: { usedPercent: 42 },
},
},
});
expect(result.initialize.codexHome).toBe('/Users/me/.codex');
expect(requests).toEqual([
{ method: 'account/read', params: { refreshToken: true } },
{ method: 'account/rateLimits/read', params: undefined },
]);
});
it('keeps a successful account read when optional rate limits fail', async () => {
const rateLimitError = new Error('rate limits failed');
const { factory } = createFactory(async <TResult>(method: string) => {
if (method === 'account/read') {
return {
account: { type: 'apiKey' },
requiresOpenaiAuth: false,
} as TResult;
}
if (method === 'account/rateLimits/read') {
throw rateLimitError;
}
throw new Error(`Unexpected method ${method}`);
});
const client = new CodexAccountAppServerClient(factory);
const result = await client.readAccountSnapshot({
binaryPath: '/usr/local/bin/codex',
env: {},
includeRateLimits: true,
});
expect(result.account).toEqual({
account: { type: 'apiKey' },
requiresOpenaiAuth: false,
});
expect(result.rateLimits).toEqual({
ok: false,
error: rateLimitError,
});
});
it('surfaces account read failures without attempting rate limits', async () => {
const requests: string[] = [];
const { factory } = createFactory(async <TResult>(method: string) => {
requests.push(method);
if (method === 'account/read') {
throw new Error('account failed');
}
return {} as TResult;
});
const client = new CodexAccountAppServerClient(factory);
await expect(
client.readAccountSnapshot({
binaryPath: '/usr/local/bin/codex',
env: {},
includeRateLimits: true,
})
).rejects.toThrow('account failed');
expect(requests).toEqual(['account/read']);
});
});

View file

@ -0,0 +1,2 @@
export * from './ipc';
export type * from './types';

View file

@ -0,0 +1,3 @@
export const MEMBER_WORK_SYNC_GET_STATUS = 'member-work-sync:getStatus';
export const MEMBER_WORK_SYNC_GET_METRICS = 'member-work-sync:getMetrics';
export const MEMBER_WORK_SYNC_REPORT = 'member-work-sync:report';

View file

@ -0,0 +1,306 @@
export type MemberWorkSyncReportState = 'still_working' | 'blocked' | 'caught_up';
export type MemberWorkSyncStatusState =
| 'caught_up'
| 'needs_sync'
| 'still_working'
| 'blocked'
| 'inactive'
| 'unknown';
export type MemberWorkSyncActionableWorkKind =
| 'work'
| 'review'
| 'clarification'
| 'blocked_dependency';
export type MemberWorkSyncActionableWorkPriority =
| 'normal'
| 'review_requested'
| 'blocked'
| 'needs_clarification';
export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
export interface MemberWorkSyncActionableWorkItem {
taskId: string;
displayId?: string;
subject: string;
kind: MemberWorkSyncActionableWorkKind;
assignee: string;
priority: MemberWorkSyncActionableWorkPriority;
reason: string;
evidence: {
status: string;
owner?: string;
reviewer?: string;
reviewState?: string;
needsClarification?: 'lead' | 'user';
blockerTaskIds?: string[];
blockedByTaskIds?: string[];
historyEventIds?: string[];
};
}
export interface MemberWorkSyncAgenda {
teamName: string;
memberName: string;
generatedAt: string;
fingerprint: string;
items: MemberWorkSyncActionableWorkItem[];
diagnostics: string[];
sourceRevision?: string;
}
export interface MemberWorkSyncReport {
state: MemberWorkSyncReportState;
agendaFingerprint: string;
memberName: string;
teamName: string;
reportedAt: string;
expiresAt?: string;
taskIds?: string[];
note?: string;
source?: 'mcp' | 'app' | 'test';
accepted: boolean;
rejectionCode?: string;
}
export type MemberWorkSyncReportIntentStatus = 'pending' | 'accepted' | 'rejected' | 'superseded';
export interface MemberWorkSyncReportIntent {
id: string;
teamName: string;
memberName: string;
request: MemberWorkSyncReportRequest;
reason: string;
status: MemberWorkSyncReportIntentStatus;
recordedAt: string;
processedAt?: string;
resultCode?: string;
}
export interface MemberWorkSyncShadowDiagnostics {
reconciledBy: 'request' | 'queue' | 'report';
wouldNudge: boolean;
fingerprintChanged: boolean;
previousFingerprint?: string;
triggerReasons?: string[];
}
export interface MemberWorkSyncStatus {
teamName: string;
memberName: string;
state: MemberWorkSyncStatusState;
agenda: MemberWorkSyncAgenda;
report?: MemberWorkSyncReport;
reportToken?: string;
reportTokenExpiresAt?: string;
shadow?: MemberWorkSyncShadowDiagnostics;
evaluatedAt: string;
diagnostics: string[];
providerId?: MemberWorkSyncProviderId;
}
export type MemberWorkSyncMetricEventKind =
| 'status_evaluated'
| 'would_nudge'
| 'fingerprint_changed'
| 'report_accepted'
| 'report_rejected';
export interface MemberWorkSyncMetricEvent {
id: string;
teamName: string;
memberName: string;
kind: MemberWorkSyncMetricEventKind;
state: MemberWorkSyncStatusState;
agendaFingerprint: string;
recordedAt: string;
actionableCount: number;
providerId?: MemberWorkSyncProviderId;
previousFingerprint?: string;
triggerReasons?: string[];
reportState?: MemberWorkSyncReportState;
rejectionCode?: string;
}
export interface MemberWorkSyncTeamMetrics {
teamName: string;
generatedAt: string;
memberCount: number;
stateCounts: Record<MemberWorkSyncStatusState, number>;
actionableItemCount: number;
wouldNudgeCount: number;
fingerprintChangeCount: number;
reportAcceptedCount: number;
reportRejectedCount: number;
recentEvents: MemberWorkSyncMetricEvent[];
phase2Readiness: MemberWorkSyncPhase2ReadinessAssessment;
}
export type MemberWorkSyncPhase2ReadinessState =
| 'collecting_shadow_data'
| 'shadow_ready'
| 'blocked';
export type MemberWorkSyncPhase2ReadinessReason =
| 'insufficient_members'
| 'insufficient_status_events'
| 'insufficient_observation_window'
| 'would_nudge_rate_high'
| 'fingerprint_churn_high'
| 'report_rejection_rate_high';
export interface MemberWorkSyncPhase2ReadinessThresholds {
minObservedMembers: number;
minStatusEvents: number;
minObservationHours: number;
maxWouldNudgesPerMemberHour: number;
maxFingerprintChangesPerMemberHour: number;
maxReportRejectionRate: number;
}
export interface MemberWorkSyncPhase2ReadinessRates {
observationHours: number;
statusEventCount: number;
wouldNudgesPerMemberHour: number;
fingerprintChangesPerMemberHour: number;
reportRejectionRate: number;
}
export interface MemberWorkSyncPhase2ReadinessAssessment {
state: MemberWorkSyncPhase2ReadinessState;
reasons: MemberWorkSyncPhase2ReadinessReason[];
thresholds: MemberWorkSyncPhase2ReadinessThresholds;
rates: MemberWorkSyncPhase2ReadinessRates;
diagnostics: string[];
}
export interface MemberWorkSyncReportRequest {
teamName: string;
memberName: string;
state: MemberWorkSyncReportState;
agendaFingerprint: string;
reportToken?: string;
taskIds?: string[];
note?: string;
reportedAt?: string;
leaseTtlMs?: number;
source?: 'mcp' | 'app' | 'test';
}
export interface MemberWorkSyncReportResult {
accepted: boolean;
code: string;
message: string;
status: MemberWorkSyncStatus;
}
export interface MemberWorkSyncStatusRequest {
teamName: string;
memberName: string;
}
export interface MemberWorkSyncMetricsRequest {
teamName: string;
}
export type MemberWorkSyncOutboxStatus =
| 'pending'
| 'claimed'
| 'delivered'
| 'superseded'
| 'failed_retryable'
| 'failed_terminal';
export interface MemberWorkSyncNudgePayload {
from: 'system';
to: string;
messageKind: 'member_work_sync_nudge';
source: 'member-work-sync';
actionMode: 'do';
text: string;
taskRefs: {
taskId: string;
displayId: string;
teamName: string;
}[];
}
export interface MemberWorkSyncOutboxItem {
id: string;
teamName: string;
memberName: string;
agendaFingerprint: string;
payloadHash: string;
payload: MemberWorkSyncNudgePayload;
status: MemberWorkSyncOutboxStatus;
attemptGeneration: number;
claimedBy?: string;
claimedAt?: string;
deliveredMessageId?: string;
lastError?: string;
nextAttemptAt?: string;
createdAt: string;
updatedAt: string;
}
export type MemberWorkSyncOutboxEnsureResult =
| { ok: true; outcome: 'created' | 'existing'; item: MemberWorkSyncOutboxItem }
| {
ok: false;
outcome: 'payload_conflict';
item: MemberWorkSyncOutboxItem;
existingPayloadHash: string;
requestedPayloadHash: string;
};
export interface MemberWorkSyncOutboxEnsureInput {
id: string;
teamName: string;
memberName: string;
agendaFingerprint: string;
payloadHash: string;
payload: MemberWorkSyncNudgePayload;
nowIso: string;
nextAttemptAt?: string;
}
export interface MemberWorkSyncOutboxClaimInput {
teamName: string;
claimedBy: string;
nowIso: string;
limit: number;
}
export interface MemberWorkSyncOutboxMarkDeliveredInput {
teamName: string;
id: string;
attemptGeneration: number;
deliveredMessageId: string;
nowIso: string;
}
export interface MemberWorkSyncOutboxMarkSupersededInput {
teamName: string;
id: string;
reason: string;
nowIso: string;
}
export interface MemberWorkSyncOutboxMarkFailedInput {
teamName: string;
id: string;
attemptGeneration: number;
error: string;
retryable: boolean;
nowIso: string;
nextAttemptAt?: string;
}
export interface MemberWorkSyncOutboxCountRecentDeliveredInput {
teamName: string;
memberName: string;
sinceIso: string;
}

View file

@ -0,0 +1,44 @@
import type {
MemberWorkSyncAuditEvent,
MemberWorkSyncAuditEventName,
MemberWorkSyncUseCaseDeps,
} from './ports';
export type MemberWorkSyncAuditEventInput = Omit<MemberWorkSyncAuditEvent, 'timestamp'> & {
timestamp?: string;
};
export async function appendMemberWorkSyncAudit(
deps: Pick<MemberWorkSyncUseCaseDeps, 'auditJournal' | 'clock' | 'logger'>,
input: MemberWorkSyncAuditEventInput
): Promise<void> {
if (!deps.auditJournal) {
return;
}
try {
await deps.auditJournal.append({
...input,
timestamp: input.timestamp ?? deps.clock.now().toISOString(),
});
} catch (error) {
deps.logger?.warn('member work sync audit event failed', {
teamName: input.teamName,
memberName: input.memberName,
event: input.event,
error: String(error),
});
}
}
export function reasonToAuditEvent(reason: string): MemberWorkSyncAuditEventName {
if (reason.startsWith('member_busy:')) {
return 'member_busy';
}
if (reason === 'watchdog_cooldown_active') {
return 'watchdog_cooldown_active';
}
if (reason === 'team_inactive') {
return 'team_inactive';
}
return 'nudge_skipped';
}

View file

@ -0,0 +1,16 @@
import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler';
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
export class MemberWorkSyncDiagnosticsReader {
private readonly reconciler: MemberWorkSyncReconciler;
constructor(deps: MemberWorkSyncUseCaseDeps) {
this.reconciler = new MemberWorkSyncReconciler(deps);
}
async execute(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> {
return this.reconciler.execute(request);
}
}

View file

@ -0,0 +1,50 @@
import { assessMemberWorkSyncPhase2Readiness } from '../domain';
import type { MemberWorkSyncMetricsRequest, MemberWorkSyncTeamMetrics } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
function emptyMetrics(teamName: string, generatedAt: string): MemberWorkSyncTeamMetrics {
return {
teamName,
generatedAt,
memberCount: 0,
stateCounts: {
caught_up: 0,
needs_sync: 0,
still_working: 0,
blocked: 0,
inactive: 0,
unknown: 0,
},
actionableItemCount: 0,
wouldNudgeCount: 0,
fingerprintChangeCount: 0,
reportAcceptedCount: 0,
reportRejectedCount: 0,
recentEvents: [],
phase2Readiness: assessMemberWorkSyncPhase2Readiness({
memberCount: 0,
recentEvents: [],
}),
};
}
export class MemberWorkSyncMetricsReader {
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
async execute(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics> {
if (!this.deps.statusStore.readTeamMetrics) {
return emptyMetrics(request.teamName, this.deps.clock.now().toISOString());
}
const metrics = await this.deps.statusStore.readTeamMetrics(request.teamName);
return {
...metrics,
phase2Readiness:
metrics.phase2Readiness ??
assessMemberWorkSyncPhase2Readiness({
memberCount: metrics.memberCount,
recentEvents: metrics.recentEvents,
}),
};
}
}

View file

@ -0,0 +1,264 @@
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
import type { MemberWorkSyncOutboxItem } from '../../contracts';
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
const MEMBER_WORK_SYNC_RETRY_BASE_MINUTES = 10;
const MEMBER_WORK_SYNC_RETRY_MAX_MINUTES = 60;
export interface MemberWorkSyncNudgeDispatchSummary {
claimed: number;
delivered: number;
superseded: number;
retryable: number;
terminal: number;
}
export interface MemberWorkSyncNudgeDispatchOptions {
claimedBy: string;
teamNames: string[];
limit?: number;
}
function emptySummary(): MemberWorkSyncNudgeDispatchSummary {
return { claimed: 0, delivered: 0, superseded: 0, retryable: 0, terminal: 0 };
}
function addMinutes(iso: string, minutes: number): string {
return new Date(Date.parse(iso) + minutes * 60_000).toISOString();
}
function subtractMinutes(iso: string, minutes: number): string {
return new Date(Date.parse(iso) - minutes * 60_000).toISOString();
}
function stableJitterMinutes(id: string, attemptGeneration: number): number {
const seed = `${id}:${attemptGeneration}`;
let value = 0;
for (const char of seed) {
value = (value * 31 + char.charCodeAt(0)) % 997;
}
return value % 5;
}
function nextRetryAt(item: MemberWorkSyncOutboxItem, nowIso: string): string {
const exponentialMinutes =
MEMBER_WORK_SYNC_RETRY_BASE_MINUTES * 2 ** Math.max(0, item.attemptGeneration - 1);
const cappedMinutes = Math.min(MEMBER_WORK_SYNC_RETRY_MAX_MINUTES, exponentialMinutes);
return addMinutes(nowIso, cappedMinutes + stableJitterMinutes(item.id, item.attemptGeneration));
}
export class MemberWorkSyncNudgeDispatcher {
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
async dispatchDue(
options: MemberWorkSyncNudgeDispatchOptions
): Promise<MemberWorkSyncNudgeDispatchSummary> {
const outbox = this.deps.outboxStore;
const inbox = this.deps.inboxNudge;
if (!outbox || !inbox) {
return emptySummary();
}
const nowIso = this.deps.clock.now().toISOString();
const summary = emptySummary();
for (const teamName of [
...new Set(options.teamNames.map((name) => name.trim()).filter(Boolean)),
]) {
const claimed = await outbox.claimDue({
teamName,
claimedBy: options.claimedBy,
nowIso,
limit: options.limit ?? 10,
});
summary.claimed += claimed.length;
for (const item of claimed) {
const result = await this.dispatchItem(item, nowIso);
summary[result] += 1;
}
}
return summary;
}
private async dispatchItem(
item: MemberWorkSyncOutboxItem,
nowIso: string
): Promise<keyof Omit<MemberWorkSyncNudgeDispatchSummary, 'claimed'>> {
const outbox = this.deps.outboxStore;
const inbox = this.deps.inboxNudge;
if (!outbox || !inbox) {
return 'terminal';
}
const revalidation = await this.revalidate(item, nowIso);
if (!revalidation.ok) {
if (revalidation.retryable) {
await outbox.markFailed({
teamName: item.teamName,
id: item.id,
attemptGeneration: item.attemptGeneration,
error: revalidation.reason,
retryable: true,
nowIso,
nextAttemptAt: revalidation.nextAttemptAt ?? nextRetryAt(item, nowIso),
});
await this.appendDispatchAudit(
item,
reasonToAuditEvent(revalidation.reason),
revalidation.reason
);
return 'retryable';
}
await outbox.markSuperseded({
teamName: item.teamName,
id: item.id,
reason: revalidation.reason,
nowIso,
});
await this.appendDispatchAudit(item, 'nudge_superseded', revalidation.reason);
return 'superseded';
}
try {
const inserted = await inbox.insertIfAbsent({
teamName: item.teamName,
memberName: item.memberName,
messageId: item.id,
payloadHash: item.payloadHash,
payload: item.payload,
timestamp: nowIso,
});
if (inserted.conflict) {
await outbox.markFailed({
teamName: item.teamName,
id: item.id,
attemptGeneration: item.attemptGeneration,
error: 'inbox_payload_conflict',
retryable: false,
nowIso,
});
await this.appendDispatchAudit(item, 'nudge_skipped', 'inbox_payload_conflict');
return 'terminal';
}
await outbox.markDelivered({
teamName: item.teamName,
id: item.id,
attemptGeneration: item.attemptGeneration,
deliveredMessageId: inserted.messageId,
nowIso,
});
await this.appendDispatchAudit(item, 'nudge_delivered', 'inbox_inserted');
return 'delivered';
} catch (error) {
await outbox.markFailed({
teamName: item.teamName,
id: item.id,
attemptGeneration: item.attemptGeneration,
error: String(error),
retryable: true,
nowIso,
nextAttemptAt: nextRetryAt(item, nowIso),
});
await this.appendDispatchAudit(item, 'nudge_retryable', String(error));
return 'retryable';
}
}
private async appendDispatchAudit(
item: MemberWorkSyncOutboxItem,
event: MemberWorkSyncAuditEventName,
reason: string
): Promise<void> {
await appendMemberWorkSyncAudit(this.deps, {
teamName: item.teamName,
memberName: item.memberName,
event,
source: 'nudge_dispatcher',
agendaFingerprint: item.agendaFingerprint,
reason,
taskRefs: item.payload.taskRefs,
messagePreview: item.payload.text,
});
}
private async revalidate(
item: MemberWorkSyncOutboxItem,
nowIso: string
): Promise<
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
> {
if (this.deps.lifecycle && !(await this.deps.lifecycle.isTeamActive(item.teamName))) {
return { ok: false, reason: 'team_inactive', retryable: false };
}
const status = await this.deps.statusStore.read({
teamName: item.teamName,
memberName: item.memberName,
});
if (!status) {
return { ok: false, reason: 'status_missing', retryable: false };
}
if (
status.state !== 'needs_sync' ||
status.shadow?.wouldNudge !== true ||
status.agenda.fingerprint !== item.agendaFingerprint
) {
return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false };
}
if (!this.deps.statusStore.readTeamMetrics) {
return { ok: false, reason: 'metrics_unavailable', retryable: true };
}
const metrics = await this.deps.statusStore.readTeamMetrics(item.teamName);
if (metrics.phase2Readiness.state !== 'shadow_ready') {
return { ok: false, reason: 'phase2_not_ready', retryable: true };
}
const recentDelivered = await this.deps.outboxStore?.countRecentDelivered({
teamName: item.teamName,
memberName: item.memberName,
sinceIso: subtractMinutes(nowIso, 60),
});
if (
recentDelivered != null &&
recentDelivered >= MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR
) {
return {
ok: false,
reason: 'member_nudge_rate_limited',
retryable: true,
nextAttemptAt: addMinutes(nowIso, 60),
};
}
const busy = await this.deps.busySignal?.isBusy({
teamName: item.teamName,
memberName: item.memberName,
nowIso,
});
if (busy?.busy) {
return {
ok: false,
reason: `member_busy:${busy.reason ?? 'unknown'}`,
retryable: true,
nextAttemptAt: busy.retryAfterIso,
};
}
const taskIds = item.payload.taskRefs.map((taskRef) => taskRef.taskId);
if (
this.deps.watchdogCooldown &&
(await this.deps.watchdogCooldown.hasRecentNudge({
teamName: item.teamName,
memberName: item.memberName,
taskIds,
nowIso,
}))
) {
return { ok: false, reason: 'watchdog_cooldown_active', retryable: true };
}
return { ok: true };
}
}

View file

@ -0,0 +1,85 @@
import { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
import type { MemberWorkSyncStatus } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
export interface MemberWorkSyncNudgeOutboxPlanResult {
planned: boolean;
code:
| 'outbox_unavailable'
| 'metrics_unavailable'
| 'status_not_nudgeable'
| 'phase2_not_ready'
| 'created'
| 'existing'
| 'payload_conflict';
}
export class MemberWorkSyncNudgeOutboxPlanner {
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
async plan(status: MemberWorkSyncStatus): Promise<MemberWorkSyncNudgeOutboxPlanResult> {
if (!this.deps.outboxStore) {
return { planned: false, code: 'outbox_unavailable' };
}
if (!this.deps.statusStore.readTeamMetrics) {
return { planned: false, code: 'metrics_unavailable' };
}
const input = buildMemberWorkSyncOutboxEnsureInput({
status,
hash: this.deps.hash,
nowIso: status.evaluatedAt,
});
if (!input) {
return { planned: false, code: 'status_not_nudgeable' };
}
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
if (metrics.phase2Readiness.state !== 'shadow_ready') {
await this.appendPlanAudit(status, { planned: false, code: 'phase2_not_ready' });
return { planned: false, code: 'phase2_not_ready' };
}
const result = await this.deps.outboxStore.ensurePending(input);
if (!result.ok) {
this.deps.logger?.warn('member work sync nudge outbox payload conflict', {
teamName: status.teamName,
memberName: status.memberName,
outboxId: input.id,
existingPayloadHash: result.existingPayloadHash,
requestedPayloadHash: result.requestedPayloadHash,
});
await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' });
return { planned: false, code: 'payload_conflict' };
}
const planResult = { planned: true, code: result.outcome } as const;
await this.appendPlanAudit(status, planResult);
return planResult;
}
private async appendPlanAudit(
status: MemberWorkSyncStatus,
result: MemberWorkSyncNudgeOutboxPlanResult
): Promise<void> {
await appendMemberWorkSyncAudit(this.deps, {
teamName: status.teamName,
memberName: status.memberName,
event: result.planned ? 'nudge_planned' : 'nudge_skipped',
source: 'nudge_planner',
agendaFingerprint: status.agenda.fingerprint,
state: status.state,
actionableCount: status.agenda.items.length,
reason: result.code,
...(status.providerId ? { providerId: status.providerId } : {}),
taskRefs: status.agenda.items.map((item) => ({
taskId: item.taskId,
displayId: item.displayId,
teamName: status.teamName,
})),
});
}
}

View file

@ -0,0 +1,82 @@
import { MemberWorkSyncReporter } from './MemberWorkSyncReporter';
import type { MemberWorkSyncReportIntentStatus } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
export interface MemberWorkSyncPendingReportReplaySummary {
processed: number;
accepted: number;
rejected: number;
superseded: number;
}
function statusForResult(input: {
accepted: boolean;
code: string;
}): MemberWorkSyncReportIntentStatus {
if (input.accepted) {
return 'accepted';
}
if (input.code === 'member_inactive' || input.code === 'team_runtime_inactive') {
return 'superseded';
}
return 'rejected';
}
export class MemberWorkSyncPendingReportIntentReplayer {
private readonly reporter: MemberWorkSyncReporter;
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {
this.reporter = new MemberWorkSyncReporter(deps);
}
async replayTeam(teamName: string): Promise<MemberWorkSyncPendingReportReplaySummary> {
const store = this.deps.reportStore;
if (!store?.listPendingReports || !store.markPendingReportProcessed) {
return { processed: 0, accepted: 0, rejected: 0, superseded: 0 };
}
const intents = await store.listPendingReports(teamName);
const summary: MemberWorkSyncPendingReportReplaySummary = {
processed: 0,
accepted: 0,
rejected: 0,
superseded: 0,
};
for (const intent of intents) {
let status: MemberWorkSyncReportIntentStatus = 'rejected';
let resultCode = 'replay_failed';
try {
const result = await this.reporter.execute({
...intent.request,
source: intent.request.source ?? 'mcp',
});
status = statusForResult(result);
resultCode = result.code;
} catch (error) {
this.deps.logger?.warn('member work sync pending report replay failed', {
teamName,
intentId: intent.id,
error: String(error),
});
continue;
}
summary.processed += 1;
if (status === 'accepted') {
summary.accepted += 1;
} else if (status === 'superseded') {
summary.superseded += 1;
} else {
summary.rejected += 1;
}
await store.markPendingReportProcessed(teamName, intent.id, {
status,
resultCode,
processedAt: this.deps.clock.now().toISOString(),
});
}
return summary;
}
}

View file

@ -0,0 +1,161 @@
import {
buildAgendaFingerprintPayload,
canonicalizeAgendaFingerprintPayload,
decideMemberWorkSyncStatus,
formatAgendaFingerprint,
} from '../domain';
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
import { MemberWorkSyncNudgeOutboxPlanner } from './MemberWorkSyncNudgeOutboxPlanner';
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports';
export interface MemberWorkSyncReconcileContext {
reconciledBy?: 'request' | 'queue';
triggerReasons?: string[];
}
export function finalizeMemberWorkSyncAgenda(
deps: MemberWorkSyncUseCaseDeps,
source: MemberWorkSyncAgendaSourceResult
) {
const payload = buildAgendaFingerprintPayload({
teamName: source.agenda.teamName,
memberName: source.agenda.memberName,
items: source.agenda.items,
sourceRevision: source.agenda.sourceRevision,
});
const fingerprint = formatAgendaFingerprint(
deps.hash.sha256Hex(canonicalizeAgendaFingerprintPayload(payload))
);
return {
...source.agenda,
fingerprint,
diagnostics: [...source.agenda.diagnostics, ...source.diagnostics],
};
}
export class MemberWorkSyncReconciler {
private readonly nudgeOutboxPlanner: MemberWorkSyncNudgeOutboxPlanner;
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {
this.nudgeOutboxPlanner = new MemberWorkSyncNudgeOutboxPlanner(deps);
}
async execute(
request: MemberWorkSyncStatusRequest,
context: MemberWorkSyncReconcileContext = {}
): Promise<MemberWorkSyncStatus> {
await appendMemberWorkSyncAudit(this.deps, {
teamName: request.teamName,
memberName: request.memberName,
event: 'reconcile_started',
source: 'reconciler',
...(context.triggerReasons?.length ? { triggerReasons: context.triggerReasons } : {}),
});
const source = await this.deps.agendaSource.loadAgenda(request);
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
await appendMemberWorkSyncAudit(this.deps, {
teamName: agenda.teamName,
memberName: agenda.memberName,
event: 'agenda_loaded',
source: 'reconciler',
agendaFingerprint: agenda.fingerprint,
actionableCount: agenda.items.length,
...(source.providerId ? { providerId: source.providerId } : {}),
diagnostics: agenda.diagnostics,
});
const previous = await this.deps.statusStore.read(request);
const nowIso = this.deps.clock.now().toISOString();
const teamActive = this.deps.lifecycle
? await this.deps.lifecycle.isTeamActive(agenda.teamName)
: true;
const decision = decideMemberWorkSyncStatus({
agenda,
latestAcceptedReport: previous?.report?.accepted ? previous.report : null,
nowIso,
inactive: source.inactive || !teamActive,
});
await appendMemberWorkSyncAudit(this.deps, {
teamName: agenda.teamName,
memberName: agenda.memberName,
event: source.inactive || !teamActive ? 'team_inactive' : 'decision_made',
source: 'reconciler',
agendaFingerprint: agenda.fingerprint,
state: decision.state,
actionableCount: agenda.items.length,
...(source.providerId ? { providerId: source.providerId } : {}),
diagnostics: decision.diagnostics,
});
const status = await attachMemberWorkSyncReportToken(this.deps, {
teamName: agenda.teamName,
memberName: agenda.memberName,
state: decision.state,
agenda,
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
shadow: {
reconciledBy: context.reconciledBy ?? 'request',
wouldNudge: decision.state === 'needs_sync' && agenda.items.length > 0,
fingerprintChanged:
Boolean(previous?.agenda.fingerprint) &&
previous?.agenda.fingerprint !== agenda.fingerprint,
...(previous?.agenda.fingerprint
? { previousFingerprint: previous.agenda.fingerprint }
: {}),
...(context.triggerReasons?.length
? { triggerReasons: [...new Set(context.triggerReasons)].sort() }
: {}),
},
evaluatedAt: nowIso,
diagnostics: [
...agenda.diagnostics,
...(!teamActive ? ['team_runtime_inactive'] : []),
...decision.diagnostics,
],
...(source.providerId ? { providerId: source.providerId } : {}),
});
await this.deps.statusStore.write(status);
if ((context.reconciledBy ?? 'request') === 'queue') {
await this.planNudgeOutbox(status);
}
return status;
}
private async planNudgeOutbox(status: MemberWorkSyncStatus): Promise<void> {
const result = await this.nudgeOutboxPlanner.plan(status);
if (result.code !== 'outbox_unavailable' && result.code !== 'status_not_nudgeable') {
this.deps.logger?.debug('member work sync nudge outbox planning result', {
teamName: status.teamName,
memberName: status.memberName,
code: result.code,
planned: result.planned,
});
}
}
}
export async function attachMemberWorkSyncReportToken(
deps: MemberWorkSyncUseCaseDeps,
status: MemberWorkSyncStatus
): Promise<MemberWorkSyncStatus> {
if (!deps.reportToken) {
return status;
}
const issued = await deps.reportToken.create({
teamName: status.teamName,
memberName: status.memberName,
agendaFingerprint: status.agenda.fingerprint,
issuedAt: status.evaluatedAt,
});
return {
...status,
reportToken: issued.token,
reportTokenExpiresAt: issued.expiresAt,
diagnostics: [...status.diagnostics, 'report_token_issued'],
};
}

View file

@ -0,0 +1,178 @@
import { validateMemberWorkSyncReport } from '../domain';
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
import {
attachMemberWorkSyncReportToken,
finalizeMemberWorkSyncAgenda,
MemberWorkSyncReconciler,
} from './MemberWorkSyncReconciler';
import type {
MemberWorkSyncReport,
MemberWorkSyncReportRequest,
MemberWorkSyncReportResult,
MemberWorkSyncStatus,
} from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports';
export class MemberWorkSyncReporter {
private readonly reconciler: MemberWorkSyncReconciler;
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {
this.reconciler = new MemberWorkSyncReconciler(deps);
}
async execute(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> {
await appendMemberWorkSyncAudit(this.deps, {
teamName: request.teamName,
memberName: request.memberName,
event: 'report_received',
source: 'reporter',
agendaFingerprint: request.agendaFingerprint,
state: request.state,
...(request.taskIds?.length
? {
taskRefs: request.taskIds.map((taskId) => ({
taskId,
teamName: request.teamName,
})),
}
: {}),
});
const source = await this.deps.agendaSource.loadAgenda(request);
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
const nowIso = this.deps.clock.now().toISOString();
const teamActive = this.deps.lifecycle
? await this.deps.lifecycle.isTeamActive(agenda.teamName)
: true;
if (!teamActive) {
const status = await this.reconciler.execute(request);
const rejectedStatus = await this.recordRejectedReport(
status,
request,
'team_runtime_inactive'
);
return {
accepted: false,
code: 'team_runtime_inactive',
message: 'Team runtime is not active. Restart the team before reporting work sync state.',
status: rejectedStatus,
};
}
const tokenValidation = this.deps.reportToken
? await this.deps.reportToken.verify({
token: request.reportToken,
teamName: agenda.teamName,
memberName: agenda.memberName,
agendaFingerprint: agenda.fingerprint,
nowIso,
})
: ({ ok: false, reason: 'missing' } as const);
const validation = validateMemberWorkSyncReport({
request,
agenda,
nowIso,
activeMemberNames: source.activeMemberNames,
tokenValidation,
});
if (!validation.ok) {
const status = await this.reconciler.execute(request);
const rejectedStatus = await this.recordRejectedReport(status, request, validation.code);
return {
accepted: false,
code: validation.code,
message: validation.message,
status: rejectedStatus,
};
}
const report: MemberWorkSyncReport = {
teamName: agenda.teamName,
memberName: agenda.memberName,
state: request.state,
agendaFingerprint: agenda.fingerprint,
reportedAt: nowIso,
...(validation.expiresAt ? { expiresAt: validation.expiresAt } : {}),
...(request.taskIds ? { taskIds: [...request.taskIds] } : {}),
...(request.note ? { note: request.note } : {}),
source: request.source ?? 'app',
accepted: true,
};
const status = await attachMemberWorkSyncReportToken(this.deps, {
teamName: agenda.teamName,
memberName: agenda.memberName,
state:
report.state === 'caught_up'
? ('caught_up' as const)
: report.state === 'blocked'
? ('blocked' as const)
: ('still_working' as const),
agenda,
report,
shadow: {
reconciledBy: 'report',
wouldNudge: false,
fingerprintChanged: false,
},
evaluatedAt: nowIso,
diagnostics: [...agenda.diagnostics, 'report_accepted'],
...(source.providerId ? { providerId: source.providerId } : {}),
});
await this.deps.statusStore.write(status);
await appendMemberWorkSyncAudit(this.deps, {
teamName: status.teamName,
memberName: status.memberName,
event: 'report_accepted',
source: 'reporter',
agendaFingerprint: agenda.fingerprint,
state: status.state,
actionableCount: agenda.items.length,
...(source.providerId ? { providerId: source.providerId } : {}),
});
return {
accepted: true,
code: 'accepted',
message: validation.message,
status,
};
}
private async recordRejectedReport(
status: MemberWorkSyncStatus,
request: MemberWorkSyncReportRequest,
rejectionCode: string
): Promise<MemberWorkSyncStatus> {
const rejectedStatus: MemberWorkSyncStatus = {
...status,
report: {
teamName: status.teamName,
memberName: status.memberName,
state: request.state,
agendaFingerprint: request.agendaFingerprint,
reportedAt: status.evaluatedAt,
...(request.taskIds ? { taskIds: [...request.taskIds] } : {}),
...(request.note ? { note: request.note } : {}),
source: request.source ?? 'app',
accepted: false,
rejectionCode,
},
diagnostics: [...status.diagnostics, `report_rejected:${rejectionCode}`],
};
await this.deps.statusStore.write(rejectedStatus);
await appendMemberWorkSyncAudit(this.deps, {
teamName: status.teamName,
memberName: status.memberName,
event: 'report_rejected',
source: 'reporter',
agendaFingerprint: request.agendaFingerprint,
state: request.state,
actionableCount: status.agenda.items.length,
reason: rejectionCode,
...(status.providerId ? { providerId: status.providerId } : {}),
});
return rejectedStatus;
}
}

View file

@ -0,0 +1,188 @@
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
import type { RuntimeTurnSettledEvent } from '../domain';
import type {
MemberWorkSyncAuditJournalPort,
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;
auditJournal?: MemberWorkSyncAuditJournalPort;
logger?: MemberWorkSyncLoggerPort;
}
export interface RuntimeTurnSettledDrainSummary {
claimed: number;
enqueued: number;
unresolved: number;
ignored: number;
invalid: 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 {
constructor(private readonly deps: RuntimeTurnSettledIngestorDeps) {}
async drainPending(limit: number = 50): Promise<RuntimeTurnSettledDrainSummary> {
const summary: RuntimeTurnSettledDrainSummary = {
claimed: 0,
enqueued: 0,
unresolved: 0,
ignored: 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;
}
if (normalized.event.teamName && normalized.event.memberName) {
await appendMemberWorkSyncAudit(this.deps, {
teamName: normalized.event.teamName,
memberName: normalized.event.memberName,
event: 'turn_settled_claimed',
source: 'runtime_turn_settled_ingestor',
reason: normalized.event.provider,
metadata: {
sourceId: normalized.event.sourceId,
provider: normalized.event.provider,
},
});
}
const ignoredReason = getIgnoredReason(normalized.event);
if (ignoredReason) {
summary.ignored += 1;
await this.deps.eventStore.markProcessed(payload, {
event: normalized.event,
outcome: 'ignored',
reason: ignoredReason,
processedAt,
});
if (normalized.event.teamName && normalized.event.memberName) {
await appendMemberWorkSyncAudit(this.deps, {
teamName: normalized.event.teamName,
memberName: normalized.event.memberName,
event: 'turn_settled_ignored',
source: 'runtime_turn_settled_ingestor',
reason: ignoredReason,
metadata: {
sourceId: normalized.event.sourceId,
provider: normalized.event.provider,
},
});
}
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,
});
if (normalized.event.teamName && normalized.event.memberName) {
await appendMemberWorkSyncAudit(this.deps, {
teamName: normalized.event.teamName,
memberName: normalized.event.memberName,
event: 'turn_settled_unresolved',
source: 'runtime_turn_settled_ingestor',
reason: resolution.reason,
metadata: {
sourceId: normalized.event.sourceId,
provider: normalized.event.provider,
},
});
}
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,
});
await appendMemberWorkSyncAudit(this.deps, {
teamName: resolution.teamName,
memberName: resolution.memberName,
event: 'turn_settled_resolved',
source: 'runtime_turn_settled_ingestor',
reason: normalized.event.provider,
metadata: {
sourceId: normalized.event.sourceId,
provider: normalized.event.provider,
},
});
} 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' | 'ignored';
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

@ -0,0 +1,11 @@
export * from './MemberWorkSyncAudit';
export * from './MemberWorkSyncDiagnosticsReader';
export * from './MemberWorkSyncMetricsReader';
export * from './MemberWorkSyncNudgeDispatcher';
export * from './MemberWorkSyncNudgeOutboxPlanner';
export * from './MemberWorkSyncPendingReportIntentReplayer';
export * from './MemberWorkSyncReconciler';
export * from './MemberWorkSyncReporter';
export type * from './ports';
export * from './RuntimeTurnSettledIngestor';
export type * from './RuntimeTurnSettledPorts';

View file

@ -0,0 +1,202 @@
import type {
MemberWorkSyncAgenda,
MemberWorkSyncOutboxClaimInput,
MemberWorkSyncOutboxCountRecentDeliveredInput,
MemberWorkSyncOutboxEnsureInput,
MemberWorkSyncOutboxEnsureResult,
MemberWorkSyncOutboxItem,
MemberWorkSyncOutboxMarkDeliveredInput,
MemberWorkSyncOutboxMarkFailedInput,
MemberWorkSyncOutboxMarkSupersededInput,
MemberWorkSyncProviderId,
MemberWorkSyncReport,
MemberWorkSyncReportIntent,
MemberWorkSyncReportIntentStatus,
MemberWorkSyncReportRequest,
MemberWorkSyncStatus,
MemberWorkSyncTeamMetrics,
} from '../../contracts';
export interface MemberWorkSyncClockPort {
now(): Date;
}
export interface MemberWorkSyncHashPort {
sha256Hex(value: string): string;
}
export interface MemberWorkSyncReportTokenCreateInput {
teamName: string;
memberName: string;
agendaFingerprint: string;
issuedAt: string;
}
export interface MemberWorkSyncReportTokenVerifyInput {
token?: string;
teamName: string;
memberName: string;
agendaFingerprint: string;
nowIso: string;
}
export type MemberWorkSyncReportTokenVerification =
| { ok: true }
| { ok: false; reason: 'missing' | 'expired' | 'invalid' };
export interface MemberWorkSyncReportTokenPort {
create(input: MemberWorkSyncReportTokenCreateInput): Promise<{
token: string;
expiresAt: string;
}>;
verify(
input: MemberWorkSyncReportTokenVerifyInput
): Promise<MemberWorkSyncReportTokenVerification>;
}
export interface MemberWorkSyncLifecyclePort {
isTeamActive(teamName: string): Promise<boolean> | boolean;
}
export interface MemberWorkSyncLoggerPort {
debug(message: string, metadata?: Record<string, unknown>): void;
warn(message: string, metadata?: Record<string, unknown>): void;
error(message: string, metadata?: Record<string, unknown>): void;
}
export type MemberWorkSyncAuditEventName =
| 'turn_settled_claimed'
| 'turn_settled_resolved'
| 'turn_settled_unresolved'
| 'turn_settled_ignored'
| 'queue_enqueued'
| 'queue_coalesced'
| 'queue_reconciled'
| 'queue_dropped'
| 'reconcile_started'
| 'agenda_loaded'
| 'decision_made'
| 'status_written'
| 'report_received'
| 'report_accepted'
| 'report_rejected'
| 'nudge_planned'
| 'nudge_delivered'
| 'nudge_skipped'
| 'nudge_retryable'
| 'nudge_superseded'
| 'watchdog_cooldown_active'
| 'member_busy'
| 'team_inactive'
| 'index_repaired'
| 'legacy_fallback_used';
export interface MemberWorkSyncAuditEvent {
timestamp: string;
teamName: string;
memberName: string;
event: MemberWorkSyncAuditEventName;
source: string;
agendaFingerprint?: string;
state?: string;
actionableCount?: number;
reason?: string;
triggerReasons?: string[];
providerId?: string;
taskRefs?: { taskId: string; displayId?: string; teamName?: string }[];
diagnostics?: string[];
messagePreview?: string;
metadata?: Record<string, string | number | boolean | null>;
}
export interface MemberWorkSyncAuditJournalPort {
append(event: MemberWorkSyncAuditEvent): Promise<void>;
}
export interface MemberWorkSyncAgendaSourceResult {
agenda: Omit<MemberWorkSyncAgenda, 'fingerprint'>;
activeMemberNames: string[];
inactive: boolean;
providerId?: MemberWorkSyncProviderId;
diagnostics: string[];
}
export interface MemberWorkSyncAgendaSourcePort {
loadAgenda(input: {
teamName: string;
memberName: string;
}): Promise<MemberWorkSyncAgendaSourceResult>;
}
export interface MemberWorkSyncStatusStorePort {
read(input: { teamName: string; memberName: string }): Promise<MemberWorkSyncStatus | null>;
write(status: MemberWorkSyncStatus): Promise<void>;
readTeamMetrics?(teamName: string): Promise<MemberWorkSyncTeamMetrics>;
}
export interface MemberWorkSyncReportStorePort {
appendPendingReport?(request: MemberWorkSyncReportRequest, reason: string): Promise<void>;
listPendingReports?(teamName: string): Promise<MemberWorkSyncReportIntent[]>;
markPendingReportProcessed?(
teamName: string,
id: string,
result: { status: MemberWorkSyncReportIntentStatus; resultCode: string; processedAt: string }
): Promise<void>;
}
export interface MemberWorkSyncOutboxStorePort {
ensurePending(input: MemberWorkSyncOutboxEnsureInput): Promise<MemberWorkSyncOutboxEnsureResult>;
claimDue(input: MemberWorkSyncOutboxClaimInput): Promise<MemberWorkSyncOutboxItem[]>;
markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void>;
markSuperseded(input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void>;
markFailed(input: MemberWorkSyncOutboxMarkFailedInput): Promise<void>;
countRecentDelivered(input: MemberWorkSyncOutboxCountRecentDeliveredInput): Promise<number>;
}
export interface MemberWorkSyncInboxNudgePort {
insertIfAbsent(input: {
teamName: string;
memberName: string;
messageId: string;
payloadHash: string;
payload: MemberWorkSyncOutboxItem['payload'];
timestamp: string;
}): Promise<{ inserted: boolean; messageId: string; conflict?: boolean }>;
}
export interface MemberWorkSyncWatchdogCooldownPort {
hasRecentNudge(input: {
teamName: string;
memberName: string;
taskIds: string[];
nowIso: string;
}): Promise<boolean>;
}
export interface MemberWorkSyncBusySignalPort {
isBusy(input: {
teamName: string;
memberName: string;
nowIso: string;
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
}
export interface MemberWorkSyncUseCaseDeps {
clock: MemberWorkSyncClockPort;
hash: MemberWorkSyncHashPort;
agendaSource: MemberWorkSyncAgendaSourcePort;
statusStore: MemberWorkSyncStatusStorePort;
reportStore?: MemberWorkSyncReportStorePort;
outboxStore?: MemberWorkSyncOutboxStorePort;
inboxNudge?: MemberWorkSyncInboxNudgePort;
watchdogCooldown?: MemberWorkSyncWatchdogCooldownPort;
busySignal?: MemberWorkSyncBusySignalPort;
reportToken?: MemberWorkSyncReportTokenPort;
auditJournal?: MemberWorkSyncAuditJournalPort;
lifecycle?: MemberWorkSyncLifecyclePort;
logger?: MemberWorkSyncLoggerPort;
}
export interface LatestAcceptedReportLookup {
latestAcceptedReport?: MemberWorkSyncReport;
}

View file

@ -0,0 +1,194 @@
import {
buildAgendaFingerprintPayload,
canonicalizeAgendaFingerprintPayload,
formatAgendaFingerprint,
} from './AgendaFingerprint';
import { resolveCurrentReviewOwner, type ReviewHistoryEventLike } from './currentReviewCycle';
import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName';
import type {
MemberWorkSyncActionableWorkItem,
MemberWorkSyncAgenda,
MemberWorkSyncProviderId,
} from '../../contracts';
export interface MemberWorkSyncTaskLike {
id: string;
displayId?: string;
subject?: string;
status: string;
owner?: string | null;
reviewState?: string | null;
needsClarification?: 'lead' | 'user' | null;
blockedBy?: string[];
blocks?: string[];
deletedAt?: string | null;
historyEvents?: ReviewHistoryEventLike[];
}
export interface MemberWorkSyncMemberLike {
name: string;
providerId?: MemberWorkSyncProviderId | string;
model?: string;
agentType?: string;
removedAt?: string | null;
}
export interface BuildActionableWorkAgendaInput {
teamName: string;
memberName: string;
generatedAt: string;
tasks: MemberWorkSyncTaskLike[];
members: MemberWorkSyncMemberLike[];
kanbanReviewersByTaskId?: Record<string, string | null | undefined>;
sourceRevision?: string;
hash: (canonicalPayload: string) => string;
}
function isCompletedOrDeleted(task: MemberWorkSyncTaskLike): boolean {
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
}
function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string> {
return new Set(
members
.filter((member) => !member.removedAt)
.map((member) => normalizeMemberName(member.name))
.filter((name) => name.length > 0 && !isReservedMemberName(name))
);
}
function buildBaseItem(
task: MemberWorkSyncTaskLike,
memberName: string
): Omit<MemberWorkSyncActionableWorkItem, 'kind' | 'priority' | 'reason' | 'evidence'> {
return {
taskId: task.id,
...(task.displayId ? { displayId: task.displayId } : {}),
subject: task.subject?.trim() || 'Untitled task',
assignee: memberName,
};
}
export function buildActionableWorkAgenda(
input: BuildActionableWorkAgendaInput
): MemberWorkSyncAgenda {
const memberName = normalizeMemberName(input.memberName);
const diagnostics: string[] = [];
const activeMemberNames = getActiveMemberNames(input.members);
if (!memberName || isReservedMemberName(memberName)) {
diagnostics.push('member_invalid_or_reserved');
} else if (!activeMemberNames.has(memberName)) {
diagnostics.push('member_not_active');
}
const items: MemberWorkSyncActionableWorkItem[] = [];
if (activeMemberNames.has(memberName)) {
for (const task of input.tasks) {
if (!task.id || isCompletedOrDeleted(task)) {
continue;
}
const owner = normalizeMemberName(task.owner);
const base = buildBaseItem(task, memberName);
const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort();
const blocks = [...(task.blocks ?? [])].filter(Boolean).sort();
const reviewOwner = resolveCurrentReviewOwner({
reviewState: task.reviewState,
kanbanReviewer: input.kanbanReviewersByTaskId?.[task.id] ?? null,
historyEvents: task.historyEvents,
});
if (reviewOwner && sameMemberName(reviewOwner.reviewer, memberName)) {
items.push({
...base,
kind: 'review',
priority: 'review_requested',
reason: 'current_cycle_review_assigned',
evidence: {
status: task.status,
...(owner ? { owner } : {}),
reviewer: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}),
...(reviewOwner.historyEventIds.length > 0
? { historyEventIds: reviewOwner.historyEventIds }
: {}),
},
});
continue;
}
if (!sameMemberName(owner, memberName)) {
continue;
}
if (task.needsClarification === 'lead' || task.needsClarification === 'user') {
items.push({
...base,
kind: 'clarification',
priority: 'needs_clarification',
reason: `task_needs_${task.needsClarification}_clarification`,
evidence: {
status: task.status,
owner: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}),
needsClarification: task.needsClarification,
},
});
continue;
}
if (blockedBy.length > 0) {
items.push({
...base,
kind: 'blocked_dependency',
priority: 'blocked',
reason: 'owned_task_has_blocked_dependency',
evidence: {
status: task.status,
owner: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}),
blockedByTaskIds: blockedBy,
...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}),
},
});
continue;
}
if (task.status === 'pending' || task.status === 'in_progress') {
items.push({
...base,
kind: 'work',
priority: 'normal',
reason: task.status === 'pending' ? 'owned_pending_task' : 'owned_in_progress_task',
evidence: {
status: task.status,
owner: memberName,
...(task.reviewState ? { reviewState: task.reviewState } : {}),
},
});
}
}
}
const payload = buildAgendaFingerprintPayload({
teamName: input.teamName,
memberName,
items,
sourceRevision: input.sourceRevision,
});
const canonicalPayload = canonicalizeAgendaFingerprintPayload(payload);
return {
teamName: input.teamName,
memberName,
generatedAt: input.generatedAt,
fingerprint: formatAgendaFingerprint(input.hash(canonicalPayload)),
items,
diagnostics,
...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}),
};
}

View file

@ -0,0 +1,80 @@
import type { MemberWorkSyncActionableWorkItem } from '../../contracts';
export const MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX = 'agenda:v1:';
function stableJson(value: unknown): string {
if (value == null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableJson(item)).join(',')}]`;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`;
}
export interface AgendaFingerprintPayload {
version: 1;
teamName: string;
memberName: string;
items: {
taskId: string;
displayId?: string;
subject: string;
kind: string;
assignee: string;
priority: string;
evidence: MemberWorkSyncActionableWorkItem['evidence'];
}[];
sourceRevision?: string;
}
export function buildAgendaFingerprintPayload(input: {
teamName: string;
memberName: string;
items: MemberWorkSyncActionableWorkItem[];
sourceRevision?: string;
}): AgendaFingerprintPayload {
return {
version: 1,
teamName: input.teamName,
memberName: input.memberName,
...(input.sourceRevision ? { sourceRevision: input.sourceRevision } : {}),
items: [...input.items]
.sort((left, right) => {
const leftKey = `${left.kind}:${left.taskId}:${left.displayId ?? ''}`;
const rightKey = `${right.kind}:${right.taskId}:${right.displayId ?? ''}`;
return leftKey.localeCompare(rightKey);
})
.map((item) => ({
taskId: item.taskId,
...(item.displayId ? { displayId: item.displayId } : {}),
subject: item.subject,
kind: item.kind,
assignee: item.assignee,
priority: item.priority,
evidence: {
...item.evidence,
...(item.evidence.blockerTaskIds
? { blockerTaskIds: [...item.evidence.blockerTaskIds].sort() }
: {}),
...(item.evidence.blockedByTaskIds
? { blockedByTaskIds: [...item.evidence.blockedByTaskIds].sort() }
: {}),
...(item.evidence.historyEventIds
? { historyEventIds: [...item.evidence.historyEventIds].sort() }
: {}),
},
})),
};
}
export function canonicalizeAgendaFingerprintPayload(payload: AgendaFingerprintPayload): string {
return stableJson(payload);
}
export function formatAgendaFingerprint(hashHex: string): string {
return `${MEMBER_WORK_SYNC_AGENDA_FINGERPRINT_PREFIX}${hashHex}`;
}

View file

@ -0,0 +1,105 @@
import type {
MemberWorkSyncNudgePayload,
MemberWorkSyncOutboxEnsureInput,
MemberWorkSyncStatus,
} from '../../contracts';
export const MEMBER_WORK_SYNC_NUDGE_ID_PREFIX = 'member-work-sync';
interface MemberWorkSyncNudgeHash {
sha256Hex(value: string): string;
}
function stableJson(value: unknown): string {
if (value == null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(stableJson).join(',')}]`;
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
.join(',')}}`;
}
export function buildMemberWorkSyncNudgeId(input: {
teamName: string;
memberName: string;
agendaFingerprint: string;
}): string {
return [
MEMBER_WORK_SYNC_NUDGE_ID_PREFIX,
input.teamName,
input.memberName.trim().toLowerCase(),
input.agendaFingerprint,
].join(':');
}
export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload {
const taskRefs = status.agenda.items.map((item) => ({
teamName: status.teamName,
taskId: item.taskId,
displayId: item.displayId ?? item.taskId.slice(0, 8),
}));
const preview = status.agenda.items
.slice(0, 3)
.map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`)
.join('; ');
return {
from: 'system',
to: status.memberName,
messageKind: 'member_work_sync_nudge',
source: 'member-work-sync',
actionMode: 'do',
taskRefs,
text: [
'Work sync check: you have current actionable work assigned.',
preview ? `Current agenda: ${preview}.` : '',
'Continue concrete task work, report a real blocker with task tools, or call member_work_sync_report for the current fingerprint.',
'Do not reply only with acknowledgement.',
]
.filter(Boolean)
.join('\n'),
};
}
export function buildMemberWorkSyncNudgePayloadHash(
hash: MemberWorkSyncNudgeHash,
payload: MemberWorkSyncNudgePayload
): string {
return hash.sha256Hex(stableJson(payload));
}
export function buildMemberWorkSyncOutboxEnsureInput(input: {
status: MemberWorkSyncStatus;
hash: MemberWorkSyncNudgeHash;
nowIso: string;
}): MemberWorkSyncOutboxEnsureInput | null {
const status = input.status;
if (
status.state !== 'needs_sync' ||
status.shadow?.wouldNudge !== true ||
status.agenda.items.length === 0
) {
return null;
}
const payload = buildMemberWorkSyncNudgePayload(status);
return {
id: buildMemberWorkSyncNudgeId({
teamName: status.teamName,
memberName: status.memberName,
agendaFingerprint: status.agenda.fingerprint,
}),
teamName: status.teamName,
memberName: status.memberName,
agendaFingerprint: status.agenda.fingerprint,
payloadHash: buildMemberWorkSyncNudgePayloadHash(input.hash, payload),
payload,
nowIso: input.nowIso,
};
}

View file

@ -0,0 +1,131 @@
import type {
MemberWorkSyncMetricEvent,
MemberWorkSyncPhase2ReadinessAssessment,
MemberWorkSyncPhase2ReadinessReason,
MemberWorkSyncPhase2ReadinessThresholds,
} from '../../contracts';
export const DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS: MemberWorkSyncPhase2ReadinessThresholds =
{
minObservedMembers: 1,
minStatusEvents: 20,
minObservationHours: 1,
maxWouldNudgesPerMemberHour: 2,
maxFingerprintChangesPerMemberHour: 1,
maxReportRejectionRate: 0.2,
};
interface AssessMemberWorkSyncPhase2ReadinessInput {
memberCount: number;
recentEvents: MemberWorkSyncMetricEvent[];
thresholds?: Partial<MemberWorkSyncPhase2ReadinessThresholds>;
}
function parseTime(value: string): number | null {
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : null;
}
function getObservationHours(events: MemberWorkSyncMetricEvent[]): number {
const times = events.flatMap((event) => {
const time = parseTime(event.recordedAt);
return time == null ? [] : [time];
});
if (times.length < 2) {
return 0;
}
const min = Math.min(...times);
const max = Math.max(...times);
return Math.max(0, (max - min) / 3_600_000);
}
function roundRate(value: number): number {
return Math.round(value * 1000) / 1000;
}
function pushIf(
reasons: MemberWorkSyncPhase2ReadinessReason[],
condition: boolean,
reason: MemberWorkSyncPhase2ReadinessReason
): void {
if (condition) {
reasons.push(reason);
}
}
export function assessMemberWorkSyncPhase2Readiness({
memberCount,
recentEvents,
thresholds: thresholdOverrides,
}: AssessMemberWorkSyncPhase2ReadinessInput): MemberWorkSyncPhase2ReadinessAssessment {
const thresholds = {
...DEFAULT_MEMBER_WORK_SYNC_PHASE2_READINESS_THRESHOLDS,
...thresholdOverrides,
};
const statusEvents = recentEvents.filter((event) => event.kind === 'status_evaluated');
const wouldNudgeEvents = recentEvents.filter((event) => event.kind === 'would_nudge');
const fingerprintChangeEvents = recentEvents.filter(
(event) => event.kind === 'fingerprint_changed'
);
const reportAcceptedEvents = recentEvents.filter((event) => event.kind === 'report_accepted');
const reportRejectedEvents = recentEvents.filter((event) => event.kind === 'report_rejected');
const observationHours = getObservationHours(recentEvents);
const memberHourDenominator = Math.max(memberCount, 1) * Math.max(observationHours, 1 / 60);
const wouldNudgesPerMemberHour = wouldNudgeEvents.length / memberHourDenominator;
const fingerprintChangesPerMemberHour = fingerprintChangeEvents.length / memberHourDenominator;
const reportEventCount = reportAcceptedEvents.length + reportRejectedEvents.length;
const reportRejectionRate =
reportEventCount > 0 ? reportRejectedEvents.length / reportEventCount : 0;
const collectingReasons: MemberWorkSyncPhase2ReadinessReason[] = [];
pushIf(collectingReasons, memberCount < thresholds.minObservedMembers, 'insufficient_members');
pushIf(
collectingReasons,
statusEvents.length < thresholds.minStatusEvents,
'insufficient_status_events'
);
pushIf(
collectingReasons,
observationHours < thresholds.minObservationHours,
'insufficient_observation_window'
);
const blockingReasons: MemberWorkSyncPhase2ReadinessReason[] = [];
pushIf(
blockingReasons,
wouldNudgesPerMemberHour > thresholds.maxWouldNudgesPerMemberHour,
'would_nudge_rate_high'
);
pushIf(
blockingReasons,
fingerprintChangesPerMemberHour > thresholds.maxFingerprintChangesPerMemberHour,
'fingerprint_churn_high'
);
pushIf(
blockingReasons,
reportRejectionRate > thresholds.maxReportRejectionRate,
'report_rejection_rate_high'
);
const state =
collectingReasons.length > 0
? 'collecting_shadow_data'
: blockingReasons.length > 0
? 'blocked'
: 'shadow_ready';
const reasons = [...collectingReasons, ...blockingReasons];
return {
state,
reasons,
thresholds,
rates: {
observationHours: roundRate(observationHours),
statusEventCount: statusEvents.length,
wouldNudgesPerMemberHour: roundRate(wouldNudgesPerMemberHour),
fingerprintChangesPerMemberHour: roundRate(fingerprintChangesPerMemberHour),
reportRejectionRate: roundRate(reportRejectionRate),
},
diagnostics: reasons.map((reason) => `phase2_readiness:${reason}`),
};
}

View file

@ -0,0 +1,134 @@
import { isReservedMemberName, normalizeMemberName, sameMemberName } from './memberName';
import type {
MemberWorkSyncAgenda,
MemberWorkSyncReportRequest,
MemberWorkSyncReportState,
} from '../../contracts';
export interface MemberWorkSyncReportValidation {
ok: boolean;
code: string;
message: string;
expiresAt?: string;
}
export type MemberWorkSyncReportTokenValidation =
| { ok: true }
| { ok: false; reason: 'missing' | 'expired' | 'invalid' };
const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000;
const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000;
const MIN_LEASE_MS = 60_000;
const MAX_LEASE_MS = 60 * 60 * 1000;
function clampLeaseTtlMs(
value: number | undefined,
state: MemberWorkSyncReportState
): number | undefined {
if (state === 'caught_up') {
return undefined;
}
const fallback = state === 'blocked' ? DEFAULT_BLOCKED_LEASE_MS : DEFAULT_STILL_WORKING_LEASE_MS;
const numeric = Number.isFinite(value) ? Math.floor(Number(value)) : fallback;
return Math.min(MAX_LEASE_MS, Math.max(MIN_LEASE_MS, numeric));
}
function agendaHasBlockedEvidence(
agenda: MemberWorkSyncAgenda,
taskIds: string[] | undefined
): boolean {
const targetIds = new Set((taskIds ?? []).filter(Boolean));
return agenda.items.some((item) => {
if (targetIds.size > 0 && !targetIds.has(item.taskId)) {
return false;
}
return item.kind === 'blocked_dependency' || item.priority === 'blocked';
});
}
export function validateMemberWorkSyncReport(input: {
request: MemberWorkSyncReportRequest;
agenda: MemberWorkSyncAgenda;
nowIso: string;
activeMemberNames: string[];
tokenValidation: MemberWorkSyncReportTokenValidation;
}): MemberWorkSyncReportValidation {
const memberName = normalizeMemberName(input.request.memberName);
const activeMemberNames = new Set(input.activeMemberNames.map(normalizeMemberName));
if (!memberName || isReservedMemberName(memberName)) {
return { ok: false, code: 'reserved_or_invalid_member', message: 'Invalid member identity.' };
}
if (!sameMemberName(memberName, input.agenda.memberName)) {
return {
ok: false,
code: 'identity_mismatch',
message: 'Report member does not match agenda.',
};
}
if (!activeMemberNames.has(memberName)) {
return { ok: false, code: 'member_inactive', message: 'Member is not active in this team.' };
}
if (input.request.agendaFingerprint !== input.agenda.fingerprint) {
return {
ok: false,
code: 'stale_fingerprint',
message: 'Report fingerprint is stale. Read current member work sync status and retry.',
};
}
if (!input.tokenValidation.ok) {
return input.tokenValidation.reason === 'missing'
? {
ok: false,
code: 'identity_untrusted',
message: 'Report token is required. Read current member work sync status and retry.',
}
: {
ok: false,
code: 'invalid_report_token',
message:
'Report token is invalid or expired. Read current member work sync status and retry.',
};
}
const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId));
for (const taskId of input.request.taskIds ?? []) {
if (!agendaTaskIds.has(taskId)) {
return {
ok: false,
code: 'foreign_task_id',
message: `Task ${taskId} is not in the current actionable agenda.`,
};
}
}
if (input.request.state === 'caught_up' && input.agenda.items.length > 0) {
return {
ok: false,
code: 'caught_up_rejected_actionable_items_exist',
message: 'Cannot report caught_up while actionable work remains.',
};
}
if (
input.request.state === 'blocked' &&
!agendaHasBlockedEvidence(input.agenda, input.request.taskIds)
) {
return {
ok: false,
code: 'blocked_without_evidence',
message: 'Blocked report requires current blocker evidence in the task board.',
};
}
const leaseTtlMs = clampLeaseTtlMs(input.request.leaseTtlMs, input.request.state);
return {
ok: true,
code: 'accepted',
message: 'Member work sync report accepted.',
...(leaseTtlMs
? { expiresAt: new Date(Date.parse(input.nowIso) + leaseTtlMs).toISOString() }
: {}),
};
}

View file

@ -0,0 +1,36 @@
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;
teamName?: string;
memberName?: string;
agentId?: string;
threadId?: string;
outcome?: 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' | 'opencode';
export function isRuntimeTurnSettledProvider(value: unknown): value is RuntimeTurnSettledProvider {
return value === 'claude' || value === 'codex' || value === 'opencode';
}

View file

@ -0,0 +1,52 @@
import type {
MemberWorkSyncAgenda,
MemberWorkSyncReport,
MemberWorkSyncStatusState,
} from '../../contracts';
export interface SyncDecision {
state: MemberWorkSyncStatusState;
acceptedReport?: MemberWorkSyncReport;
diagnostics: string[];
}
export function decideMemberWorkSyncStatus(input: {
agenda: MemberWorkSyncAgenda;
latestAcceptedReport?: MemberWorkSyncReport | null;
nowIso: string;
inactive?: boolean;
}): SyncDecision {
if (input.inactive) {
return { state: 'inactive', diagnostics: ['member_or_team_inactive'] };
}
if (input.agenda.items.length === 0) {
return {
state: 'caught_up',
diagnostics: ['agenda_empty'],
acceptedReport:
input.latestAcceptedReport?.agendaFingerprint === input.agenda.fingerprint
? input.latestAcceptedReport
: undefined,
};
}
const report = input.latestAcceptedReport ?? null;
if (!report) {
return { state: 'needs_sync', diagnostics: ['no_current_report'] };
}
if (report.agendaFingerprint !== input.agenda.fingerprint) {
return { state: 'needs_sync', diagnostics: ['report_fingerprint_stale'] };
}
if (report.expiresAt && Date.parse(report.expiresAt) <= Date.parse(input.nowIso)) {
return { state: 'needs_sync', diagnostics: ['report_lease_expired'] };
}
if (report.state === 'still_working') {
return { state: 'still_working', acceptedReport: report, diagnostics: ['lease_still_working'] };
}
if (report.state === 'blocked') {
return { state: 'blocked', acceptedReport: report, diagnostics: ['lease_blocked'] };
}
return { state: 'needs_sync', diagnostics: ['caught_up_report_not_valid_for_non_empty_agenda'] };
}

View file

@ -0,0 +1,75 @@
import { normalizeMemberName } from './memberName';
export interface ReviewHistoryEventLike {
id?: string;
type: string;
timestamp?: string;
actor?: string;
reviewer?: string;
}
export interface CurrentReviewOwner {
reviewer: string;
historyEventIds: string[];
}
function compareEventsByTimestamp(
left: ReviewHistoryEventLike,
right: ReviewHistoryEventLike
): number {
const leftTime = Date.parse(left.timestamp ?? '');
const rightTime = Date.parse(right.timestamp ?? '');
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
export function resolveCurrentReviewOwner(input: {
reviewState?: string | null;
kanbanReviewer?: string | null;
historyEvents?: ReviewHistoryEventLike[];
}): CurrentReviewOwner | null {
if (input.reviewState !== 'review') {
return null;
}
const historyEvents = [...(input.historyEvents ?? [])]
.filter((event) =>
[
'review_requested',
'review_started',
'review_approved',
'review_changes_requested',
].includes(event.type)
)
.sort(compareEventsByTimestamp);
const latest = historyEvents.at(-1);
if (latest?.type === 'review_approved' || latest?.type === 'review_changes_requested') {
return null;
}
const latestStarted = [...historyEvents]
.reverse()
.find((event) => event.type === 'review_started');
const latestRequested = [...historyEvents]
.reverse()
.find((event) => event.type === 'review_requested');
const reviewer =
normalizeMemberName(latestStarted?.actor) ||
normalizeMemberName(latestRequested?.reviewer) ||
normalizeMemberName(input.kanbanReviewer);
if (!reviewer) {
return null;
}
return {
reviewer,
historyEventIds: [latestStarted?.id, latestRequested?.id].filter(
(id): id is string => typeof id === 'string' && id.length > 0
),
};
}

View file

@ -0,0 +1,10 @@
export * from './ActionableWorkAgenda';
export * from './AgendaFingerprint';
export * from './currentReviewCycle';
export * from './memberName';
export * from './MemberWorkSyncNudge';
export * from './MemberWorkSyncPhase2Readiness';
export * from './MemberWorkSyncReportValidator';
export * from './RuntimeTurnSettledEvent';
export * from './RuntimeTurnSettledProvider';
export * from './SyncDecisionPolicy';

View file

@ -0,0 +1,15 @@
const RESERVED_MEMBER_NAMES = new Set(['', 'user', 'system']);
export function normalizeMemberName(value: unknown): string {
return typeof value === 'string' ? value.trim().toLowerCase() : '';
}
export function isReservedMemberName(value: unknown): boolean {
return RESERVED_MEMBER_NAMES.has(normalizeMemberName(value));
}
export function sameMemberName(left: unknown, right: unknown): boolean {
const normalizedLeft = normalizeMemberName(left);
const normalizedRight = normalizeMemberName(right);
return normalizedLeft.length > 0 && normalizedLeft === normalizedRight;
}

View file

@ -0,0 +1 @@
export * from './contracts';

View file

@ -0,0 +1,155 @@
import type {
MemberWorkSyncEventQueue,
MemberWorkSyncTriggerReason,
} 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[]>;
}
interface MemberWorkSyncMemberStorageMaterializer {
materializeMember(teamName: string, memberName: string): Promise<void>;
}
const TEAM_WIDE_REASONS: Partial<Record<TeamChangeEvent['type'], MemberWorkSyncTriggerReason>> = {
config: 'config_changed',
task: 'task_changed',
'task-log-change': 'runtime_activity',
'log-source-change': 'runtime_activity',
process: 'runtime_activity',
'lead-activity': 'runtime_activity',
};
function parseInboxRecipient(detail: string | undefined): string | null {
if (!detail) {
return null;
}
const match = /^inboxes\/(.+)\.json$/.exec(detail);
return match?.[1]?.trim() || null;
}
function parseToolActivity(detail: string | undefined): ToolActivityEventPayload | null {
if (!detail) {
return null;
}
try {
const parsed = JSON.parse(detail) as ToolActivityEventPayload;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
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,
private readonly queue: MemberWorkSyncEventQueue,
private readonly materializer?: MemberWorkSyncMemberStorageMaterializer
) {}
async enqueueStartupScan(teamNames: string[]): Promise<void> {
await Promise.allSettled(
teamNames.map((teamName) => this.enqueueTeam(teamName, 'startup_scan', 30_000))
);
}
noteTeamChange(event: TeamChangeEvent): void {
if (event.type === 'lead-activity' && event.detail === 'offline') {
this.queue.dropTeam(event.teamName);
return;
}
if (event.type === 'member-spawn') {
const memberName = event.detail?.trim();
if (memberName) {
this.queue.enqueue({
teamName: event.teamName,
memberName,
triggerReason: 'member_spawned',
runAfterMs: 30_000,
});
} else {
void this.enqueueTeam(event.teamName, 'member_spawned', 30_000).catch(() => undefined);
}
return;
}
if (event.type === 'tool-activity') {
const payload = parseToolActivity(event.detail);
if (payload?.action === 'finish' && payload.memberName) {
this.queue.enqueue({
teamName: event.teamName,
memberName: payload.memberName,
triggerReason: 'tool_finished',
});
}
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) {
this.queue.enqueue({
teamName: event.teamName,
memberName: recipient,
triggerReason: 'inbox_changed',
});
}
return;
}
const teamWideReason = TEAM_WIDE_REASONS[event.type];
if (teamWideReason) {
void this.enqueueTeam(event.teamName, teamWideReason).catch(() => undefined);
}
}
private async enqueueTeam(
teamName: string,
triggerReason: MemberWorkSyncTriggerReason,
runAfterMs?: number
): Promise<void> {
const activeMembers = await this.rosterSource.loadActiveMemberNames(teamName);
const materializer = this.materializer;
if (materializer) {
await Promise.allSettled(
activeMembers.map((memberName) => materializer.materializeMember(teamName, memberName))
);
}
for (const memberName of activeMembers) {
this.queue.enqueue({ teamName, memberName, triggerReason, runAfterMs });
}
}
}

View file

@ -0,0 +1,65 @@
import { createLogger } from '@shared/utils/logger';
import {
MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REPORT,
type MemberWorkSyncMetricsRequest,
type MemberWorkSyncReportRequest,
type MemberWorkSyncReportResult,
type MemberWorkSyncStatus,
type MemberWorkSyncStatusRequest,
type MemberWorkSyncTeamMetrics,
} from '../../../contracts';
import type { MemberWorkSyncFeatureFacade } from '../../composition/createMemberWorkSyncFeature';
import type { IpcMain } from 'electron';
const logger = createLogger('Feature:MemberWorkSync:IPC');
export function registerMemberWorkSyncIpc(
ipcMain: IpcMain,
feature: MemberWorkSyncFeatureFacade
): void {
ipcMain.handle(
MEMBER_WORK_SYNC_GET_STATUS,
async (_event, request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> => {
try {
return await feature.getStatus(request);
} catch (error) {
logger.error('Failed to get member work sync status', error);
throw error;
}
}
);
ipcMain.handle(
MEMBER_WORK_SYNC_GET_METRICS,
async (_event, request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics> => {
try {
return await feature.getMetrics(request);
} catch (error) {
logger.error('Failed to get member work sync metrics', error);
throw error;
}
}
);
ipcMain.handle(
MEMBER_WORK_SYNC_REPORT,
async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => {
try {
return await feature.report(request);
} catch (error) {
logger.error('Failed to submit member work sync report', error);
throw error;
}
}
);
}
export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS);
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS);
ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT);
}

View file

@ -0,0 +1,37 @@
import { TeamInboxReader } from '@main/services/team/TeamInboxReader';
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import type { MemberWorkSyncInboxNudgePort } from '../../../core/application';
export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudgePort {
constructor(
private readonly inboxReader: Pick<TeamInboxReader, 'getMessagesFor'> = new TeamInboxReader(),
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter()
) {}
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
if (existing.some((message) => message.messageId === input.messageId)) {
return { inserted: false, messageId: input.messageId };
}
const result = await this.inboxWriter.sendMessage(input.teamName, {
member: input.memberName,
from: input.payload.from,
to: input.payload.to,
messageId: input.messageId,
timestamp: input.timestamp,
text: input.payload.text,
taskRefs: input.payload.taskRefs,
actionMode: input.payload.actionMode,
summary: 'Work sync check',
source: 'system_notification',
messageKind: input.payload.messageKind,
});
return {
inserted: true,
messageId: result.messageId,
};
}
}

View file

@ -0,0 +1,210 @@
import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import path from 'path';
import { isReservedMemberName, normalizeMemberName } from '../../../core/domain';
import type {
RuntimeTurnSettledTargetResolution,
RuntimeTurnSettledTargetResolverPort,
} from '../../../core/application';
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 === 'codex') {
return this.resolveProviderOwnedEvent(event, 'codex');
}
if (event.provider === 'opencode') {
return this.resolveProviderOwnedEvent(event, 'opencode');
}
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: {
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 resolveProviderOwnedEvent(
event: RuntimeTurnSettledEvent,
expectedProviderId: 'codex' | 'opencode'
): Promise<RuntimeTurnSettledTargetResolution> {
const teamName = event.teamName?.trim();
const memberName = event.memberName?.trim();
if (!teamName || !memberName) {
return { ok: false, reason: 'missing_team_member_identity' };
}
const member = await this.resolveActiveMember(teamName, 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 !== expectedProviderId) {
return { ok: false, reason: 'provider_mismatch' };
}
return {
ok: true,
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

@ -0,0 +1,141 @@
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import {
buildActionableWorkAgenda,
isReservedMemberName,
type MemberWorkSyncMemberLike,
normalizeMemberName,
} from '../../../core/domain';
import type {
MemberWorkSyncAgendaSourcePort,
MemberWorkSyncAgendaSourceResult,
MemberWorkSyncHashPort,
} from '../../../core/application';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamMember } from '@shared/types';
export interface TeamTaskAgendaSourceDeps {
configReader: TeamConfigReader;
taskReader: TeamTaskReader;
kanbanManager: TeamKanbanManager;
membersMetaStore: TeamMembersMetaStore;
hash: MemberWorkSyncHashPort;
clock: { now(): Date };
}
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 toMemberLike(member: TeamMember): MemberWorkSyncMemberLike {
const providerId =
normalizeOptionalTeamProviderId(member.providerId) ??
inferTeamProviderIdFromModel(member.model);
return {
name: member.name,
...(providerId ? { providerId } : {}),
...(member.model ? { model: member.model } : {}),
...(member.agentType ? { agentType: member.agentType } : {}),
...(member.removedAt ? { removedAt: String(member.removedAt) } : {}),
};
}
export class TeamTaskAgendaSource implements MemberWorkSyncAgendaSourcePort {
constructor(private readonly deps: TeamTaskAgendaSourceDeps) {}
async loadActiveMemberNames(teamName: string): Promise<string[]> {
const config = await this.deps.configReader.getConfig(teamName);
if (!config || config.deletedAt) {
return [];
}
const metaMembers = await this.deps.membersMetaStore.getMembers(teamName);
return mergeMembers(config.members ?? [], metaMembers)
.filter((member) => !member.removedAt)
.map((member) => normalizeMemberName(member.name))
.filter((memberName) => memberName.length > 0 && !isReservedMemberName(memberName))
.sort((left, right) => left.localeCompare(right));
}
async loadAgenda(input: {
teamName: string;
memberName: string;
}): Promise<MemberWorkSyncAgendaSourceResult> {
const config = await this.deps.configReader.getConfig(input.teamName);
if (!config || config.deletedAt) {
const nowIso = this.deps.clock.now().toISOString();
return {
agenda: {
teamName: input.teamName,
memberName: normalizeMemberName(input.memberName),
generatedAt: nowIso,
items: [],
diagnostics: config?.deletedAt ? ['team_deleted'] : ['team_config_missing'],
},
activeMemberNames: [],
inactive: true,
diagnostics: [],
};
}
const [tasks, kanban, metaMembers] = await Promise.all([
this.deps.taskReader.getTasks(input.teamName),
this.deps.kanbanManager.getState(input.teamName),
this.deps.membersMetaStore.getMembers(input.teamName),
]);
const members = mergeMembers(config.members ?? [], metaMembers);
const activeMemberNames = members
.filter((member) => !member.removedAt)
.map((member) => normalizeMemberName(member.name))
.filter(Boolean);
const normalizedMemberName = normalizeMemberName(input.memberName);
const member = members.find((candidate) => memberKey(candidate) === normalizedMemberName);
const providerId =
normalizeOptionalTeamProviderId(member?.providerId) ??
inferTeamProviderIdFromModel(member?.model);
const agenda = buildActionableWorkAgenda({
teamName: input.teamName,
memberName: input.memberName,
generatedAt: this.deps.clock.now().toISOString(),
tasks,
members: members.map(toMemberLike),
kanbanReviewersByTaskId: Object.fromEntries(
Object.entries(kanban.tasks).map(([taskId, value]) => [taskId, value.reviewer ?? null])
),
hash: this.deps.hash.sha256Hex.bind(this.deps.hash),
});
return {
agenda,
activeMemberNames,
inactive: !activeMemberNames.includes(normalizedMemberName),
...(providerId ? { providerId } : {}),
diagnostics: [],
};
}
}

View file

@ -0,0 +1,61 @@
import { readFile } from 'fs/promises';
import { join } from 'path';
import type { MemberWorkSyncWatchdogCooldownPort } from '../../../core/application';
const DEFAULT_WATCHDOG_COOLDOWN_MS = 10 * 60_000;
interface StallJournalEntry {
taskId: string;
state: string;
alertedAt?: string;
}
function parseTime(value: string | undefined): number | null {
if (!value) {
return null;
}
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : null;
}
export class TeamTaskStallJournalWorkSyncCooldown implements MemberWorkSyncWatchdogCooldownPort {
constructor(
private readonly teamsBasePath: string,
private readonly cooldownMs: number = DEFAULT_WATCHDOG_COOLDOWN_MS
) {}
async hasRecentNudge(input: {
teamName: string;
memberName: string;
taskIds: string[];
nowIso: string;
}): Promise<boolean> {
const taskIds = new Set(input.taskIds);
if (taskIds.size === 0) {
return false;
}
try {
const raw = await readFile(
join(this.teamsBasePath, input.teamName, 'stall-monitor-journal.json'),
'utf8'
);
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return true;
}
const now = parseTime(input.nowIso) ?? Date.now();
return parsed.some((entry): boolean => {
const row = entry as Partial<StallJournalEntry>;
if (row.state !== 'alerted' || !row.taskId || !taskIds.has(row.taskId)) {
return false;
}
const alertedAt = parseTime(row.alertedAt);
return alertedAt != null && now - alertedAt <= this.cooldownMs;
});
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ENOENT';
}
}
}

View file

@ -0,0 +1,287 @@
import {
MemberWorkSyncDiagnosticsReader,
MemberWorkSyncMetricsReader,
MemberWorkSyncNudgeDispatcher,
type MemberWorkSyncNudgeDispatchSummary,
MemberWorkSyncPendingReportIntentReplayer,
type MemberWorkSyncPendingReportReplaySummary,
type MemberWorkSyncReconcileContext,
MemberWorkSyncReconciler,
MemberWorkSyncReporter,
type RuntimeTurnSettledDrainSummary,
RuntimeTurnSettledIngestor,
type RuntimeTurnSettledTargetResolverPort,
} from '../../core/application';
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTaskStallJournalWorkSyncCooldown';
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore';
import {
MemberWorkSyncEventQueue,
type MemberWorkSyncQueueDiagnostics,
} from '../infrastructure/MemberWorkSyncEventQueue';
import { MemberWorkSyncNudgeDispatchScheduler } from '../infrastructure/MemberWorkSyncNudgeDispatchScheduler';
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 { RuntimeTurnSettledSpoolInitializer } from '../infrastructure/RuntimeTurnSettledSpoolInitializer';
import { SystemClockAdapter } from '../infrastructure/SystemClockAdapter';
import type {
MemberWorkSyncMetricsRequest,
MemberWorkSyncReportRequest,
MemberWorkSyncReportResult,
MemberWorkSyncStatus,
MemberWorkSyncStatusRequest,
MemberWorkSyncTeamMetrics,
} from '../../contracts';
import type { MemberWorkSyncLoggerPort } from '../../core/application';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamChangeEvent } from '@shared/types';
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 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>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
noteTeamChange(event: TeamChangeEvent): void;
enqueueStartupScan(teamNames: string[]): Promise<void>;
replayPendingReports(teamNames: string[]): Promise<MemberWorkSyncPendingReportReplaySummary>;
dispatchDueNudges(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
buildRuntimeTurnSettledHookSettings(input: {
provider: RuntimeTurnSettledProvider;
}): Promise<Record<string, unknown> | null>;
buildRuntimeTurnSettledEnvironment(input: {
provider: RuntimeTurnSettledProvider;
}): Promise<Record<string, string> | null>;
drainRuntimeTurnSettledEvents(): Promise<RuntimeTurnSettledDrainSummary>;
getQueueDiagnostics(): MemberWorkSyncQueueDiagnostics;
dispose(): Promise<void>;
}
export function createMemberWorkSyncFeature(deps: {
teamsBasePath: string;
configReader: TeamConfigReader;
taskReader: TeamTaskReader;
kanbanManager: TeamKanbanManager;
membersMetaStore: TeamMembersMetaStore;
isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
listLifecycleActiveTeamNames?: () => Promise<string[]>;
nudgeSideEffectsEnabled?: boolean;
queueQuietWindowMs?: number;
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
logger?: MemberWorkSyncLoggerPort;
}): MemberWorkSyncFeatureFacade {
const clock = new SystemClockAdapter();
const hash = new NodeHashAdapter();
const agendaSource = new TeamTaskAgendaSource({
configReader: deps.configReader,
taskReader: deps.taskReader,
kanbanManager: deps.kanbanManager,
membersMetaStore: deps.membersMetaStore,
hash,
clock,
});
const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath);
const auditJournal = new FileMemberWorkSyncAuditJournal(storePaths, deps.logger);
const store = new JsonMemberWorkSyncStore(storePaths, {
auditJournal,
logger: deps.logger,
});
const runtimeTurnSettledSpool = new RuntimeTurnSettledSpoolInitializer(deps.teamsBasePath);
const runtimeTurnSettledStore = new FileRuntimeTurnSettledEventStore({
paths: runtimeTurnSettledSpool.getPaths(),
});
const runtimeTurnSettledNormalizer = new CompositeRuntimeTurnSettledPayloadNormalizer([
new ClaudeStopHookPayloadNormalizer(hash),
new CodexNativeTurnSettledPayloadNormalizer(hash),
new OpenCodeTurnSettledPayloadNormalizer(hash),
]);
const runtimeTurnSettledTargetResolver =
deps.runtimeTurnSettledTargetResolver ??
new TeamRuntimeTurnSettledTargetResolver({
teamSource: deps.configReader,
membersMetaStore: deps.membersMetaStore,
});
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
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,
...(nudgeSideEffectsEnabled ? { outboxStore: store } : {}),
...(inboxNudge ? { inboxNudge } : {}),
watchdogCooldown,
busySignal,
reportToken,
auditJournal,
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
logger: deps.logger,
};
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
const metricsReader = new MemberWorkSyncMetricsReader(useCaseDeps);
const reporter = new MemberWorkSyncReporter(useCaseDeps);
const reconciler = new MemberWorkSyncReconciler(useCaseDeps);
const pendingReportReplayer = new MemberWorkSyncPendingReportIntentReplayer(useCaseDeps);
const nudgeDispatcher = new MemberWorkSyncNudgeDispatcher(useCaseDeps);
const queue = new MemberWorkSyncEventQueue({
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
await reconciler.execute(request, context);
if (nudgeSideEffectsEnabled) {
await nudgeDispatcher.dispatchDue({
teamNames: [request.teamName],
claimedBy: `member-work-sync:${process.pid}`,
});
}
},
isTeamActive: deps.isTeamActive ?? (() => true),
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
auditJournal,
logger: deps.logger,
});
const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue, {
materializeMember: (teamName, memberName) =>
storePaths.ensureMemberWorkSyncDir(teamName, memberName),
});
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,
auditJournal,
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) =>
nudgeDispatcher.dispatchDue({
teamNames,
claimedBy: `member-work-sync:${process.pid}:scheduled`,
}),
logger: deps.logger,
})
: null;
runtimeTurnSettledDrainScheduler.start();
nudgeDispatchScheduler?.start();
return {
getStatus: (request) => diagnosticsReader.execute(request),
getMetrics: (request) => metricsReader.execute(request),
report: (request) => reporter.execute(request),
noteTeamChange: (event) => {
busySignal.noteTeamChange(event);
router.noteTeamChange(event);
},
enqueueStartupScan: (teamNames) => router.enqueueStartupScan(teamNames),
replayPendingReports: async (teamNames) => {
const summaries = await Promise.allSettled(
teamNames.map((teamName) => pendingReportReplayer.replayTeam(teamName))
);
return summaries.reduce<MemberWorkSyncPendingReportReplaySummary>(
(accumulator, summary) => {
if (summary.status !== 'fulfilled') {
return accumulator;
}
accumulator.processed += summary.value.processed;
accumulator.accepted += summary.value.accepted;
accumulator.rejected += summary.value.rejected;
accumulator.superseded += summary.value.superseded;
return accumulator;
},
{ processed: 0, accepted: 0, rejected: 0, superseded: 0 }
);
},
dispatchDueNudges: (teamNames) =>
nudgeSideEffectsEnabled
? nudgeDispatcher.dispatchDue({
teamNames,
claimedBy: `member-work-sync:${process.pid}`,
})
: Promise.resolve(emptyNudgeDispatchSummary()),
buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
runtimeTurnSettledSpool.buildHookSettings({ provider }),
buildRuntimeTurnSettledEnvironment: async ({ provider }) =>
runtimeTurnSettledSpool.buildEnvironment({ provider }),
drainRuntimeTurnSettledEvents: () => runtimeTurnSettledIngestor.drainPending(),
getQueueDiagnostics: () => queue.getDiagnostics(),
dispose: async () => {
runtimeTurnSettledDrainScheduler.dispose();
await Promise.allSettled([queue.stop(), nudgeDispatchScheduler?.dispose()]);
},
};
}

View file

@ -0,0 +1,12 @@
export type { RuntimeTurnSettledProvider } from '../core/domain';
export {
registerMemberWorkSyncIpc,
removeMemberWorkSyncIpc,
} from './adapters/input/registerMemberWorkSyncIpc';
export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWorkSyncFeature';
export {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
resolveMemberWorkSyncNudgeSideEffectsEnabled,
} from './composition/createMemberWorkSyncFeature';

View file

@ -0,0 +1,87 @@
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,112 @@
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 CodexNativeTurnSettledPayloadNormalizer implements RuntimeTurnSettledPayloadNormalizerPort {
constructor(private readonly hash: MemberWorkSyncHashPort) {}
normalize(input: {
provider: RuntimeTurnSettledProvider;
raw: string;
recordedAt: string;
}): RuntimeTurnSettledPayloadNormalization {
if (input.provider !== 'codex') {
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 !== 'codex') {
return { ok: false, reason: 'provider_mismatch' };
}
const source = getString(payload, 'source');
if (source !== 'agent-teams-orchestrator-codex-native') {
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');
const teamName = getString(payload, 'teamName', 'team_name');
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 threadId = getString(payload, 'threadId', 'thread_id');
const turnId = getString(payload, 'turnId', 'turn_id') ?? threadId;
const cwd = getString(payload, 'cwd');
const agentId = getString(payload, 'agentId', 'agent_id');
const outcome = getString(payload, 'outcome');
return {
ok: true,
event: {
schemaVersion: 1,
provider: 'codex',
hookEventName: 'Stop',
payloadHash,
recordedAt: getString(payload, 'recordedAt', 'recorded_at') ?? input.recordedAt,
sourceId: buildRuntimeTurnSettledSourceId({
provider: 'codex',
sessionId,
turnId,
payloadHash,
}),
sessionId,
...(turnId ? { turnId } : {}),
...(cwd ? { cwd } : {}),
teamName,
memberName,
...(agentId ? { agentId } : {}),
...(threadId ? { threadId } : {}),
...(outcome ? { outcome } : {}),
},
};
}
}

View file

@ -0,0 +1,32 @@
import type {
RuntimeTurnSettledPayloadNormalization,
RuntimeTurnSettledPayloadNormalizerPort,
} from '../../core/application';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
export class CompositeRuntimeTurnSettledPayloadNormalizer
implements RuntimeTurnSettledPayloadNormalizerPort
{
constructor(
private readonly normalizers: readonly RuntimeTurnSettledPayloadNormalizerPort[]
) {}
normalize(input: {
provider: RuntimeTurnSettledProvider;
raw: string;
recordedAt: string;
}): RuntimeTurnSettledPayloadNormalization {
let lastUnsupportedReason = 'unsupported_provider';
for (const normalizer of this.normalizers) {
const result = normalizer.normalize(input);
if (result.ok) {
return result;
}
if (result.reason !== 'unsupported_provider') {
return result;
}
lastUnsupportedReason = result.reason;
}
return { ok: false, reason: lastUnsupportedReason };
}
}

View file

@ -0,0 +1,172 @@
import { withFileLock } from '@main/services/team/fileLock';
import { appendFile, mkdir, rename, rm, stat } from 'fs/promises';
import { dirname } from 'path';
import type {
MemberWorkSyncAuditEvent,
MemberWorkSyncAuditJournalPort,
MemberWorkSyncLoggerPort,
} from '../../core/application';
import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths';
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
const DEFAULT_ROTATED_FILE_COUNT = 5;
const MAX_PREVIEW_CHARS = 240;
const MAX_DIAGNOSTICS = 20;
const MAX_TRIGGER_REASONS = 20;
const MAX_TASK_REFS = 20;
const MAX_SHORT_FIELD_CHARS = 240;
interface PersistedAuditEvent extends MemberWorkSyncAuditEvent {
schemaVersion: 1;
}
export interface FileMemberWorkSyncAuditJournalOptions {
maxBytes?: number;
rotatedFileCount?: number;
}
function truncateText(value: string, maxChars: number): string {
return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
}
function sanitizeMetadata(
metadata: MemberWorkSyncAuditEvent['metadata']
): MemberWorkSyncAuditEvent['metadata'] {
if (!metadata) {
return undefined;
}
const sanitized = Object.create(null) as NonNullable<MemberWorkSyncAuditEvent['metadata']>;
for (const [key, value] of Object.entries(metadata)) {
sanitized[truncateText(key, MAX_SHORT_FIELD_CHARS)] =
typeof value === 'string' ? truncateText(value, MAX_SHORT_FIELD_CHARS) : value;
}
return sanitized;
}
function sanitizeTaskRefs(
taskRefs: MemberWorkSyncAuditEvent['taskRefs']
): MemberWorkSyncAuditEvent['taskRefs'] {
return taskRefs?.slice(0, MAX_TASK_REFS).map((taskRef) => ({
taskId: truncateText(taskRef.taskId, MAX_SHORT_FIELD_CHARS),
...(taskRef.displayId
? { displayId: truncateText(taskRef.displayId, MAX_SHORT_FIELD_CHARS) }
: {}),
...(taskRef.teamName
? { teamName: truncateText(taskRef.teamName, MAX_SHORT_FIELD_CHARS) }
: {}),
}));
}
function sanitizeEvent(event: MemberWorkSyncAuditEvent): PersistedAuditEvent {
return {
...event,
schemaVersion: 1,
source: truncateText(event.source, MAX_SHORT_FIELD_CHARS),
...(event.reason ? { reason: truncateText(event.reason, MAX_SHORT_FIELD_CHARS) } : {}),
...(event.providerId
? { providerId: truncateText(event.providerId, MAX_SHORT_FIELD_CHARS) }
: {}),
...(event.state ? { state: truncateText(event.state, MAX_SHORT_FIELD_CHARS) } : {}),
...(event.agendaFingerprint
? { agendaFingerprint: truncateText(event.agendaFingerprint, MAX_SHORT_FIELD_CHARS) }
: {}),
...(typeof event.messagePreview === 'string'
? { messagePreview: truncateText(event.messagePreview, MAX_PREVIEW_CHARS) }
: {}),
...(event.diagnostics
? {
diagnostics: event.diagnostics
.slice(0, MAX_DIAGNOSTICS)
.map((diagnostic) => truncateText(diagnostic, MAX_SHORT_FIELD_CHARS)),
}
: {}),
...(event.triggerReasons
? {
triggerReasons: event.triggerReasons
.slice(0, MAX_TRIGGER_REASONS)
.map((reason) => truncateText(reason, MAX_SHORT_FIELD_CHARS)),
}
: {}),
...(event.taskRefs ? { taskRefs: sanitizeTaskRefs(event.taskRefs) } : {}),
...(event.metadata ? { metadata: sanitizeMetadata(event.metadata) } : {}),
};
}
function rotatedPath(filePath: string, index: number): string {
return `${filePath}.${index}`;
}
async function rotateIfNeeded(
filePath: string,
maxBytes: number,
rotatedFileCount: number
): Promise<void> {
const current = await stat(filePath).catch(() => null);
if (!current?.isFile() || current.size < maxBytes) {
return;
}
await rm(rotatedPath(filePath, rotatedFileCount), { force: true }).catch(() => undefined);
for (let index = rotatedFileCount - 1; index >= 1; index -= 1) {
await rename(rotatedPath(filePath, index), rotatedPath(filePath, index + 1)).catch(
() => undefined
);
}
await rename(filePath, rotatedPath(filePath, 1)).catch(() => undefined);
}
export class NoopMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort {
async append(): Promise<void> {
// Intentionally empty.
}
}
export class FileMemberWorkSyncAuditJournal implements MemberWorkSyncAuditJournalPort {
private readonly maxBytes: number;
private readonly rotatedFileCount: number;
private readonly appendChains = new Map<string, Promise<void>>();
constructor(
private readonly paths: MemberWorkSyncStorePaths,
private readonly logger?: MemberWorkSyncLoggerPort,
options: FileMemberWorkSyncAuditJournalOptions = {}
) {
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.rotatedFileCount = options.rotatedFileCount ?? DEFAULT_ROTATED_FILE_COUNT;
}
async append(event: MemberWorkSyncAuditEvent): Promise<void> {
const filePath = this.paths.getMemberJournalPath(event.teamName, event.memberName);
const previous = this.appendChains.get(filePath) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(() => this.appendToFile(filePath, event));
this.appendChains.set(filePath, next);
try {
await next;
} finally {
if (this.appendChains.get(filePath) === next) {
this.appendChains.delete(filePath);
}
}
}
private async appendToFile(filePath: string, event: MemberWorkSyncAuditEvent): Promise<void> {
try {
await this.paths.ensureMemberWorkSyncDir(event.teamName, event.memberName);
await mkdir(dirname(filePath), { recursive: true });
await withFileLock(filePath, async () => {
await rotateIfNeeded(filePath, this.maxBytes, this.rotatedFileCount);
await appendFile(filePath, `${JSON.stringify(sanitizeEvent(event))}\n`, 'utf8');
});
} catch (error) {
this.logger?.warn('member work sync audit journal append failed', {
teamName: event.teamName,
memberName: event.memberName,
event: event.event,
error: String(error),
});
}
}
}

View file

@ -0,0 +1,232 @@
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 { RuntimeTurnSettledProvider } from '../../core/domain';
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;
const DEFAULT_PROCESSING_STALE_MS = 5 * 60 * 1000;
export interface FileRuntimeTurnSettledEventStoreDeps {
paths: RuntimeTurnSettledSpoolPaths;
maxPayloadBytes?: number;
processedRetentionMs?: number;
processedRetentionCount?: number;
processingStaleMs?: number;
now?: () => Date;
}
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;
}
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 processingStaleMs: 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.processingStaleMs = deps.processingStaleMs ?? DEFAULT_PROCESSING_STALE_MS;
this.now = deps.now ?? (() => new Date());
}
async claimPending(limit: number): Promise<RuntimeTurnSettledClaimedPayload[]> {
await this.ensureDirectories();
await this.recoverStaleProcessingPayloads();
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 recoverStaleProcessingPayloads(): Promise<void> {
const cutoff = this.now().getTime() - this.processingStaleMs;
const entries = await readdir(this.deps.paths.getProcessingDir(), {
withFileTypes: true,
}).catch(() => []);
await Promise.allSettled(
entries
.filter(
(entry) =>
entry.isFile() && !entry.name.startsWith('.') && !entry.name.endsWith('.meta.json')
)
.map(async (entry) => {
const processingPath = path.join(this.deps.paths.getProcessingDir(), entry.name);
const fileStat = await stat(processingPath).catch(() => null);
if (!fileStat?.isFile() || fileStat.mtimeMs > cutoff) {
return;
}
await moveFileBestEffort(
processingPath,
path.join(this.deps.paths.getIncomingDir(), entry.name)
);
})
);
}
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

@ -0,0 +1,171 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises';
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import type {
MemberWorkSyncReportTokenCreateInput,
MemberWorkSyncReportTokenPort,
MemberWorkSyncReportTokenVerification,
MemberWorkSyncReportTokenVerifyInput,
} from '../../core/application';
import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths';
const TOKEN_PREFIX = 'wrs:v1';
const TOKEN_TTL_MS = 15 * 60 * 1000;
interface SecretFile {
schemaVersion: 1;
secret: string;
}
interface TokenPayload {
version: 1;
teamName: string;
memberName: string;
agendaFingerprint: string;
expiresAt: string;
}
function base64UrlEncode(value: string): string {
return Buffer.from(value, 'utf8').toString('base64url');
}
function base64UrlDecode(value: string): string {
return Buffer.from(value, 'base64url').toString('utf8');
}
function isSecretFile(value: unknown): value is SecretFile {
return (
value != null &&
typeof value === 'object' &&
(value as SecretFile).schemaVersion === 1 &&
typeof (value as SecretFile).secret === 'string' &&
(value as SecretFile).secret.length >= 32
);
}
function isTokenPayload(value: unknown): value is TokenPayload {
return (
value != null &&
typeof value === 'object' &&
(value as TokenPayload).version === 1 &&
typeof (value as TokenPayload).teamName === 'string' &&
typeof (value as TokenPayload).memberName === 'string' &&
typeof (value as TokenPayload).agendaFingerprint === 'string' &&
typeof (value as TokenPayload).expiresAt === 'string'
);
}
function safeEqual(left: string, right: string): boolean {
const leftBytes = Buffer.from(left);
const rightBytes = Buffer.from(right);
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
}
export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncReportTokenPort {
private readonly secretCache = new Map<string, Promise<string>>();
constructor(private readonly paths: MemberWorkSyncStorePaths) {}
async create(input: MemberWorkSyncReportTokenCreateInput): Promise<{
token: string;
expiresAt: string;
}> {
const expiresAt = new Date(Date.parse(input.issuedAt) + TOKEN_TTL_MS).toISOString();
const payload: TokenPayload = {
version: 1,
teamName: input.teamName,
memberName: input.memberName,
agendaFingerprint: input.agendaFingerprint,
expiresAt,
};
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signature = await this.sign(input.teamName, encodedPayload);
return {
token: `${TOKEN_PREFIX}.${encodedPayload}.${signature}`,
expiresAt,
};
}
async verify(
input: MemberWorkSyncReportTokenVerifyInput
): Promise<MemberWorkSyncReportTokenVerification> {
if (!input.token) {
return { ok: false, reason: 'missing' };
}
const [prefix, encodedPayload, signature, extra] = input.token.split('.');
if (prefix !== TOKEN_PREFIX || !encodedPayload || !signature || extra) {
return { ok: false, reason: 'invalid' };
}
const expectedSignature = await this.sign(input.teamName, encodedPayload);
if (!safeEqual(signature, expectedSignature)) {
return { ok: false, reason: 'invalid' };
}
let payload: unknown;
try {
payload = JSON.parse(base64UrlDecode(encodedPayload));
} catch {
return { ok: false, reason: 'invalid' };
}
if (!isTokenPayload(payload)) {
return { ok: false, reason: 'invalid' };
}
if (
payload.teamName !== input.teamName ||
payload.memberName !== input.memberName ||
payload.agendaFingerprint !== input.agendaFingerprint
) {
return { ok: false, reason: 'invalid' };
}
if (Date.parse(payload.expiresAt) <= Date.parse(input.nowIso)) {
return { ok: false, reason: 'expired' };
}
return { ok: true };
}
private async sign(teamName: string, encodedPayload: string): Promise<string> {
const secret = await this.getSecret(teamName);
return createHmac('sha256', secret).update(encodedPayload).digest('base64url');
}
private async getSecret(teamName: string): Promise<string> {
const existing = this.secretCache.get(teamName);
if (existing) {
return existing;
}
const next = this.loadOrCreateSecret(teamName);
this.secretCache.set(teamName, next);
return next;
}
private async loadOrCreateSecret(teamName: string): Promise<string> {
try {
const raw = await readFile(this.paths.getReportTokenSecretPath(teamName), 'utf8');
const parsed = JSON.parse(raw);
if (isSecretFile(parsed)) {
return parsed.secret;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
const secretFile: SecretFile = {
schemaVersion: 1,
secret: randomBytes(32).toString('base64url'),
};
await mkdir(this.paths.getTeamDir(teamName), { recursive: true });
await atomicWriteAsync(
this.paths.getReportTokenSecretPath(teamName),
JSON.stringify(secretFile, null, 2)
);
return secretFile.secret;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,309 @@
import type {
MemberWorkSyncAuditEvent,
MemberWorkSyncAuditJournalPort,
MemberWorkSyncLoggerPort,
} from '../../core/application';
import type { MemberWorkSyncReconcileContext } from '../../core/application/MemberWorkSyncReconciler';
export type MemberWorkSyncTriggerReason =
| 'startup_scan'
| 'config_changed'
| 'task_changed'
| 'inbox_changed'
| 'member_spawned'
| 'tool_finished'
| 'runtime_activity'
| 'turn_settled'
| 'manual_refresh';
export interface MemberWorkSyncQueueDiagnostics {
queued: number;
running: number;
enqueued: number;
coalesced: number;
reconciled: number;
dropped: number;
failed: number;
}
interface QueueItem {
teamName: string;
memberName: string;
runAt: number;
triggerReasons: Set<MemberWorkSyncTriggerReason>;
}
interface RunningItem {
rerunRequested: boolean;
triggerReasons: Set<MemberWorkSyncTriggerReason>;
}
export interface MemberWorkSyncEventQueueDeps {
reconcile(
input: { teamName: string; memberName: string },
context: MemberWorkSyncReconcileContext
): Promise<void>;
isTeamActive(teamName: string): Promise<boolean> | boolean;
quietWindowMs?: number;
concurrency?: number;
now?: () => number;
nowIso?: () => string;
auditJournal?: MemberWorkSyncAuditJournalPort;
logger?: MemberWorkSyncLoggerPort;
}
function keyOf(teamName: string, memberName: string): string {
return `${teamName}\0${memberName.trim().toLowerCase()}`;
}
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
timer.unref?.();
}
export class MemberWorkSyncEventQueue {
private readonly items = new Map<string, QueueItem>();
private readonly running = new Map<string, RunningItem>();
private readonly inFlight = new Set<Promise<void>>();
private readonly quietWindowMs: number;
private readonly concurrency: number;
private readonly now: () => number;
private readonly nowIso: () => string;
private timer: ReturnType<typeof setTimeout> | null = null;
private stopped = false;
private counters = {
enqueued: 0,
coalesced: 0,
reconciled: 0,
dropped: 0,
failed: 0,
};
constructor(private readonly deps: MemberWorkSyncEventQueueDeps) {
this.quietWindowMs = deps.quietWindowMs ?? 90_000;
this.concurrency = Math.max(1, deps.concurrency ?? 2);
this.now = deps.now ?? Date.now;
this.nowIso = deps.nowIso ?? (() => new Date().toISOString());
}
enqueue(input: {
teamName: string;
memberName: string;
triggerReason: MemberWorkSyncTriggerReason;
runAfterMs?: number;
}): void {
if (this.stopped) {
return;
}
const teamName = input.teamName.trim();
const memberName = input.memberName.trim();
if (!teamName || !memberName) {
this.counters.dropped += 1;
return;
}
const key = keyOf(teamName, memberName);
const runAt = this.now() + (input.runAfterMs ?? this.quietWindowMs);
const running = this.running.get(key);
if (running) {
running.rerunRequested = true;
running.triggerReasons.add(input.triggerReason);
this.counters.coalesced += 1;
this.appendAudit({
teamName,
memberName,
event: 'queue_coalesced',
source: 'event_queue',
reason: input.triggerReason,
});
return;
}
const existing = this.items.get(key);
if (existing) {
existing.triggerReasons.add(input.triggerReason);
existing.runAt = Math.max(existing.runAt, runAt);
this.counters.coalesced += 1;
this.appendAudit({
teamName,
memberName,
event: 'queue_coalesced',
source: 'event_queue',
reason: input.triggerReason,
});
this.schedule();
return;
}
this.items.set(key, {
teamName,
memberName,
runAt,
triggerReasons: new Set([input.triggerReason]),
});
this.counters.enqueued += 1;
this.appendAudit({
teamName,
memberName,
event: 'queue_enqueued',
source: 'event_queue',
reason: input.triggerReason,
});
this.schedule();
}
dropTeam(teamName: string): void {
for (const [key, item] of this.items) {
if (item.teamName === teamName) {
this.items.delete(key);
this.counters.dropped += 1;
}
}
this.schedule();
}
getDiagnostics(): MemberWorkSyncQueueDiagnostics {
return {
queued: this.items.size,
running: this.running.size,
...this.counters,
};
}
async stop(): Promise<void> {
this.stopped = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.items.clear();
await Promise.allSettled([...this.inFlight]);
}
private schedule(): void {
if (this.stopped) {
return;
}
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.items.size === 0) {
return;
}
if (this.running.size >= this.concurrency) {
return;
}
const nextRunAt = Math.min(...[...this.items.values()].map((item) => item.runAt));
const delayMs = Math.max(0, nextRunAt - this.now());
this.timer = setTimeout(() => {
this.timer = null;
this.pump();
}, delayMs);
unrefTimer(this.timer);
}
private pump(): void {
if (this.stopped) {
return;
}
const due = [...this.items.entries()]
.filter(([, item]) => item.runAt <= this.now())
.sort((left, right) => left[1].runAt - right[1].runAt);
for (const [key, item] of due) {
if (this.running.size >= this.concurrency) {
break;
}
this.items.delete(key);
this.runItem(key, item);
}
this.schedule();
}
private runItem(key: string, item: QueueItem): void {
const running: RunningItem = {
rerunRequested: false,
triggerReasons: new Set(item.triggerReasons),
};
this.running.set(key, running);
const promise = this.executeItem(key, item, running)
.catch((error: unknown) => {
this.counters.failed += 1;
this.deps.logger?.warn('member work sync queue reconcile failed', {
teamName: item.teamName,
memberName: item.memberName,
error: String(error),
});
})
.finally(() => {
this.running.delete(key);
this.inFlight.delete(promise);
if (running.rerunRequested && !this.stopped) {
for (const reason of running.triggerReasons) {
this.enqueue({
teamName: item.teamName,
memberName: item.memberName,
triggerReason: reason,
});
}
}
this.pump();
});
this.inFlight.add(promise);
}
private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise<void> {
if (!(await this.deps.isTeamActive(item.teamName))) {
this.counters.dropped += 1;
this.appendAudit({
teamName: item.teamName,
memberName: item.memberName,
event: 'queue_dropped',
source: 'event_queue',
reason: 'team_inactive',
});
return;
}
await this.deps.reconcile(
{ teamName: item.teamName, memberName: item.memberName },
{
reconciledBy: 'queue',
triggerReasons: [...running.triggerReasons].sort(),
}
);
this.counters.reconciled += 1;
this.appendAudit({
teamName: item.teamName,
memberName: item.memberName,
event: 'queue_reconciled',
source: 'event_queue',
triggerReasons: [...running.triggerReasons].sort(),
});
}
private appendAudit(input: Omit<MemberWorkSyncAuditEvent, 'timestamp'>): void {
if (!this.deps.auditJournal) {
return;
}
void this.deps.auditJournal
.append({
...input,
timestamp: this.nowIso(),
})
.catch((error: unknown) => {
this.deps.logger?.warn('member work sync queue audit append failed', {
teamName: input.teamName,
memberName: input.memberName,
event: input.event,
error: String(error),
});
});
}
}

View file

@ -0,0 +1,104 @@
import type {
MemberWorkSyncLoggerPort,
MemberWorkSyncNudgeDispatchSummary,
} from '../../core/application';
const DEFAULT_NUDGE_DISPATCH_INTERVAL_MS = 60_000;
function uniqueNonEmpty(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
timer.unref?.();
}
export interface MemberWorkSyncNudgeDispatchSchedulerDeps {
listLifecycleActiveTeamNames(): Promise<string[]>;
dispatchDue(teamNames: string[]): Promise<MemberWorkSyncNudgeDispatchSummary>;
intervalMs?: number;
logger?: MemberWorkSyncLoggerPort;
}
export class MemberWorkSyncNudgeDispatchScheduler {
private readonly intervalMs: number;
private timer: ReturnType<typeof setTimeout> | null = null;
private running: Promise<void> | null = null;
private stopped = false;
constructor(private readonly deps: MemberWorkSyncNudgeDispatchSchedulerDeps) {
this.intervalMs = Math.max(10_000, deps.intervalMs ?? DEFAULT_NUDGE_DISPATCH_INTERVAL_MS);
}
start(): void {
if (this.stopped || this.timer) {
return;
}
this.schedule(this.intervalMs);
}
async runOnce(): Promise<void> {
if (this.stopped) {
return;
}
if (this.running) {
await this.running;
return;
}
const work = this.dispatchOnce();
this.running = work;
try {
await work;
} finally {
if (this.running === work) {
this.running = null;
}
}
}
async dispose(): Promise<void> {
this.stopped = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.running) {
await this.running.catch(() => undefined);
}
}
private schedule(delayMs: number): void {
if (this.stopped) {
return;
}
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.timer = null;
void this.runOnce().finally(() => this.schedule(this.intervalMs));
}, delayMs);
unrefTimer(this.timer);
}
private async dispatchOnce(): Promise<void> {
try {
const teamNames = uniqueNonEmpty(await this.deps.listLifecycleActiveTeamNames());
if (teamNames.length === 0) {
return;
}
const summary = await this.deps.dispatchDue(teamNames);
if (summary.claimed > 0 || summary.delivered > 0 || summary.retryable > 0) {
this.deps.logger?.debug('member work sync scheduled nudge dispatch completed', {
teamCount: teamNames.length,
...summary,
});
}
} catch (error) {
this.deps.logger?.warn('member work sync scheduled nudge dispatch failed', {
error: String(error),
});
}
}
}

View file

@ -0,0 +1,94 @@
import { TeamMemberStoragePaths } from '@main/services/team/TeamMemberStoragePaths';
import { join } from 'path';
export class MemberWorkSyncStorePaths {
private readonly memberStorage: TeamMemberStoragePaths;
constructor(private readonly teamsBasePath: string) {
this.memberStorage = new TeamMemberStoragePaths(teamsBasePath);
}
getTeamRootDir(teamName: string): string {
return join(this.teamsBasePath, teamName);
}
getTeamDir(teamName: string): string {
return join(this.teamsBasePath, teamName, '.member-work-sync');
}
getStatusPath(teamName: string): string {
return join(this.getTeamDir(teamName), 'status.json');
}
getPendingReportsPath(teamName: string): string {
return join(this.getTeamDir(teamName), 'pending-reports.json');
}
getOutboxPath(teamName: string): string {
return join(this.getTeamDir(teamName), 'outbox.json');
}
getReportTokenSecretPath(teamName: string): string {
return join(this.getTeamDir(teamName), 'report-token-secret.json');
}
getIndexesDir(teamName: string): string {
return join(this.getTeamDir(teamName), 'indexes');
}
getMetricsIndexPath(teamName: string): string {
return join(this.getIndexesDir(teamName), 'metrics.json');
}
getOutboxIndexPath(teamName: string): string {
return join(this.getIndexesDir(teamName), 'outbox-index.json');
}
getPendingReportsIndexPath(teamName: string): string {
return join(this.getIndexesDir(teamName), 'pending-reports-index.json');
}
getLegacyStatusPath(teamName: string): string {
return this.getStatusPath(teamName);
}
getLegacyPendingReportsPath(teamName: string): string {
return this.getPendingReportsPath(teamName);
}
getLegacyOutboxPath(teamName: string): string {
return this.getOutboxPath(teamName);
}
getMemberKey(memberName: string): string {
return this.memberStorage.getMemberKey(memberName);
}
getMemberDir(teamName: string, memberName: string): string {
return this.memberStorage.getMemberDir(teamName, memberName);
}
getMemberWorkSyncDir(teamName: string, memberName: string): string {
return this.memberStorage.getMemberFeatureDir(teamName, memberName, '.member-work-sync');
}
getMemberStatusPath(teamName: string, memberName: string): string {
return join(this.getMemberWorkSyncDir(teamName, memberName), 'status.json');
}
getMemberReportsPath(teamName: string, memberName: string): string {
return join(this.getMemberWorkSyncDir(teamName, memberName), 'reports.json');
}
getMemberOutboxPath(teamName: string, memberName: string): string {
return join(this.getMemberWorkSyncDir(teamName, memberName), 'outbox.json');
}
getMemberJournalPath(teamName: string, memberName: string): string {
return join(this.getMemberWorkSyncDir(teamName, memberName), 'journal.jsonl');
}
async ensureMemberWorkSyncDir(teamName: string, memberName: string): Promise<void> {
await this.memberStorage.ensureMemberMeta(teamName, memberName);
}
}

View file

@ -0,0 +1,207 @@
import type { MemberWorkSyncBusySignalPort } from '../../core/application';
import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
const DEFAULT_TOOL_ACTIVITY_BUSY_GRACE_MS = 90_000;
interface MemberActivityState {
activeToolIds: Set<string>;
recentBusyUntilByToolId: Map<string, string>;
}
function memberKey(teamName: string, memberName: string): string {
return `${teamName}\0${memberName.trim().toLowerCase()}`;
}
function parseToolActivity(detail: string | undefined): ToolActivityEventPayload | null {
if (!detail) {
return null;
}
try {
const parsed = JSON.parse(detail) as ToolActivityEventPayload;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
function parseIsoMs(value: string | undefined, fallbackMs: number): number {
const parsed = value ? Date.parse(value) : NaN;
return Number.isFinite(parsed) ? parsed : fallbackMs;
}
function addMsIso(baseIso: string, ms: number): string {
return new Date(Date.parse(baseIso) + ms).toISOString();
}
function maxIso(values: Iterable<string>): string | null {
let max: string | null = null;
for (const value of values) {
if (!max || Date.parse(value) > Date.parse(max)) {
max = value;
}
}
return max;
}
export class MemberWorkSyncToolActivityBusySignal implements MemberWorkSyncBusySignalPort {
private readonly activityByMember = new Map<string, MemberActivityState>();
private readonly busyGraceMs: number;
constructor(options: { busyGraceMs?: number } = {}) {
this.busyGraceMs = Math.max(0, options.busyGraceMs ?? DEFAULT_TOOL_ACTIVITY_BUSY_GRACE_MS);
}
noteTeamChange(event: TeamChangeEvent): void {
if (event.type === 'lead-activity' && event.detail === 'offline') {
this.dropTeam(event.teamName);
return;
}
if (event.type !== 'tool-activity') {
return;
}
const payload = parseToolActivity(event.detail);
if (!payload) {
return;
}
if (payload.action === 'start' && payload.activity) {
this.noteStart(event.teamName, payload.activity.memberName, payload.activity.toolUseId);
return;
}
if (payload.action === 'finish' && payload.memberName && payload.toolUseId) {
this.noteFinish(event.teamName, payload.memberName, payload.toolUseId, payload.finishedAt);
return;
}
if (payload.action === 'reset') {
this.noteReset(event.teamName, payload.memberName, payload.toolUseIds);
}
}
async isBusy(input: {
teamName: string;
memberName: string;
nowIso: string;
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }> {
const key = memberKey(input.teamName, input.memberName);
const state = this.activityByMember.get(key);
if (!state) {
return { busy: false };
}
this.pruneState(key, state, input.nowIso);
if (state.activeToolIds.size > 0) {
return {
busy: true,
reason: 'active_tool_activity',
retryAfterIso: addMsIso(input.nowIso, this.busyGraceMs),
};
}
const retryAfterIso = maxIso(state.recentBusyUntilByToolId.values());
if (retryAfterIso) {
return {
busy: true,
reason: 'recent_tool_activity',
retryAfterIso,
};
}
return { busy: false };
}
private noteStart(teamName: string, memberName: string, toolUseId: string): void {
const normalizedToolUseId = toolUseId.trim();
if (!memberName.trim() || !normalizedToolUseId) {
return;
}
const state = this.getOrCreateState(teamName, memberName);
state.activeToolIds.add(normalizedToolUseId);
state.recentBusyUntilByToolId.delete(normalizedToolUseId);
}
private noteFinish(
teamName: string,
memberName: string,
toolUseId: string,
finishedAt: string | undefined
): void {
const normalizedToolUseId = toolUseId.trim();
if (!memberName.trim() || !normalizedToolUseId) {
return;
}
const finishedAtMs = parseIsoMs(finishedAt, Date.now());
const busyUntilIso = new Date(finishedAtMs + this.busyGraceMs).toISOString();
const state = this.getOrCreateState(teamName, memberName);
state.activeToolIds.delete(normalizedToolUseId);
state.recentBusyUntilByToolId.set(normalizedToolUseId, busyUntilIso);
}
private noteReset(teamName: string, memberName?: string, toolUseIds?: string[]): void {
const normalizedMemberName = memberName?.trim();
if (!normalizedMemberName) {
this.dropTeam(teamName);
return;
}
const key = memberKey(teamName, normalizedMemberName);
const state = this.activityByMember.get(key);
if (!state) {
return;
}
const normalizedToolUseIds = new Set(
(toolUseIds ?? []).map((toolUseId) => toolUseId.trim()).filter(Boolean)
);
if (normalizedToolUseIds.size === 0) {
this.activityByMember.delete(key);
return;
}
for (const toolUseId of normalizedToolUseIds) {
state.activeToolIds.delete(toolUseId);
state.recentBusyUntilByToolId.delete(toolUseId);
}
if (state.activeToolIds.size === 0 && state.recentBusyUntilByToolId.size === 0) {
this.activityByMember.delete(key);
}
}
private getOrCreateState(teamName: string, memberName: string): MemberActivityState {
const key = memberKey(teamName, memberName);
const existing = this.activityByMember.get(key);
if (existing) {
return existing;
}
const created: MemberActivityState = {
activeToolIds: new Set(),
recentBusyUntilByToolId: new Map(),
};
this.activityByMember.set(key, created);
return created;
}
private pruneState(key: string, state: MemberActivityState, nowIso: string): void {
const nowMs = Date.parse(nowIso);
for (const [toolUseId, busyUntilIso] of state.recentBusyUntilByToolId) {
if (Date.parse(busyUntilIso) <= nowMs) {
state.recentBusyUntilByToolId.delete(toolUseId);
}
}
if (state.activeToolIds.size === 0 && state.recentBusyUntilByToolId.size === 0) {
this.activityByMember.delete(key);
}
}
private dropTeam(teamName: string): void {
for (const key of this.activityByMember.keys()) {
if (key.startsWith(`${teamName}\0`)) {
this.activityByMember.delete(key);
}
}
}
}

View file

@ -0,0 +1,9 @@
import { createHash } from 'crypto';
import type { MemberWorkSyncHashPort } from '../../core/application';
export class NodeHashAdapter implements MemberWorkSyncHashPort {
sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
}

View file

@ -0,0 +1,124 @@
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,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,47 @@
import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment';
import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings';
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller';
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

@ -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,69 @@
import fs from 'fs/promises';
import type { 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');
// eslint-disable-next-line sonarjs/file-permissions -- the hook script must be executable on POSIX hosts.
await fs.chmod(scriptPath, 0o755);
return {
scriptPath,
spoolRoot: this.paths.getRootDir(),
};
}
}

View file

@ -0,0 +1,7 @@
import type { MemberWorkSyncClockPort } from '../../core/application';
export class SystemClockAdapter implements MemberWorkSyncClockPort {
now(): Date {
return new Date();
}
}

View file

@ -0,0 +1,15 @@
import type { RuntimeTurnSettledProvider } from '../../core/domain';
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' && input.provider !== 'opencode') {
return null;
}
return {
[RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV]: input.spoolRoot,
};
}

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

@ -0,0 +1,27 @@
import {
MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REPORT,
type MemberWorkSyncMetricsRequest,
type MemberWorkSyncReportRequest,
type MemberWorkSyncReportResult,
type MemberWorkSyncStatus,
type MemberWorkSyncStatusRequest,
type MemberWorkSyncTeamMetrics,
} from '../contracts';
import type { IpcRenderer } from 'electron';
export interface MemberWorkSyncElectronApi {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
}
export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi {
return {
getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request),
getMetrics: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_METRICS, request),
report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request),
};
}

View file

@ -0,0 +1,91 @@
import type { MemberWorkSyncStatus } from '../../contracts';
export type MemberWorkSyncViewTone = 'neutral' | 'success' | 'working' | 'attention' | 'blocked';
export interface MemberWorkSyncStatusViewModel {
label: 'Synced' | 'Working' | 'Needs sync' | 'Blocked' | 'Unknown';
tone: MemberWorkSyncViewTone;
actionableCount: number;
tooltip: string;
fingerprint?: string;
leaseExpiresAt?: string;
reportState?: string;
wouldNudge?: boolean;
}
function describeAgenda(count: number): string {
if (count === 0) {
return 'No actionable work items.';
}
if (count === 1) {
return '1 actionable work item.';
}
return `${count} actionable work items.`;
}
export function toMemberWorkSyncStatusViewModel(
status: MemberWorkSyncStatus | null | undefined
): MemberWorkSyncStatusViewModel {
if (!status) {
return {
label: 'Unknown',
tone: 'neutral',
actionableCount: 0,
tooltip: 'Member work sync status has not been evaluated yet.',
};
}
const actionableCount = status.agenda.items.length;
const base = {
actionableCount,
fingerprint: status.agenda.fingerprint,
...(status.report?.expiresAt ? { leaseExpiresAt: status.report.expiresAt } : {}),
...(status.report?.state ? { reportState: status.report.state } : {}),
...(status.shadow ? { wouldNudge: status.shadow.wouldNudge } : {}),
};
if (status.state === 'caught_up') {
return {
...base,
label: 'Synced',
tone: 'success',
tooltip: `Synced with current work agenda. ${describeAgenda(actionableCount)}`,
};
}
if (status.state === 'still_working') {
return {
...base,
label: 'Working',
tone: 'working',
tooltip: `Member reported still working on current agenda. ${describeAgenda(actionableCount)}`,
};
}
if (status.state === 'blocked') {
return {
...base,
label: 'Blocked',
tone: 'blocked',
tooltip: `Member reported blocked on current agenda. ${describeAgenda(actionableCount)}`,
};
}
if (status.state === 'needs_sync') {
return {
...base,
label: 'Needs sync',
tone: 'attention',
tooltip: `Shadow status only: current agenda has no valid member report. ${describeAgenda(
actionableCount
)}`,
};
}
return {
...base,
label: 'Unknown',
tone: 'neutral',
tooltip: `Member work sync is not active for this member. ${describeAgenda(actionableCount)}`,
};
}

View file

@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import {
type MemberWorkSyncStatusViewModel,
toMemberWorkSyncStatusViewModel,
} from '../adapters/memberWorkSyncStatusViewModel';
import type { MemberWorkSyncStatus } from '../../contracts';
export interface UseMemberWorkSyncStatusOptions {
teamName?: string | null;
memberName?: string | null;
enabled?: boolean;
}
export interface UseMemberWorkSyncStatusResult {
status: MemberWorkSyncStatus | null;
viewModel: MemberWorkSyncStatusViewModel;
loading: boolean;
error: string | null;
refresh: () => void;
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Failed to load member work sync status.';
}
export function useMemberWorkSyncStatus({
teamName,
memberName,
enabled = true,
}: UseMemberWorkSyncStatusOptions): UseMemberWorkSyncStatusResult {
const [status, setStatus] = useState<MemberWorkSyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
const normalizedTeamName = teamName?.trim();
const normalizedMemberName = memberName?.trim();
if (!enabled || !normalizedTeamName || !normalizedMemberName) {
setStatus(null);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setStatus((current) =>
current?.teamName === normalizedTeamName && current.memberName === normalizedMemberName
? current
: null
);
api.memberWorkSync
.getStatus({ teamName: normalizedTeamName, memberName: normalizedMemberName })
.then((nextStatus) => {
if (!cancelled) {
setStatus(nextStatus);
}
})
.catch((nextError: unknown) => {
if (!cancelled) {
setStatus(null);
setError(getErrorMessage(nextError));
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [enabled, memberName, refreshKey, teamName]);
return {
status,
viewModel: toMemberWorkSyncStatusViewModel(status),
loading,
error,
refresh: () => setRefreshKey((current) => current + 1),
};
}

View file

@ -0,0 +1,5 @@
export * from './adapters/memberWorkSyncStatusViewModel';
export * from './hooks/useMemberWorkSyncStatus';
export * from './ui/MemberWorkSyncBadge';
export * from './ui/MemberWorkSyncDetails';
export * from './ui/MemberWorkSyncStatusPanel';

View file

@ -0,0 +1,46 @@
import { Badge } from '@renderer/components/ui/badge';
import { cn } from '@renderer/lib/utils';
import {
type MemberWorkSyncStatusViewModel,
toMemberWorkSyncStatusViewModel,
} from '../adapters/memberWorkSyncStatusViewModel';
import type { MemberWorkSyncStatus } from '../../contracts';
import type React from 'react';
type MemberWorkSyncBadgeProps = Readonly<{
status?: MemberWorkSyncStatus | null;
viewModel?: MemberWorkSyncStatusViewModel;
className?: string;
}>;
const toneClassName: Record<MemberWorkSyncStatusViewModel['tone'], string> = {
neutral: 'border-[var(--color-border)] text-[var(--color-text-muted)]',
success: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-300',
working: 'border-sky-500/25 bg-sky-500/10 text-sky-300',
attention: 'border-amber-500/25 bg-amber-500/10 text-amber-200',
blocked: 'border-red-500/25 bg-red-500/10 text-red-300',
};
export function MemberWorkSyncBadge({
status,
viewModel,
className,
}: MemberWorkSyncBadgeProps): React.ReactElement {
const resolved = viewModel ?? toMemberWorkSyncStatusViewModel(status);
return (
<Badge
variant="outline"
className={cn(
'cursor-default whitespace-nowrap font-medium',
toneClassName[resolved.tone],
className
)}
title={resolved.tooltip}
>
{resolved.label}
</Badge>
);
}

View file

@ -0,0 +1,85 @@
import { toMemberWorkSyncStatusViewModel } from '../adapters/memberWorkSyncStatusViewModel';
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
import type { MemberWorkSyncStatus } from '../../contracts';
import type React from 'react';
type MemberWorkSyncDetailsProps = Readonly<{
status: MemberWorkSyncStatus | null;
showDiagnostics?: boolean;
}>;
function shortFingerprint(fingerprint?: string): string {
if (!fingerprint) {
return 'unknown';
}
const suffix = fingerprint.split(':').at(-1) ?? fingerprint;
return suffix.length > 12 ? `${suffix.slice(0, 12)}...` : suffix;
}
export function MemberWorkSyncDetails({
status,
showDiagnostics = false,
}: MemberWorkSyncDetailsProps): React.ReactElement {
const viewModel = toMemberWorkSyncStatusViewModel(status);
const agendaItems = status?.agenda.items ?? [];
return (
<section className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text)]">Member work sync</h3>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{viewModel.tooltip}</p>
</div>
<MemberWorkSyncBadge viewModel={viewModel} />
</div>
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div>
<dt className="text-[var(--color-text-muted)]">Actionable items</dt>
<dd className="font-medium text-[var(--color-text)]">{viewModel.actionableCount}</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">Fingerprint</dt>
<dd className="font-mono text-[var(--color-text)]">
{shortFingerprint(viewModel.fingerprint)}
</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">Report</dt>
<dd className="font-medium text-[var(--color-text)]">
{viewModel.reportState ?? 'none'}
</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">Shadow would nudge</dt>
<dd className="font-medium text-[var(--color-text)]">
{viewModel.wouldNudge ? 'yes' : 'no'}
</dd>
</div>
</dl>
{agendaItems.length > 0 ? (
<ul className="mt-3 space-y-1 text-xs text-[var(--color-text-secondary)]">
{agendaItems.slice(0, 3).map((item) => (
<li key={`${item.kind}:${item.taskId}`} className="truncate">
#{item.displayId ?? item.taskId.slice(0, 8)} - {item.kind} - {item.subject}
</li>
))}
{agendaItems.length > 3 ? (
<li className="text-[var(--color-text-muted)]">
{agendaItems.length - 3} more actionable item(s)
</li>
) : null}
</ul>
) : null}
{showDiagnostics && status?.diagnostics.length ? (
<p className="mt-3 text-xs text-[var(--color-text-muted)]">
Diagnostics: {status.diagnostics.join(', ')}
</p>
) : null}
</section>
);
}

View file

@ -0,0 +1,52 @@
import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus';
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
import { MemberWorkSyncDetails } from './MemberWorkSyncDetails';
import type React from 'react';
type MemberWorkSyncStatusPanelProps = Readonly<{
teamName: string;
memberName: string;
enabled?: boolean;
showDiagnostics?: boolean;
}>;
export function MemberWorkSyncStatusPanel({
teamName,
memberName,
enabled = true,
showDiagnostics = false,
}: MemberWorkSyncStatusPanelProps): React.ReactElement | null {
const { status, viewModel, loading, error } = useMemberWorkSyncStatus({
teamName,
memberName,
enabled,
});
if (!enabled) {
return null;
}
if (status) {
return <MemberWorkSyncDetails status={status} showDiagnostics={showDiagnostics} />;
}
return (
<section className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text)]">Member work sync</h3>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
{loading
? 'Loading member work sync diagnostics.'
: error
? 'Member work sync diagnostics are unavailable.'
: viewModel.tooltip}
</p>
</div>
<MemberWorkSyncBadge viewModel={viewModel} />
</div>
</section>
);
}

View file

@ -35,6 +35,7 @@ import type {
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
import type { TeamDataService } from '../services/team/TeamDataService';
import type { TeamProvisioningService } from '../services/team/TeamProvisioningService';
import type { MemberWorkSyncFeatureFacade } from '@features/member-work-sync/main';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:routes');
@ -46,6 +47,7 @@ export interface HttpServices {
chunkBuilder: ChunkBuilder;
dataCache: DataCache;
recentProjectsFeature?: RecentProjectsFeatureFacade;
memberWorkSyncFeature?: MemberWorkSyncFeatureFacade;
updaterService: UpdaterService;
sshConnectionManager: SshConnectionManager;
teamDataService?: TeamDataService;

View file

@ -14,6 +14,7 @@ import { access } from 'fs/promises';
import { isAbsolute, join } from 'path';
import type { HttpServices } from './index';
import type { MemberWorkSyncReportState } from '@features/member-work-sync/contracts';
import type {
EffortLevel,
TeamCreateConfigRequest,
@ -31,6 +32,10 @@ type CreateTeamBody = TeamCreateConfigRequest;
class HttpBadRequestError extends Error {}
class HttpFeatureUnavailableError extends Error {}
function isMemberWorkSyncReportState(value: string): value is MemberWorkSyncReportState {
return value === 'still_working' || value === 'blocked' || value === 'caught_up';
}
function getTeamProvisioningService(
services: HttpServices
): NonNullable<HttpServices['teamProvisioningService']> {
@ -466,6 +471,15 @@ function withRuntimeTeamName(teamName: string, body: unknown): Record<string, un
return { ...payload, teamName };
}
function getMemberWorkSyncFeature(
services: HttpServices
): NonNullable<HttpServices['memberWorkSyncFeature']> {
if (!services.memberWorkSyncFeature) {
throw new HttpBadRequestError('Member work sync feature is unavailable');
}
return services.memberWorkSyncFeature;
}
export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void {
app.get('/api/teams', async (_request, reply) => {
try {
@ -736,4 +750,113 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
}
}
);
app.get<{ Params: { teamName: string } }>(
'/api/teams/:teamName/member-work-sync/metrics',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
return reply.send(
await getMemberWorkSyncFeature(services).getMetrics({
teamName: validatedTeamName.value!,
})
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in GET /api/teams/${request.params.teamName}/member-work-sync/metrics:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.get<{ Params: { teamName: string; memberName: string } }>(
'/api/teams/:teamName/member-work-sync/:memberName',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
const memberName = request.params.memberName?.trim();
if (!memberName) {
return reply.status(400).send({ error: 'memberName is required' });
}
return reply.send(
await getMemberWorkSyncFeature(services).getStatus({
teamName: validatedTeamName.value!,
memberName,
})
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in GET /api/teams/${request.params.teamName}/member-work-sync/${request.params.memberName}:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
'/api/teams/:teamName/member-work-sync/report',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
const payload = withRuntimeTeamName(validatedTeamName.value!, request.body);
const memberName = typeof payload.memberName === 'string' ? payload.memberName.trim() : '';
const state = typeof payload.state === 'string' ? payload.state.trim() : '';
const agendaFingerprint =
typeof payload.agendaFingerprint === 'string' ? payload.agendaFingerprint.trim() : '';
if (!memberName || !state || !agendaFingerprint) {
return reply.status(400).send({
error: 'memberName, state, and agendaFingerprint are required',
});
}
if (!isMemberWorkSyncReportState(state)) {
return reply
.status(400)
.send({ error: 'state must be still_working, blocked, or caught_up' });
}
const taskIds = Array.isArray(payload.taskIds)
? payload.taskIds.filter((taskId): taskId is string => typeof taskId === 'string')
: undefined;
return reply.send(
await getMemberWorkSyncFeature(services).report({
teamName: validatedTeamName.value!,
memberName,
state,
agendaFingerprint,
...(typeof payload.reportToken === 'string'
? { reportToken: payload.reportToken }
: {}),
...(taskIds ? { taskIds } : {}),
...(typeof payload.note === 'string' ? { note: payload.note } : {}),
...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}),
...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}),
source: 'mcp',
})
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/member-work-sync/report:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
}

View file

@ -30,6 +30,13 @@ import {
type CodexModelCatalogFeatureFacade,
createCodexModelCatalogFeature,
} from '@features/codex-model-catalog/main';
import {
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature,
type MemberWorkSyncFeatureFacade,
registerMemberWorkSyncIpc,
removeMemberWorkSyncIpc,
} from '@features/member-work-sync/main';
import {
createRecentProjectsFeature,
type RecentProjectsFeatureFacade,
@ -173,16 +180,20 @@ import {
SshConnectionManager,
TaskBoundaryParser,
TeamDataService,
TeamKanbanManager,
TeamLogSourceTracker,
TeammateToolTracker,
TeamMemberLogsFinder,
TeamMembersMetaStore,
TeamProvisioningService,
TeamRuntimeAdapterRegistry,
TeamTaskReader,
TeamTaskStallJournal,
TeamTaskStallMonitor,
TeamTaskStallNotifier,
TeamTaskStallPolicy,
TeamTaskStallSnapshotSource,
TeamTranscriptSourceLocator,
UpdaterService,
} from './services';
@ -237,12 +248,28 @@ 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];
if (mcpEntry) {
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
}
} catch (error) {
logger.warn(
@ -558,6 +585,7 @@ let codexAccountFeature: CodexAccountFeatureFacade | null = null;
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
let recentProjectsFeature: RecentProjectsFeatureFacade;
let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
let teamDataService: TeamDataService;
let teamProvisioningService: TeamProvisioningService;
let cliInstallerService: CliInstallerService;
@ -777,6 +805,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
const teamName = row.teamName.trim();
const detail = typeof row.detail === 'string' ? row.detail : '';
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
if (
teamDataService &&
@ -1024,7 +1053,14 @@ async function initializeServices(): Promise<void> {
cliInstallerService = new CliInstallerService();
ptyTerminalService = new PtyTerminalService();
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator();
teamLogSourceTracker.onLogSourceChange((teamName) => {
teamTranscriptSourceLocator.invalidateTeam(teamName);
});
const boardTaskActivityRecordSource = new BoardTaskActivityRecordSource(
teamTranscriptSourceLocator
);
const boardTaskActivityService = new BoardTaskActivityService(boardTaskActivityRecordSource);
const boardTaskActivityDetailService = new BoardTaskActivityDetailService(
boardTaskActivityRecordSource
@ -1033,7 +1069,15 @@ async function initializeServices(): Promise<void> {
const boardTaskExactLogDetailService = new BoardTaskExactLogDetailService(
boardTaskActivityRecordSource
);
const boardTaskLogStreamService = new BoardTaskLogStreamService(boardTaskActivityRecordSource);
const boardTaskLogStreamService = new BoardTaskLogStreamService(
boardTaskActivityRecordSource,
undefined,
undefined,
undefined,
undefined,
undefined,
teamTranscriptSourceLocator
);
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
teamMemberLogsFinder
);
@ -1073,10 +1117,9 @@ async function initializeServices(): Promise<void> {
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
teamTaskStallMonitor = new TeamTaskStallMonitor(
new ActiveTeamRegistry(teamDataService, teamLogSourceTracker),
new TeamTaskStallSnapshotSource(),
new TeamTaskStallSnapshotSource(teamTranscriptSourceLocator),
new TeamTaskStallPolicy(),
new TeamTaskStallJournal(),
new TeamTaskStallNotifier(teamDataService, teamProvisioningService)
@ -1172,6 +1215,7 @@ async function initializeServices(): Promise<void> {
const teamChangeEmitter = (event: TeamChangeEvent): void => {
forwardTeamChange(event);
teamTaskStallMonitor?.noteTeamChange(event);
memberWorkSyncFeature?.noteTeamChange(event);
if (event.type === 'lead-activity' && event.detail === 'offline') {
teammateToolTracker?.handleTeamOffline(event.teamName);
}
@ -1215,6 +1259,48 @@ async function initializeServices(): Promise<void> {
logger: createLogger('Feature:RecentProjects'),
});
runtimeProviderManagementFeature = createRuntimeProviderManagementFeature();
memberWorkSyncFeature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader: new TeamTaskReader(),
kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(),
isTeamActive: (teamName) =>
teamProvisioningService.isTeamAlive(teamName) ||
teamProvisioningService.hasProvisioningRun(teamName),
listLifecycleActiveTeamNames: async () => {
const teams = await teamDataService.listTeams();
return teams
.filter(
(team) =>
!team.deletedAt &&
(teamProvisioningService.isTeamAlive(team.teamName) ||
teamProvisioningService.hasProvisioningRun(team.teamName))
)
.map((team) => team.teamName);
},
logger: createLogger('Feature:MemberWorkSync'),
});
teamProvisioningService.setRuntimeTurnSettledHookSettingsProvider((input) =>
memberWorkSyncFeature
? memberWorkSyncFeature.buildRuntimeTurnSettledHookSettings(input)
: Promise.resolve(null)
);
teamProvisioningService.setRuntimeTurnSettledEnvironmentProvider((input) =>
memberWorkSyncFeature
? memberWorkSyncFeature.buildRuntimeTurnSettledEnvironment(input)
: Promise.resolve(null)
);
void teamDataService
.listTeams()
.then(async (teams) => {
const activeTeamNames = teams.filter((team) => !team.deletedAt).map((team) => team.teamName);
await memberWorkSyncFeature?.replayPendingReports(activeTeamNames);
await memberWorkSyncFeature?.enqueueStartupScan(activeTeamNames);
})
.catch((error: unknown) =>
logger.warn(`[Init] Member work sync startup scan failed: ${String(error)}`)
);
codexAccountFeature = createCodexAccountFeature({
logger: createLogger('Feature:CodexAccount'),
configManager,
@ -1282,6 +1368,7 @@ async function initializeServices(): Promise<void> {
registerCodexAccountIpc(ipcMain, codexAccountFeature);
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature);
// Forward SSH state changes to renderer and HTTP SSE clients
sshConnectionManager.on('state-change', (status: unknown) => {
@ -1335,6 +1422,7 @@ async function startHttpServer(
chunkBuilder: activeContext.chunkBuilder,
dataCache: activeContext.dataCache,
recentProjectsFeature,
memberWorkSyncFeature: memberWorkSyncFeature ?? undefined,
updaterService,
sshConnectionManager,
teamDataService,
@ -1457,6 +1545,8 @@ async function shutdownServices(): Promise<void> {
codexModelCatalogFeature = null;
await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose());
codexAccountFeature = null;
await runShutdownStep('member work sync dispose', () => memberWorkSyncFeature?.dispose());
memberWorkSyncFeature = null;
if (ptyTerminalService) {
await runShutdownStep('PTY terminals kill', () => ptyTerminalService.killAll());
@ -1467,6 +1557,7 @@ async function shutdownServices(): Promise<void> {
removeCodexAccountIpc(ipcMain);
removeRecentProjectsIpc(ipcMain);
removeRuntimeProviderManagementIpc(ipcMain);
removeMemberWorkSyncIpc(ipcMain);
});
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());

View file

@ -1893,13 +1893,15 @@ async function handleLaunchTeam(
const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId;
const effortValidation = parseOptionalTeamEffort(
payload.effort ?? savedRequest.effort,
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort,
resolvedProviderId
);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode ?? savedRequest.fastMode);
const fastModeValidation = parseOptionalTeamFastMode(
Object.hasOwn(payload, 'fastMode') ? payload.fastMode : savedRequest.fastMode
);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };
}
@ -1963,20 +1965,16 @@ async function handleLaunchTeam(
if (!launchProviderBackendValidation.valid) {
return { success: false, error: launchProviderBackendValidation.error };
}
const rawLaunchEffort =
payload.effort ??
persistedMeta?.effort ??
persistedMeta?.launchIdentity?.selectedEffort ??
undefined;
const rawLaunchEffort = Object.hasOwn(payload, 'effort')
? payload.effort
: (persistedMeta?.effort ?? persistedMeta?.launchIdentity?.selectedEffort ?? undefined);
const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId);
if (!effortValidation.valid) {
return { success: false, error: effortValidation.error };
}
const rawLaunchFastMode =
payload.fastMode ??
persistedMeta?.fastMode ??
persistedMeta?.launchIdentity?.selectedFastMode ??
undefined;
const rawLaunchFastMode = Object.hasOwn(payload, 'fastMode')
? payload.fastMode
: (persistedMeta?.fastMode ?? persistedMeta?.launchIdentity?.selectedFastMode ?? undefined);
const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode);
if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error };

View file

@ -4,14 +4,24 @@ import { createLogger } from '@shared/utils/logger';
const logger = createLogger('Perf:EventLoop');
const DEFAULT_MAX_STALL_THRESHOLD_MS = 750;
const DEFAULT_REPORT_INTERVAL_MS = 30_000;
let started = false;
let currentOp: string | null = null;
let lastReportAt = 0;
function isEnabled(): boolean {
const raw = process.env.CLAUDE_TEAM_EVENT_LOOP_LAG_MONITOR_ENABLED?.trim().toLowerCase();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
export function setCurrentMainOp(op: string | null): void {
currentOp = op;
}
export function startEventLoopLagMonitor(): void {
if (!isEnabled()) return;
if (started) return;
started = true;
@ -24,14 +34,19 @@ export function startEventLoopLagMonitor(): void {
// Reset first so next window is clean even if logging throws
h.reset();
// Only report meaningful stalls
if (maxMs < 250) return;
// Only report severe stalls. Sub-second blips are common during expected
// Electron/main-process IO and are too noisy for default development logs.
if (maxMs < DEFAULT_MAX_STALL_THRESHOLD_MS) return;
// For known IPC/main-thread operations we already emit operation-specific
// timing diagnostics. Suppress the generic event-loop warning to avoid
// duplicate noisy logs that do not add new debugging value.
if (currentOp) return;
const now = Date.now();
if (now - lastReportAt < DEFAULT_REPORT_INTERVAL_MS) return;
lastReportAt = now;
logger.warn(
`Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` +
(currentOp ? ` op=${currentOp}` : '')

View file

@ -423,6 +423,10 @@ export class ProviderConnectionService {
connection: await this.getConnectionInfo(provider.providerId),
};
if (provider.providerId === 'anthropic') {
return this.enrichAnthropicProviderStatus(withConnection);
}
if (provider.providerId !== 'codex') {
return withConnection;
}
@ -480,6 +484,32 @@ export class ProviderConnectionService {
}
}
private enrichAnthropicProviderStatus(provider: CliProviderStatus): CliProviderStatus {
const connection = provider.connection;
if (connection?.configuredAuthMode !== 'api_key') {
return provider;
}
if (connection.apiKeyConfigured) {
return {
...provider,
authenticated: true,
authMethod: 'api_key',
verificationState:
provider.verificationState === 'error' ? provider.verificationState : 'verified',
statusMessage: 'Connected via API key',
};
}
return {
...provider,
authenticated: false,
authMethod: null,
verificationState: provider.verificationState === 'error' ? 'error' : 'unknown',
statusMessage: 'API key mode is selected, but no Anthropic API credential is available yet.',
};
}
async enrichProviderStatuses(providers: CliProviderStatus[]): Promise<CliProviderStatus[]> {
return Promise.all(providers.map((provider) => this.enrichProviderStatus(provider)));
}

View file

@ -1,5 +1,7 @@
type JsonObject = Record<string, unknown>;
type JsonArray = unknown[];
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@ -13,10 +15,80 @@ function parseJsonSettingsObject(raw: string): JsonObject | null {
}
}
function isHookEntry(value: unknown): value is JsonObject {
return isJsonObject(value) && Array.isArray(value.hooks);
}
function getHookEntryDedupeKey(value: unknown): string | null {
if (!isHookEntry(value)) {
return 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;
}
return JSON.stringify({
matcher: typeof value.matcher === 'string' ? value.matcher : '',
commands,
});
}
function mergeHookEntryArrays(target: JsonArray, source: JsonArray): JsonArray {
const merged = [...target];
const seen = new Set<string>();
for (const entry of merged) {
const key = getHookEntryDedupeKey(entry);
if (key) {
seen.add(key);
}
}
for (const entry of source) {
const key = getHookEntryDedupeKey(entry);
if (key && seen.has(key)) {
continue;
}
merged.push(entry);
if (key) {
seen.add(key);
}
}
return merged;
}
function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject {
const merged: JsonObject = { ...target };
for (const [hookName, sourceValue] of Object.entries(source)) {
const currentValue = merged[hookName];
if (Array.isArray(currentValue) && Array.isArray(sourceValue)) {
merged[hookName] = mergeHookEntryArrays(currentValue, sourceValue);
continue;
}
if (isJsonObject(currentValue) && isJsonObject(sourceValue)) {
merged[hookName] = deepMergeJsonObjects(currentValue, sourceValue);
continue;
}
merged[hookName] = sourceValue;
}
return merged;
}
function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject {
const merged: JsonObject = { ...target };
for (const [key, value] of Object.entries(source)) {
const current = merged[key];
if (key === 'hooks' && isJsonObject(current) && isJsonObject(value)) {
merged[key] = mergeHooksObject(current, value);
continue;
}
if (isJsonObject(current) && isJsonObject(value)) {
merged[key] = deepMergeJsonObjects(current, value);
continue;

View file

@ -1185,7 +1185,7 @@ function mapRejectedHunkIndicesByHashStrict(
error: 'Ledger partial reject hunk context is ambiguous; please re-review.',
};
}
out.add(candidates[0]!);
out.add(candidates[0]);
}
return { ok: true, indices: [...out].sort((a, b) => a - b) };
}

Some files were not shown because too many files have changed in this diff Show more